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

CriterionManualClaude Code
Map the impactgrep + manual readingComplete, includes inheritance
Find callersgrep by function nameSemantic (distinguishes overloads)
Propagation to subclassesRemember each subclassAutomatic
Update testsSearch manuallyIncluded in the propagation
ConsistencyDepends on attentionAll the callers are updated the same
Risk of forgetting oneHigh (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:

  1. Add a parameter verbose: bool = False to a function
  2. Change process(data: dict) to process(data: ProcessRequest)
  3. Rename the method save() to persist() in a base class with 8 subclasses
  4. Change the return type from list to Generator
See solution
  1. Low — default value, no breaking change
  2. High — all the callers must build a ProcessRequest
  3. Very high — 8 subclasses + all their callers
  4. 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

  1. Refactoring Guru - Change Method Signature - Visual explanation
  2. Liskov Substitution Principle - The principle interface changes must respect
  3. Python Type Hints (PEP 484) - Type hints that document interfaces
  4. Pydantic - Data Validation - To create typed request/response objects
  5. Python Protocol Classes (PEP 544) - Implicit interfaces in Python
  6. Strangler Fig Pattern - A pattern for gradual interface migration

Module 4, Capsule 04 — Refactoring & Legacy Code with Claude Code Guide