Module 4: Coordinated Multi-File Refactoring
Interface Changes and Change Propagation
Interface Changes and Change Propagation
Capsule description
Of the four types of refactoring this module covers, interface changes is the most complex and the most impactful. When you change a function's signature — its parameters, its return type, or its method name in a base class — every caller, implementation, and test that uses it needs to be updated. A change in one place propagates to dozens of files.
In this capsule you're going to learn to execute interface changes with Claude Code as a coordinator. The key is understanding the cascade: a change to the interface flows downward (implementations) and upward (callers). Claude Code can trace that complete cascade and make the updates coherently.
This is the type of refactoring where Claude Code offers the most value. Manually, tracing all the callers of a function used across 20 files is tedious and error-prone. Claude Code does it completely and consistently.
What an Interface Change Is
Definition
An interface change modifies the "contract" of a function, method, or class: what it receives, what it returns, or how it's called. All code that depends on that contract must adapt.
Types of interface changes
# Type 1: Add a parameter
# Before:
def create_user(name: str, email: str) -> User:
# After:
def create_user(name: str, email: str, role: str = "user") -> User:
# Impact: all callers can keep working (default value)
# Risk: LOW
# Type 2: Change a mandatory parameter
# Before:
def create_user(name: str, email: str) -> User:
# After:
def create_user(user_data: UserCreateRequest) -> User:
# Impact: ALL callers must change
# Risk: HIGH
# Type 3: Change the return type
# Before:
def get_user(user_id: int) -> dict:
# After:
def get_user(user_id: int) -> User:
# Impact: all callers that access the result must adapt
# Risk: MEDIUM
# Type 4: Change a base class method
# Before (in the base class):
class BaseRepository:
def find(self, id: int) -> dict:
# After:
class BaseRepository:
def find_by_id(self, id: int) -> Model:
# Impact: ALL subclasses + all callers of all subclasses
# Risk: VERY HIGH
Interface Change with a New Parameter (Low Risk)
With a default value (non-breaking)
# Prompt to Claude Code:
> "Add a 'role' parameter to create_user() in
src/services/user_service.py. The parameter should be
optional with a default value of 'user'. Find all the
callers that should pass an explicit role (like
the admin routes) and update them."
# Before:
def create_user(name: str, email: str) -> User:
user = User(name=name, email=email, role="user")
db.session.add(user)
return user
# After:
def create_user(name: str, email: str, role: str = "user") -> User:
user = User(name=name, email=email, role=role)
db.session.add(user)
return user
# Updated callers:
# src/api/routes/admin.py:
# Before: create_user(name, email) # always created with role="user"
# After: create_user(name, email, role="admin")
Without a default value (breaking change)
# Prompt:
> "Add a mandatory 'organization_id' parameter to
create_user() in src/services/user_service.py.
Find ALL the callers and update them to pass
organization_id. If a caller doesn't have access to
organization_id, report the problem."
# Claude Code:
# 1. Updates the signature of create_user()
# 2. Finds 8 callers
# 3. Updates 6 that have access to organization_id
# 4. REPORTS 2 that don't (they need investigation)
Interface Change with a Parameter Type (Medium Risk)
From individual parameters to an object
This is one of the most common refactorings when a function grows:
# Before: 7 individual parameters
def create_order(
user_id: int,
items: list,
shipping_address: str,
billing_address: str,
coupon_code: str | None,
payment_method: str,
notes: str | None
) -> Order:
...
# After: a request object
class OrderCreateRequest(BaseModel):
user_id: int
items: list
shipping_address: str
billing_address: str
coupon_code: str | None = None
payment_method: str
notes: str | None = None
def create_order(request: OrderCreateRequest) -> Order:
...
Executing with Claude Code
# Step 1: Tests
> "Run the create_order tests and confirm that they pass"
# Step 2: Create the request object
> "Create a Pydantic model OrderCreateRequest in
src/models/requests.py with the same fields as
the current parameters of create_order(). The nullable
fields should have a default of None."
# Step 3: Change the signature
> "Change the signature of create_order() in order_service.py
to receive a single parameter 'request: OrderCreateRequest'.
Update the function body to use request.field
instead of the direct parameters."
# Step 4: Propagate to callers
> "Find all the callers of create_order() and
update them to build an OrderCreateRequest
before calling. Show each caller before and after."
# Propagation example:
# Before:
order = create_order(
user_id=user.id,
items=cart.items,
shipping_address=address,
billing_address=billing,
coupon_code=coupon,
payment_method="credit_card",
notes=None
)
# After:
request = OrderCreateRequest(
user_id=user.id,
items=cart.items,
shipping_address=address,
billing_address=billing,
coupon_code=coupon,
payment_method="credit_card"
)
order = create_order(request)
# Step 5: Update tests
> "Update all the create_order tests to use
OrderCreateRequest. Run tests."
Interface Change in a Base Class (High Risk)
The cascade effect
When you change a method in a base class, ALL the subclasses must be updated, and ALL the callers of ALL the subclasses too:
# A base class with 5 subclasses:
class BaseRepository:
def find(self, id: int) -> dict: # ← change this
...
class UserRepository(BaseRepository): # ← update
def find(self, id: int) -> dict:
...
class OrderRepository(BaseRepository): # ← update
def find(self, id: int) -> dict:
...
# + ProductRepository, InvoiceRepository, PaymentRepository
# And each subclass is called from multiple services:
# user_service.py: user_repo.find(user_id) # ← update
# order_service.py: order_repo.find(order_id) # ← update
# admin_service.py: user_repo.find(admin_id) # ← update
# ... (potentially 20+ callers)
Executing with Claude Code
# Step 1: Map the complete impact
> "I'm going to change the find() method of BaseRepository to
find_by_id(). Before making any change, map
the complete impact:
1. All the subclasses of BaseRepository
2. All the files that call .find() on any
subclass of BaseRepository
3. All the tests that test .find()
Report the total number of necessary changes."
# Expected output:
# Subclasses: 5 (User, Order, Product, Invoice, Payment)
# Callers: 18 files with 23 calls to .find()
# Tests: 12 tests in 5 files
# Total: 40 changes in 23 files
# Step 2: Tests
> "Run all the tests and confirm that they pass"
# Step 3: Change in the base + subclasses
> "Rename find() to find_by_id() in BaseRepository
and in ALL its 5 subclasses. Don't update callers yet."
# Step 4: Update callers
> "Update all the 23 callers that call .find()
on any repository to .find_by_id(). Show
each change."
# Step 5: Update tests
> "Update the 12 tests that test .find() to
.find_by_id(). Run all the tests."
Interface Change in the Return Type
From dict to object
# Before: returns a dict (no type safety)
def get_user(user_id: int) -> dict:
row = db.execute("SELECT * FROM users WHERE id = ?", user_id)
return dict(row) # {"id": 1, "name": "Ana", "email": "ana@..."}
# After: returns a User object (with type safety)
def get_user(user_id: int) -> User:
row = db.execute("SELECT * FROM users WHERE id = ?", user_id)
return User(**dict(row))
# Impact on callers:
# Before: user["name"]
# After: user.name
# Prompt:
> "Change get_user() in user_repository.py to return
a User object instead of a dict. Find all the
callers that access the result as a dict (user['name'])
and update them to attribute access (user.name). Run tests."
Comparison: Manual Interface Change vs Claude Code
| Criterion | Manual | Claude Code |
|---|---|---|
| Map the impact | grep + manual reading | Complete, includes inheritance |
| Find callers | grep by function name | Semantic (distinguishes overloads) |
| Propagation to subclasses | Remember each subclass | Automatic |
| Update tests | Search manually | Included in the propagation |
| Consistency | Depends on attention | All the callers are updated the same |
| Risk of forgetting one | High (in large projects) | Low |
Connection with the Project
In the Module Project (capsule 06), if the codebase has inconsistent interfaces (functions with 8 parameters, different return types for the same operation, outdated base class methods), interface changes is the technique you need.
Troubleshooting
Problem 1: A caller can't build the new type
Cause: A caller doesn't have access to the data needed for the new parameter.
Solution: Trace where each piece of data comes from:
> "The caller in api/routes/admin.py doesn't have access to
organization_id. Where can it get it from? From the
request, from the JWT token, or from another source?"
Problem 2: A subclass has a different signature
Cause: A subclass override has extra parameters.
Solution: Check compatibility:
> "When changing find() to find_by_id() in BaseRepository,
check that no subclass has an override of
find() with additional parameters that would be lost."
Problem 3: The return type change breaks serialization
Cause: The callers serialize the result to JSON.
Solution: Ensure the new type is serializable:
> "After changing get_user() to return a User object,
check that the callers that do json.dumps() or
jsonify() on the result keep working. If User
isn't serializable, add a to_dict() method."
Problem 4: Too many callers to change at once
Cause: The function is used in 30+ places.
Solution: Use the strangler pattern (Module 5 preview):
> "Create a new function create_user_v2() with the new
signature. Migrate callers one by one to the new version.
When all the callers use v2, remove the original
and rename v2 to create_user."
Exercises
Exercise 1: Classify the risk of interface changes (Easy)
Classify each change as low, medium, or high risk:
- Add a parameter
verbose: bool = Falseto a function - Change
process(data: dict)toprocess(data: ProcessRequest) - Rename the method
save()topersist()in a base class with 8 subclasses - Change the return type from
listtoGenerator
See solution
- Low — default value, no breaking change
- High — all the callers must build a ProcessRequest
- Very high — 8 subclasses + all their callers
- Medium — callers that index (
result[0]) break, those that iterate (for x in result) work
Exercise 2: Design a propagation (Medium)
send_notification(user_id, message) is going to change to send_notification(notification: Notification). Write the steps for Claude Code.
See solution
# 1. Create the model
> "Create a Notification model with fields: user_id (int),
message (str), channel (str, default 'email'),
priority (str, default 'normal')."
# 2. Tests
> "Run the send_notification tests, confirm that they pass."
# 3. Change the signature
> "Change send_notification() to receive a Notification
instead of user_id + message. Update the body."
# 4. Propagate
> "Find all the callers of send_notification().
For each one, build a Notification object with
the existing data. Show each change."
# 5. Verify
> "Run tests. If they fail, show what changed."
Exercise 3: Interface change in a base class (Hard)
BaseService.execute(data: dict) -> dict changes to BaseService.execute(request: BaseRequest) -> BaseResponse. There are 6 subclasses. Design the complete plan.
See solution
# 1. Map the impact
> "Map all the subclasses of BaseService and all
the callers of .execute() in each subclass."
# 2. Create types
> "Create BaseRequest and BaseResponse as base classes.
For each subclass, create specific Request/Response:
UserRequest(BaseRequest), UserResponse(BaseResponse), etc."
# 3. Tests
> "Run all the tests, confirm that they pass."
# 4. Change base + subclasses (one at a time)
> "Change execute() in BaseService to use BaseRequest/BaseResponse.
Then update UserService.execute() to use
UserRequest/UserResponse. Run the UserService tests."
# Repeat for each subclass:
> "Update OrderService.execute(). Run tests."
> "Update PaymentService.execute(). Run tests."
# ... (6 iterations, tests in each one)
# 5. Update callers
> "Update all the callers to build the
specific Request. Run all the tests."
Key: one subclass at a time with tests between each change. Never 6 subclasses at once.
Summary
In this capsule you learned:
- Interface changes modify the contract of a function and propagate to callers and implementations
- 4 types: add a parameter, change a parameter type, change the return type, change a base class method
- The risk scales with the number of affected callers and implementations
- Claude Code maps the complete impact including inheritance and transitive callers
- The cascade is: base → subclasses → callers → tests — in that order
- For massive changes, use the strangler pattern: a new version + gradual migration
Next capsule: Regression Tests for Refactoring. The complete testing framework that guarantees every refactoring preserves behavior.
Additional Resources
- Refactoring Guru - Change Method Signature - Visual explanation
- Liskov Substitution Principle - The principle interface changes must respect
- Python Type Hints (PEP 484) - Type hints that document interfaces
- Pydantic - Data Validation - To create typed request/response objects
- Python Protocol Classes (PEP 544) - Implicit interfaces in Python
- Strangler Fig Pattern - A pattern for gradual interface migration
Module 4, Capsule 04 — Refactoring & Legacy Code with Claude Code Guide