Loading learning content...
"But wait," you might say, "if the Law of Demeter limits method chaining, how do I explain this perfectly reasonable code?"
const result = users
.filter(user => user.isActive())
.map(user => user.getEmail())
.sort()
.join(", ");
This has four dots. According to the "one dot rule," this should be a catastrophic LoD violation. Yet it's idiomatic, well-designed code found in production systems everywhere.
The confusion arises from an oversimplified understanding of LoD. The principle isn't about counting dots—it's about whether you're reaching through objects to access their internals. Understanding this distinction is crucial for applying LoD correctly without abandoning useful patterns.
This page clarifies the relationship between method chaining and the Law of Demeter. You'll learn to distinguish between chains that violate LoD (object graph navigation) and chains that don't (fluent interfaces, transformations, builders). By the end, you'll have precise criteria for evaluating any chain you encounter.
The key to understanding method chains and LoD lies in distinguishing between two fundamentally different types of chains:
Navigation Chains traverse an object graph, moving from one object to another, accessing internal structure. Each step reveals implementation details about how objects are composed. These chains violate LoD.
Transformation Chains apply operations to data, where each step returns a result that flows to the next operation. They don't navigate internal structure; they process values. These chains don't violate LoD.
The visual similarity between these chains is superficial. Their semantics are completely different.
123456789101112131415161718192021222324252627
// NAVIGATION CHAIN — LoD Violation// Each method returns a DIFFERENT OBJECT that we then interrogateconst city = order .getCustomer() // Returns Customer (different type) .getAddress() // Returns Address (different type) .getCity(); // Returns City (different type) // We've exposed: Order has Customer, Customer has Address, Address has City// We're coupled to: Order, Customer, Address, City // TRANSFORMATION CHAIN — LoD Compliant// Each method returns the RESULT of an operation on the same valueconst result = text .trim() // Returns trimmed string .toLowerCase() // Returns lowercased string .split(" ") // Returns array of words .filter(w => w) // Returns filtered array .join("-"); // Returns joined string // We've exposed: nothing about internal structure// We're coupled to: string and array operations // THE CRITICAL DIFFERENCE// Navigation: we're getting to objects INSIDE other objects// Transformation: we're applying operations to VALUESAsk yourself: "Am I getting from A to B to C (navigation), or am I applying operation1, then operation2, then operation3 to a value (transformation)?" Navigation violates LoD; transformation doesn't.
Fluent interfaces are a design pattern where methods return this (the current object), enabling method chaining for configuration or command sequences. They create long chains but don't violate LoD because every method call is still on the same object.
When you see:
builder.setName("Alice").setAge(30).setEmail("alice@example.com").build();
You might count four dots and suspect an LoD violation. But look closer:
builder.setName("Alice") → returns builderbuilder.setAge(30) → returns builderbuilder.setEmail(...) → returns builderbuilder.build() → returns the built objectEvery intermediate call is to the same object — your original builder. You're not navigating into other objects; you're configuring the one object you have.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ✅ FLUENT INTERFACE — Not an LoD violation// Every method returns 'this', so we're always talking to the same object class QueryBuilder { private selectClauses: string[] = []; private fromTable: string = ""; private whereClauses: string[] = []; private orderByClause: string = ""; private limitValue: number | null = null; select(...columns: string[]): this { this.selectClauses.push(...columns); return this; // Returns the same QueryBuilder } from(table: string): this { this.fromTable = table; return this; // Returns the same QueryBuilder } where(condition: string): this { this.whereClauses.push(condition); return this; // Returns the same QueryBuilder } orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this { this.orderByClause = `${column} ${direction}`; return this; // Returns the same QueryBuilder } limit(count: number): this { this.limitValue = count; return this; // Returns the same QueryBuilder } build(): string { // Finally builds and returns the SQL string let sql = `SELECT ${this.selectClauses.join(", ")} FROM ${this.fromTable}`; if (this.whereClauses.length > 0) { sql += ` WHERE ${this.whereClauses.join(" AND ")}`; } if (this.orderByClause) { sql += ` ORDER BY ${this.orderByClause}`; } if (this.limitValue !== null) { sql += ` LIMIT ${this.limitValue}`; } return sql; }} // Usage — long chain, but only one object is involvedconst query = new QueryBuilder() .select("id", "name", "email") .from("users") .where("active = true") .where("created_at > '2024-01-01'") .orderBy("created_at", "DESC") .limit(10) .build();The Law of Demeter says you can call methods on objects you directly have. In a fluent interface, you have the builder object. Every chained call is still on that same builder object (because each method returns this). You're not navigating to new objects; you're repeatedly calling methods on your direct friend.
Collection processing with methods like filter(), map(), reduce(), sort(), and flatMap() creates chains that look like LoD violations but aren't. Each method returns a new collection (or a final value), not an internal component object.
The key insight: You're not navigating structure; you're transforming data. Each step operates on the output of the previous step, not on an internal property of the previous step.
123456789101112131415161718192021
// ✅ COLLECTION PROCESSING — Not an LoD violation// Each method transforms the collection and returns a new collection // Processing a list of ordersconst highValueCustomerEmails = orders .filter(order => order.getTotal() > 1000) // Returns filtered Order[] .map(order => order.getCustomer()) // Returns Customer[] 🤔 Wait... .filter(customer => customer.isActive()) // Returns filtered Customer[] .map(customer => customer.getEmail()) // Returns string[] .filter((email, index, self) => // Unique emails self.indexOf(email) === index) .sort(); // Returns sorted string[] // IMPORTANT NUANCE: // - `order.getCustomer()` could be an LoD concern if you then dig into Customer// - But we're EXTRACTING data for processing, not navigating for manipulation// - The result is a flat list of emails, not an object graph // This is the difference between:// ❌ order.getCustomer().getAddress().getCity() — navigating through structure// ✅ orders.map(o => o.getCustomerEmail()) — extracting data for processingThe Nuanced Case:
Collection chains can contain LoD violations. Consider:
// This chain contains an LoD violation inside the map
orders.map(order => order.getCustomer().getAddress().getCity());
The collection chain itself (.map()) isn't the problem. The content of the lambda (order.getCustomer().getAddress().getCity()) is the problem. This is an LoD violation wrapped in collection processing.
The fix:
// Better: ask Order for what you need
orders.map(order => order.getShippingCity());
Now the collection processing is clean, and Order handles its internal structure.
Collection chains are LoD-compliant for the chain itself, but violations can hide inside the lambdas. Always evaluate what happens inside filter(), map(), and similar methods — that's where navigation violations lurk.
Optional types (like Java's Optional, Kotlin's nullable types, TypeScript's optional chaining) create chains for handling null safely. These require careful analysis — they can be either LoD-compliant or violating, depending on what's being chained.
Optional chaining for transformation: When you use Optional to apply transformations to a value that might be absent, you're not violating LoD. You're wrapping operations in null-safety.
Optional chaining for navigation: When you use Optional to safely navigate through an object graph, you're still navigating — the Optional just makes the null-checking prettier. This is still an LoD violation.
1234567891011121314151617181920212223242526272829303132333435363738
// ✅ OPTIONAL FOR TRANSFORMATION — Not an LoD violation// We're applying transformations to a value, handling absence gracefully function formatUserName(user: User | null): string { return Optional.ofNullable(user) .map(u => u.getName()) .map(name => name.toUpperCase()) .map(name => `User: ${name}`) .orElse("Unknown User");} // Each .map() transforms the value if present// We're not navigating structure, we're processing a value // ❌ OPTIONAL FOR NAVIGATION — Still an LoD violation!// We're using Optional to navigate safely through structure function getShippingCity(order: Order | null): string { return Optional.ofNullable(order) .map(o => o.getCustomer()) // Navigation to Customer .map(c => c.getAddress()) // Navigation to Address .map(a => a.getCity()) // Navigation to City .map(city => city.getName()) // Navigation to City's name .orElse("Unknown");} // This is cleaner than nested null checks, but STILL couples us to:// Order -> Customer -> Address -> City -> name// Optional doesn't change the LoD violation — it just makes it null-safe // ✅ THE FIX — Push navigation to the object that owns structurefunction getShippingCity(order: Order | null): string { return Optional.ofNullable(order) .map(o => o.getShippingCityName()) // Order handles its internals .orElse("Unknown");}1234567891011121314151617
// TypeScript's optional chaining (?.) has the same consideration // ❌ Optional chaining used for navigation — still LoD violationconst cityName = order?.customer?.address?.city?.name ?? "Unknown"; // Prettier than:// const cityName = order && order.customer && order.customer.address // && order.customer.address.city && order.customer.address.city.name // || "Unknown"; // But STILL couples to the entire structure // ✅ Optional chaining on a single level — LoD compliantconst cityName = order?.getShippingCityName() ?? "Unknown"; // Order handles the internal navigation; we just handle order's absenceOptional wrappers and null-safe operators make null-checking elegant, but they don't change the coupling. If you're navigating through a?.b?.c?.d, you're coupled to all four levels regardless of syntax. The question is always: "Am I reaching into internal structure?"
Promise chains (.then().then().catch()) and their async/await equivalents create another form of chaining that people sometimes confuse with LoD violations. Promise chains are transformation chains — each step receives the previous step's result and produces a new result.
The Promise itself is a container for an eventual value. Chaining .then() calls applies transformations to that eventual value. You're not navigating object structure; you're defining a pipeline of operations.
12345678910111213141516171819202122232425262728293031323334353637
// ✅ PROMISE CHAIN — Not an LoD violation// Each .then() transforms the result, creating a pipeline async function processUserOrder(userId: string): Promise<Receipt> { return fetchUser(userId) // Promise<User> .then(user => validateUserStatus(user)) // Promise<ValidatedUser> .then(user => createOrder(user)) // Promise<Order> .then(order => processPayment(order)) // Promise<PaymentResult> .then(payment => generateReceipt(payment)) // Promise<Receipt> .catch(error => handleError(error)); // Promise<Receipt>} // We're defining a pipeline: fetch → validate → create → pay → receipt// Each step's output feeds the next step's input// This is transformation, not navigation // ❌ BUT: LoD violations can hide INSIDE the .then() callbacks async function getCustomerCity(orderId: string): Promise<string> { return fetchOrder(orderId) .then(order => order.getCustomer()) // Navigation .then(customer => customer.getAddress()) // Navigation .then(address => address.getCity()) // Navigation .then(city => city.getName()); // Navigation} // The Promise chain itself is fine// The CONTENT of each .then() creates LoD violations // ✅ FIX: Keep promise chain, eliminate internal navigation async function getCustomerCity(orderId: string): Promise<string> { return fetchOrder(orderId) .then(order => order.getShippingCityName()); // Order handles internals}Using async/await instead of .then() changes syntax but not semantics. If your async function navigates through objects (const city = (await getOrder()).customer.address.city), it's still an LoD violation. The await syntax just makes the control flow linear.
Let's consolidate our understanding into a precise checklist for evaluating any method chain. These criteria distinguish LoD-violating chains from acceptable ones.
| Pattern | LoD Compliant? | Reason |
|---|---|---|
| a.getB().getC().getD() | ❌ No | Navigation through object graph |
| builder.setX().setY().setZ().build() | ✅ Yes | Fluent interface, returns this |
| list.filter().map().reduce() | ✅ Yes | Collection transformation pipeline |
| text.trim().toLowerCase().split() | ✅ Yes | Value transformation chain |
| optional.map().filter().orElse() | ✅ Yes | Optional transformation |
| promise.then().then().catch() | ✅ Yes | Async transformation pipeline |
| a?.b?.c?.d | ❌ No | Navigation with null-safety (still navigation) |
| result.data.user.profile.name | ❌ No | Property navigation through structure |
| Observable.pipe(map(), filter(), tap()) | ✅ Yes | Reactive stream transformation |
123456789101112131415161718192021222324252627282930313233343536
// Apply the checklist to real examples // Example 1: a.getB().getC().doSomething()// 1. Types: A → B → C → result (different types) ❌// 2. Structure: B is inside A, C is inside B ❌// 3. Refactoring: if B stops containing C, this breaks ❌// 4. Interrogating: we're getting B, getting C ❌// 5. Testing: need to mock A, B, C ❌// VERDICT: LoD violation ❌ // Example 2: queryBuilder.select().from().where().build()// 1. Types: QB → QB → QB → QB → Result (same until build) ✅// 2. Structure: we're configuring, not accessing internals ✅// 3. Refactoring: QB internal changes don't affect chain ✅// 4. Commanding: we're telling QB to configure itself ✅// 5. Testing: mock just queryBuilder ✅// VERDICT: LoD compliant ✅ // Example 3: users.filter(u => u.isActive()).map(u => u.getEmail())// 1. Types: User[] → User[] → string[] (collections) ✅// 2. Structure: not accessing internal structure ✅// 3. Refactoring: User can change how it stores email ✅// 4. Commanding: telling collection to transform ✅// 5. Testing: mock User array easily ✅// VERDICT: LoD compliant ✅ // Example 4: apiResponse.data.results[0].metadata.tags// 1. Types: Response → Data → Results → Item → Metadata → Tags ❌// 2. Structure: exposing deep response structure ❌// 3. Refactoring: API response change breaks this ❌// 4. Interrogating: navigating into response structure ❌// 5. Testing: need to construct entire response shape ❌// VERDICT: LoD violation ❌A common question arises with external APIs that return deeply nested data structures: "How do I follow LoD when the API gives me response.data.results[0].user.profile.address.city?"
External APIs present a unique challenge. You can't refactor a third-party API to be LoD-compliant. But you can contain the navigation to a single point in your codebase.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// ❌ PROBLEM: LoD violations spread throughout codebase// Every component that uses the API navigates its structure // In UserService.tsconst userName = apiResponse.data.user.profile.name; // In AddressValidator.ts const city = apiResponse.data.user.profile.address.city; // In NotificationService.tsconst email = apiResponse.data.user.contactInfo.email; // Every file knows the API response structure// API changes require updates everywhere // ✅ SOLUTION: Adapter/Anti-Corruption Layer// Create an adapter that isolates API structure knowledge // Define the domain model your code wants to work withinterface UserData { readonly name: string; readonly email: string; readonly city: string;} // Adapter class contains ALL navigation in ONE placeclass ExternalApiAdapter { parseUserResponse(response: ExternalApiResponse): UserData { // All structure navigation is here and ONLY here const rawUser = response.data.user; return { name: rawUser.profile.name, email: rawUser.contactInfo.email, city: rawUser.profile.address.city, }; }} // Rest of your codebase works with clean UserData// In UserService.tsconst userName = userData.name; // Clean! // In AddressValidator.tsconst city = userData.city; // Clean! // In NotificationService.tsconst email = userData.email; // Clean! // API response structure change? Only ExternalApiAdapter needs updatingUserData, not complex API response shapesThis approach is formalized as the Anti-Corruption Layer pattern in Domain-Driven Design. It creates a boundary between your domain and external systems, translating external models into your internal models. The "corruption" being prevented is external structure polluting your domain code.
When you identify an LoD-violating chain, how do you fix it? Here are systematic refactoring strategies for common scenarios.
a.getB().getC().doSomething() into a.doSomethingWithC()12345678910111213141516171819202122
// Before: navigating to send notificationorder.getCustomer().getContactInfo().getEmail()... // After: telling order to handle notificationorder.sendConfirmationNotification(); // Order internally:class Order { sendConfirmationNotification(): void { this.customer.notifyOfOrder(this); }} // Customer internally:class Customer { notifyOfOrder(order: Order): void { this.notificationService.send( this.contactInfo.getEmail(), this.createOrderMessage(order) ); }}a.getB().getC().getValue() into a.getCValue()123456789101112131415161718192021222324
// Before: caller navigates to get shipping zoneconst zone = order.getCustomer().getAddress().getCity().getShippingZone(); // After: Order exposes a delegation methodconst zone = order.getShippingZone(); // Implementation distributed appropriately:class Order { getShippingZone(): string { return this.customer.getShippingZone(); }} class Customer { getShippingZone(): string { return this.address.getShippingZone(); }} class Address { getShippingZone(): string { return this.city.getShippingZone(); }}1234567891011121314151617181920212223242526272829303132
// Before: caller extracts multiple values through navigationconst email = order.getCustomer().getContactInfo().getEmail();const name = order.getCustomer().getProfile().getName();const address = order.getCustomer().getAddress().getFormatted();sendConfirmation(email, name, address); // After: Order provides a consolidated confirmation data objectinterface ConfirmationRecipient { email: string; name: string; address: string;} class Order { getConfirmationRecipient(): ConfirmationRecipient { return this.customer.getConfirmationDetails(); }} class Customer { getConfirmationDetails(): ConfirmationRecipient { return { email: this.contactInfo.getEmail(), name: this.profile.getName(), address: this.address.getFormatted(), }; }} // Caller is cleanconst recipient = order.getConfirmationRecipient();sendConfirmation(recipient);Method chains aren't inherently bad. The question is whether you're navigating through object internals (LoD violation) or transforming values through a pipeline (LoD compliant). Fluent interfaces, collection processing, and promise chains typically don't violate LoD. Object graph navigation does — regardless of how elegantly it's written.
this, you're always talking to the same object. Multi-dot fluent chains are not LoD violations.filter(), map(), reduce() transform collections. The chain itself is fine; watch what happens inside the lambdas..then() receives and transforms the previous result. LoD violations can hide inside .then() callbacks.We've clarified when method chains violate LoD and when they don't. The final page of this module addresses applying Demeter appropriately — understanding when LoD applies, when it might be relaxed, and how to balance LoD with other design concerns.