Loading content...
Consider two development teams working on an e-commerce platform. Team A builds the order processing system, Team B builds the customer management system. One day, Team B refactors how customer addresses are stored—moving from a single Address object to a collection of AddressRecord entries with types and validity dates.
For Team A, if they wrote LoD-compliant code, this change is invisible. Their code asks customers for shipping information; customers know how to provide it.
But in most codebases, Team A's code reaches directly into customer objects to access addresses: order.getCustomer().getAddress().getCity(). Now Team B's internal refactoring breaks Team A's code. Builds fail. Deployments block. Teams point fingers.
This is the coupling cost of LoD violations—and it compounds as systems grow.
This page explores how the Law of Demeter functions as a coupling-reduction mechanism. You'll understand the different types of coupling, how LoD violations amplify coupling across systems, and how proper LoD application creates fire-breaks that contain change impact. By the end, you'll see LoD not just as a code style guideline but as an architectural strategy for building change-resilient systems.
Before examining how LoD reduces coupling, we need a precise understanding of what coupling means, how it manifests, and why it's problematic.
Coupling is the degree of interdependence between software modules. When module A is coupled to module B, changes in B may require changes in A. High coupling is problematic because it:
| Coupling Type | Description | Example | LoD Impact |
|---|---|---|---|
| Content Coupling | A directly modifies B's internals | Accessing private fields via reflection | LoD strongly prevents |
| Common Coupling | A and B share global data | Both modules modify a singleton registry | LoD discourages globals |
| External Coupling | A and B depend on external format/protocol | Both parse same XML schema | LoD neutral |
| Control Coupling | A passes control information to B | Boolean flag changes B's behavior | LoD addresses indirectly |
| Stamp Coupling | A passes data structure with unused parts | Passing entire Customer when only email needed | LoD addresses via interface segregation |
| Data Coupling | A passes only needed data to B | Passing email string directly | LoD-compliant target |
| Message Coupling | A calls B only via messages (loosest) | Event-driven communication | LoD-compliant ideal |
Coupling exists on a spectrum. The goal isn't zero coupling—that would mean completely disconnected modules that can't work together. The goal is appropriate coupling: tight enough to function cohesively, loose enough to evolve independently. LoD helps achieve this balance by making coupling explicit and intentional rather than accidental and sprawling.
The most important coupling distinction for understanding LoD is between direct coupling and transitive coupling.
Direct Coupling exists when A explicitly depends on B. This is visible, intentional, and often necessary. If you import a class, call its methods, or receive it as a parameter, you have direct coupling. Direct coupling is manageable because it's explicit.
Transitive Coupling exists when A depends on B, and B exposes C to A, creating an indirect A→C dependency. A never explicitly imported C, never declared a dependency on C, yet changes to C can break A. This is the coupling that LoD addresses.
LoD violations create transitive coupling because they reach through direct dependencies to access indirect ones.
123456789101112131415161718192021222324252627
// DIRECT COUPLING: OrderService → Order// This is explicit, visible, expectedimport { Order } from './order'; class OrderService { // OrderService explicitly depends on Order — this is fine calculateShipping(order: Order): number { // ... }} // TRANSITIVE COUPLING: OrderService → Order → Customer → Address → City// This is hidden, implicit, problematicclass OrderService { getShippingZone(order: Order): string { // OrderService now has hidden dependencies on: // - Customer (through order.getCustomer()) // - Address (through customer.getAddress()) // - City (through address.getCity()) // - Whatever City returns from getShippingZone() return order.getCustomer().getAddress().getCity().getShippingZone(); }} // None of these dependencies are declared// None are visible in imports// All of them can break this code if they changeThe Danger of Transitive Coupling:
Transitive coupling is particularly insidious because it's invisible. Consider the previous example:
OrderService.ts has no import statement for Customer, Address, or CityOrder as a dependencyCity changes its API, OrderService breaksCity had no idea OrderService existedCityIn large codebases, these hidden dependencies number in the hundreds or thousands. A single internal refactoring can create cascading failures across the entire system.
If module A has 3 direct dependencies, each with 3 transitive dependencies, A is actually coupled to 12 modules. If each of those has 3 more, A is coupled to 39 modules. Transitive coupling grows geometrically with each LoD violation in the chain. What looks like a simple method call actually welds you to an entire object graph.
Let's trace exactly how an LoD violation creates and propagates coupling throughout a system. We'll use a concrete example and follow the dependency chain.
Scenario: An e-commerce system where order processing needs to send a confirmation email.
1234567891011121314151617181920
// The LoD-violating approach class OrderProcessor { confirmOrder(order: Order): void { // Violation: navigate through order → customer → contact → email const email = order.getCustomer().getContactInfo().getEmail(); // Violation: navigate through order → customer → preferences → format const preferredFormat = order.getCustomer() .getPreferences() .getEmailPreferences() .getFormat(); // Violation: navigate through order → items → each product → name const itemNames = order.getItems() .map(item => item.getProduct().getName()); this.sendEmail(email, preferredFormat, itemNames); }}What went wrong? Let's count the dependencies:
OrderProcessor explicitly depends on Order ✓ (direct, expected)OrderProcessor implicitly depends on:
Customer (through order.getCustomer())ContactInfo (through customer.getContactInfo())Preferences (through customer.getPreferences())EmailPreferences (through preferences.getEmailPreferences())OrderItem (through order.getItems())Product (through item.getProduct())One class with one method now depends on 7 different classes, only one of which was intentional. Changes to any of these 6 hidden dependencies can break OrderProcessor.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// The LoD-compliant approach class OrderProcessor { confirmOrder(order: Order): void { // Order provides a confirmation-ready data structure const confirmationData = order.getConfirmationData(); // Single interaction with our direct dependency this.sendEmail( confirmationData.recipientEmail, confirmationData.preferredFormat, confirmationData.itemDescriptions ); }} // Inside Order — it handles its own structureclass Order { getConfirmationData(): OrderConfirmationData { return { recipientEmail: this.customer.getConfirmationEmail(), preferredFormat: this.customer.getPreferredEmailFormat(), itemDescriptions: this.items.map(item => item.getDescription()), }; }} // Inside Customer — it handles its own structureclass Customer { getConfirmationEmail(): string { return this.contactInfo.getEmail(); } getPreferredEmailFormat(): EmailFormat { return this.preferences.getEmailPreferences().getFormat(); }} // Inside OrderItem — it handles its own structure class OrderItem { getDescription(): string { return this.product.getName(); }}What's better now?
OrderProcessor depends only on Order ✓ (direct, as intended)ContactInfo changes, only Customer needs updatingProduct changes, only OrderItem needs updatingOrderProcessor is insulated from all these internal changesThe coupling that was spread across 7 classes is now contained within each class's boundary.
By following LoD, each object becomes a facade for its internal structure. Order presents a simple interface for confirmation data; internally, it may use Customer, ContactInfo, Preferences, etc. But clients of Order see only Order's public interface—a stable contract that can remain unchanged even as Order's internals evolve.
To understand coupling impact quantitatively, we can measure and visualize how LoD violations affect system architecture. Several metrics help us identify and track coupling problems.
Afferent Coupling (Ca): The number of classes that depend on a given class. High Ca means many things break if this class changes.
Efferent Coupling (Ce): The number of classes a given class depends on. High Ce means this class breaks if many things change.
Instability (I): Ce / (Ca + Ce). Ranges from 0 (completely stable) to 1 (completely unstable).
Depth of Coupling (DoC): How many "hops" through objects a dependency requires. LoD violations have high DoC.
| Class | Before LoD (Ce) | After LoD (Ce) | Reduction |
|---|---|---|---|
| OrderProcessor | 7 | 1 | 86% |
| ShippingCalculator | 5 | 1 | 80% |
| InvoiceGenerator | 9 | 2 | 78% |
| CustomerNotifier | 6 | 1 | 83% |
| ReportBuilder | 12 | 3 | 75% |
Visualizing Dependency Graphs:
Dependency graphs make coupling visible. Each node is a class; each edge is a dependency. LoD-violating systems have:
LoD-compliant systems have:
123456789101112131415161718192021222324252627282930313233343536373839404142
BEFORE LOD COMPLIANCE (High Coupling)===================================== OrderProcessor | +---> Order | | | +---> Customer | | | | | +---> ContactInfo | | | | | | | +---> Email | | | | | +---> Preferences | | | | | +---> EmailPreferences | | | +---> [OrderItem] | | | +---> Product | | | +---> ProductDetails Total: 10 implicit dependenciesChange impact radius: anything in this tree affects OrderProcessor AFTER LOD COMPLIANCE (Low Coupling)=================================== OrderProcessor ---> Order <--- InvoiceGenerator | (internal only) | Customer | ContactInfo | Email Total: 1 explicit dependency per consumerChange impact radius: only Order interface affects consumersTools like NDepend, Structure101, JDepend, and SonarQube can automatically calculate coupling metrics and visualize dependency graphs. Integrating these into CI/CD pipelines helps catch coupling creep early. Setting thresholds for Ce (efferent coupling) prevents LoD violations from accumulating.
In wildfire management, firebreaks are gaps in vegetation that stop fires from spreading. The fire reaches the break and has nowhere to go. LoD creates firebreaks in code — boundaries that stop change from propagating throughout the system.
When you follow LoD, each object's public interface becomes a firebreak. Internal changes are contained within the object; they don't spread to callers, callers' callers, or the entire system.
City classAddress.getCity()Customer.getAddress().getCity()Order.getCustomer().getAddress()OrderProcessor.confirmOrder()OrderController.handleSubmit()OrderTests suiteCity classAddress updates internal usageAddress.getShippingZone() unchangedCustomer unaffectedOrder unaffectedOrderProcessor unaffectedOrderController unaffected12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Creating firebreaks through LoD-compliant design // Address provides a stable interface regardless of internal structureclass Address { private city: City; private postalCode: PostalCode; private region: Region; // Firebreak: external code asks for what it needs // Address handles the internal navigation getShippingZone(): string { // Internal implementation can change freely // Was: return this.city.getShippingZone(); // Now: return this.region.getZone(this.postalCode); // External callers never know the difference return this.region.getZone(this.postalCode); } getFormattedForLabel(): string { // Another firebreak: formatting logic is encapsulated return `${this.streetLine}\n${this.city.getName()}, ${this.region.getCode()} ${this.postalCode.getValue()}`; } isInternational(relativeTo: Country): boolean { // Business logic is encapsulated, not spread across callers return !this.region.getCountry().equals(relativeTo); }} // Customer provides a stable interface for customer-related needsclass Customer { private primaryAddress: Address; private shippingAddresses: Address[]; private billingAddress: Address; // Firebreak: callers don't need to know address storage strategy getShippingAddress(orderType: OrderType): Address { if (orderType === OrderType.DIGITAL) { return this.primaryAddress; } return this.shippingAddresses[0] ?? this.primaryAddress; } getShippingZone(orderType: OrderType): string { // Firebreak: zone logic delegated to the relevant address return this.getShippingAddress(orderType).getShippingZone(); }} // Order provides the highest-level firebreakclass Order { private customer: Customer; private orderType: OrderType; // Firebreak: OrderProcessor only knows about Order getShippingZone(): string { return this.customer.getShippingZone(this.orderType); }} // OrderProcessor is completely isolated from internal changesclass OrderProcessor { calculateShipping(order: Order): Money { const zone = order.getShippingZone(); // Single, stable call return this.rates.getRate(zone); }}Each level of LoD compliance adds another firebreak. Address shields callers from City changes. Customer shields callers from Address changes. Order shields callers from Customer changes. Three layers of protection mean internal refactoring at any level stays contained.
Let's examine a real-world scenario where LoD compliance (or violation) has dramatic practical impact: migrating payment processing systems.
The Context: A company switches from payment provider Stripe to Adyen. They need to update their payment integration without disrupting order processing, invoicing, reporting, and other systems that depend on payment data.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// LOD-VIOLATING CODEBASE// Payment provider details are exposed and spread throughout // Order Processingclass OrderProcessor { processPayment(order: Order): void { const stripeCustomer = order.getPayment().getStripeCustomer(); const stripeCard = stripeCustomer.getDefaultCard(); stripe.charges.create({ amount: order.getTotal(), source: stripeCard.getId(), customer: stripeCustomer.getId(), }); }} // Invoicingclass InvoiceGenerator { generateInvoice(order: Order): Invoice { const stripeCharge = order.getPayment().getStripeCharge(); return new Invoice({ paymentMethod: `**** ${stripeCharge.getCard().getLast4()}`, transactionId: stripeCharge.getId(), processingFee: stripeCharge.getApplicationFee(), }); }} // Reportingclass RevenueReporter { calculateFees(orders: Order[]): Money { return orders.reduce((total, order) => { const stripeFee = order.getPayment() .getStripeCharge() .getBalanceTransaction() .getFee(); return total.add(stripeFee); }, Money.zero()); }} // Refundsclass RefundProcessor { processRefund(order: Order): void { const stripeCharge = order.getPayment().getStripeCharge(); stripe.refunds.create({ charge: stripeCharge.getId(), }); }} // MIGRATION IMPACT:// - 4 classes must change// - Each class has Stripe-specific knowledge// - Stripe types are spread throughout the codebase// - Every file that imports these classes may need updates// - Testing requires Stripe mocks everywhere// - Migration is a multi-week, high-risk project123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// LOD-COMPLIANT CODEBASE// Payment provider details are encapsulated // Payment abstraction — the only thing that knows about providersinterface PaymentGateway { charge(paymentDetails: PaymentDetails, amount: Money): ChargeResult; refund(chargeId: string): RefundResult;} class StripeGateway implements PaymentGateway { /* Stripe-specific */ }class AdyenGateway implements PaymentGateway { /* Adyen-specific */ } // Order's Payment handles provider-agnostic operationsclass Payment { private gateway: PaymentGateway; private chargeDetails: ChargeDetails; charge(amount: Money): ChargeResult { return this.gateway.charge(this.paymentDetails, amount); } getReceiptInfo(): PaymentReceiptInfo { return { lastFour: this.chargeDetails.getLastFour(), transactionId: this.chargeDetails.getTransactionId(), processingFee: this.chargeDetails.getProcessingFee(), }; } refund(): RefundResult { return this.gateway.refund(this.chargeDetails.getTransactionId()); }} // Order Processing — doesn't know about payment providersclass OrderProcessor { processPayment(order: Order): void { order.chargePayment(); // Order delegates internally }} // Invoicing — doesn't know about payment providersclass InvoiceGenerator { generateInvoice(order: Order): Invoice { const receiptInfo = order.getPaymentReceiptInfo(); return new Invoice({ paymentMethod: `**** ${receiptInfo.lastFour}`, transactionId: receiptInfo.transactionId, processingFee: receiptInfo.processingFee, }); }} // Reporting — doesn't know about payment providersclass RevenueReporter { calculateFees(orders: Order[]): Money { return orders.reduce((total, order) => { return total.add(order.getPaymentFee()); }, Money.zero()); }} // Refunds — doesn't know about payment providersclass RefundProcessor { processRefund(order: Order): void { order.refundPayment(); }} // MIGRATION IMPACT:// - 1 new class (AdyenGateway)// - 1 configuration change (inject Adyen instead of Stripe)// - OrderProcessor, InvoiceGenerator, RevenueReporter, RefundProcessor: UNCHANGED// - Testing unaffected for business logic// - Migration is a 1-day, low-risk projectIn the LoD-compliant version, switching payment providers is a configuration change, not a code change. The business logic—order processing, invoicing, reporting, refunds—is completely decoupled from the payment provider. This means faster migrations, lower risk, and the ability to switch providers or support multiple providers simultaneously.
Coupling isn't just a technical concern—it directly affects how development teams work together. LoD violations create hidden dependencies between teams, leading to coordination overhead, blocked deployments, and friction.
Conway's Law states that organizations design systems that mirror their communication structures. The inverse also applies: system coupling forces communication structures. Tightly coupled systems require tightly coordinated teams.
Microservices architecture is, in many ways, LoD applied at the system level. Each service exposes only its public API; internal structure is hidden. But microservices don't magically solve coupling—if services reach deeply into each other's data models, they recreate LoD violations at the service level. LoD applies whether you're writing classes or building distributed systems.
We've explored how the Law of Demeter fundamentally operates as a coupling-reduction mechanism. The insights from this page will inform your design decisions across all scales—from individual methods to entire system architectures.
Now that we understand how LoD reduces coupling, the next page addresses a common source of confusion: method chains and LoD. We'll distinguish between chains that violate LoD and chains that don't, establishing clear guidelines for fluent interfaces, builders, and stream processing.