Train wreck anti-pattern
Learn why chaining method calls across object boundaries violates the Law of Demeter, creates brittle coupling, and how delegating methods fix the problem cleanly.
TL;DR
- A train wreck is a chain like
order.getCustomer().getAddress().getCity()where each dot crosses an object boundary, coupling the caller to the internal structure of every type in the chain. - The Law of Demeter says: only talk to your immediate neighbors. Do not navigate through friends to reach strangers.
- Each link in the chain is a dependency. If
Addressis refactored toLocation, every chain touching.getAddress()breaks. - The fix is delegating methods:
order.getShippingCity()hides the chain behind a single call on the nearest object. - Fluent APIs and builder chains are NOT train wrecks because they return the same object, not progressively deeper objects.
The Problem
Your ShippingService calculates delivery costs. To get the customer's city and shipping zone, it navigates through Order to Customer to Address:
public class ShippingService {
public ShippingQuote calculateShipping(Order order) {
// 3-level chain: Order -> Customer -> Address
String city = order.getCustomer().getAddress().getCity();
String zone = order.getCustomer().getAddress().getShippingZone();
String country = order.getCustomer().getAddress().getCountry();
// 2-level chain: Warehouse -> Location
String warehouseRegion = closestWarehouse.getLocation().getRegion();
return rateCalculator.quote(city, zone, country, warehouseRegion);
}
}
This method depends on five classes: Order, Customer, Address, Warehouse, and Location. The method signature says it needs an Order, but at runtime it reaches three levels deep. When the team renames Address to ContactInfo or nests Address inside a ShippingDetails wrapper, every chain breaks.
I've debugged a refactoring where moving Address from Customer to a new ShippingProfile class caused 47 compilation errors across 12 services, all from train wreck chains.
The method "reaches through" each object to get to the next. Every intermediate type becomes a compile-time dependency that the caller should never have known about.
Why It Happens
- Object graph navigation feels natural. Developers think in terms of the domain: "an order has a customer who has an address." Translating that directly to code produces chains.
- Getters expose the graph. If
CustomerhasgetAddress()andAddresshasgetCity(), chaining them is the path of least resistance. The getter API invites deep navigation. - Delegation feels like boilerplate. Writing
order.getShippingCity()that delegates tocustomer.getShippingCity()that delegates toaddress.getCity()feels like useless indirection, until a refactoring proves its value. - Flat service classes. In anemic-model codebases, services do all the work and entities are pure data holders. The service must navigate the graph because the entities offer no behavior.
The formal Law of Demeter
A method M of object O should only call methods on: (1) O itself, (2) M's parameters, (3) objects created within M, (4) O's direct fields. Anything reached via a return value of another call is a "stranger" and should not be accessed.
How to Detect It
| Signal | What It Means | How to Check |
|---|---|---|
| 2+ consecutive dots on the same expression | Caller navigates through intermediate objects | Regex: \.\w+\(\)\.\w+\(\)\.\w+\(\) in source |
| Method imports types it never declares | The chain pulls in transitive types | Check import statements vs parameter types |
| Refactoring an inner class breaks distant callers | Callers depend on internal structure | Rename an inner class and count compile errors |
| Mocking setup requires 3+ layers of stubs | Tests must mock every object in the chain | Check when(...).thenReturn(...) depth in tests |
| Same chain appears in multiple classes | Copy-paste coupling through navigation | Search for repeated multi-dot patterns |
Test brittleness signal
If your test setup looks like when(order.getCustomer()).thenReturn(mockCustomer) followed by when(mockCustomer.getAddress()).thenReturn(mockAddress) followed by when(mockAddress.getCity()).thenReturn("Seattle"), you have a train wreck. The test is mocking the entire object graph to test one service method.
The Fix
Add delegating methods at each level so callers only talk to their immediate neighbor. Each object exposes the information callers need without revealing its internal structure.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.