Files
gruperly/.agents/skills/clean-code-principles/rules/core-law-demeter.md
2026-09-04 16:49:24 -03:00

7.5 KiB

id, title, category, priority, tags, related
id title category priority tags related
core-law-demeter Law of Demeter core-principles critical
law-of-demeter
coupling
encapsulation
core-encapsulation
solid-srp-class
core-separation-concerns

Law of Demeter

A method should only talk to its immediate friends, not to strangers. Don't reach through objects to access their internal structure. This reduces coupling and makes code more maintainable.

Bad Example

// Anti-pattern: Reaching through object chains

class Address {
  street: string;
  city: string;
  country: Country;
}

class Country {
  name: string;
  code: string;
  taxRules: TaxRules;
}

class TaxRules {
  vatRate: number;
  calculateTax(amount: number): number {
    return amount * this.vatRate;
  }
}

class Customer {
  name: string;
  address: Address;
  wallet: Wallet;
}

class Wallet {
  balance: number;
  currency: Currency;

  deduct(amount: number): void {
    this.balance -= amount;
  }
}

class Currency {
  code: string;
  exchangeRate: number;
}

class Order {
  customer: Customer;
  items: OrderItem[];

  // Violation: Reaching deep into customer's structure
  getCustomerCountry(): string {
    return this.customer.address.country.name; // 4 levels deep!
  }

  // Violation: Reaching into customer's wallet
  calculateTax(): number {
    const amount = this.getTotal();
    // Reaching through customer -> address -> country -> taxRules
    return this.customer.address.country.taxRules.calculateTax(amount);
  }

  // Violation: Manipulating customer's wallet directly
  processPayment(): void {
    const total = this.calculateTax() + this.getTotal();

    // Reaching into wallet to check and modify
    if (this.customer.wallet.balance < total) {
      throw new Error('Insufficient funds');
    }

    // Reaching into wallet's currency for conversion
    const exchangeRate = this.customer.wallet.currency.exchangeRate;
    const convertedAmount = total * exchangeRate;

    this.customer.wallet.deduct(convertedAmount);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

// Problems with this approach:
// 1. Order knows too much about Customer's internal structure
// 2. Changes to Address, Country, or Wallet break Order
// 3. Hard to test - must mock entire object graph
// 4. Tight coupling between unrelated classes

Good Example

// Correct approach: Talk only to immediate friends

class Address {
  private street: string;
  private city: string;
  private country: Country;

  constructor(street: string, city: string, country: Country) {
    this.street = street;
    this.city = city;
    this.country = country;
  }

  getCountryName(): string {
    return this.country.getName();
  }

  calculateTax(amount: number): number {
    return this.country.calculateTax(amount);
  }
}

class Country {
  private name: string;
  private code: string;
  private taxRules: TaxRules;

  constructor(name: string, code: string, taxRules: TaxRules) {
    this.name = name;
    this.code = code;
    this.taxRules = taxRules;
  }

  getName(): string {
    return this.name;
  }

  getCode(): string {
    return this.code;
  }

  calculateTax(amount: number): number {
    return this.taxRules.calculate(amount);
  }
}

class TaxRules {
  private vatRate: number;

  constructor(vatRate: number) {
    this.vatRate = vatRate;
  }

  calculate(amount: number): number {
    return amount * this.vatRate;
  }
}

class Wallet {
  private balance: number;
  private currency: Currency;

  constructor(balance: number, currency: Currency) {
    this.balance = balance;
    this.currency = currency;
  }

  canAfford(amount: number): boolean {
    const convertedAmount = this.currency.convert(amount);
    return this.balance >= convertedAmount;
  }

  pay(amount: number): PaymentResult {
    const convertedAmount = this.currency.convert(amount);

    if (!this.canAfford(amount)) {
      return { success: false, error: 'Insufficient funds' };
    }

    this.balance -= convertedAmount;
    return { success: true, amountPaid: convertedAmount };
  }

  getBalance(): number {
    return this.balance;
  }
}

class Currency {
  private code: string;
  private exchangeRate: number;

  constructor(code: string, exchangeRate: number) {
    this.code = code;
    this.exchangeRate = exchangeRate;
  }

  convert(amount: number): number {
    return amount * this.exchangeRate;
  }
}

class Customer {
  private name: string;
  private address: Address;
  private wallet: Wallet;

  constructor(name: string, address: Address, wallet: Wallet) {
    this.name = name;
    this.address = address;
    this.wallet = wallet;
  }

  getName(): string {
    return this.name;
  }

  getCountryName(): string {
    return this.address.getCountryName();
  }

  calculateTaxFor(amount: number): number {
    return this.address.calculateTax(amount);
  }

  canAfford(amount: number): boolean {
    return this.wallet.canAfford(amount);
  }

  pay(amount: number): PaymentResult {
    return this.wallet.pay(amount);
  }
}

class Order {
  private customer: Customer;
  private items: OrderItem[];

  constructor(customer: Customer, items: OrderItem[]) {
    this.customer = customer;
    this.items = items;
  }

  // Only talks to immediate friend (customer)
  getCustomerCountry(): string {
    return this.customer.getCountryName();
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
  }

  // Asks customer to calculate tax (customer knows how)
  calculateTax(): number {
    return this.customer.calculateTaxFor(this.getTotal());
  }

  getTotalWithTax(): number {
    return this.getTotal() + this.calculateTax();
  }

  // Asks customer to pay (customer handles wallet)
  processPayment(): PaymentResult {
    const total = this.getTotalWithTax();

    if (!this.customer.canAfford(total)) {
      return { success: false, error: 'Insufficient funds' };
    }

    return this.customer.pay(total);
  }
}

// Usage
const order = new Order(customer, items);
const result = order.processPayment();

if (result.success) {
  console.log(`Payment successful: ${result.amountPaid}`);
} else {
  console.log(`Payment failed: ${result.error}`);
}

// Testing is now easy - only mock the immediate friend
describe('Order', () => {
  it('should process payment through customer', () => {
    const mockCustomer: Customer = {
      getCountryName: () => 'USA',
      calculateTaxFor: (amount: number) => amount * 0.1,
      canAfford: () => true,
      pay: jest.fn().mockReturnValue({ success: true, amountPaid: 110 })
    } as any;

    const order = new Order(mockCustomer, [{ getPrice: () => 100 }]);
    const result = order.processPayment();

    expect(result.success).toBe(true);
    expect(mockCustomer.pay).toHaveBeenCalledWith(110);
  });
});

Why

  1. Reduced Coupling: Order only knows about Customer. Changes to Address, Country, or Wallet don't affect Order.

  2. Encapsulation: Internal structure is hidden. Customer can change how it stores address without affecting clients.

  3. Easier Testing: Mock only the immediate friend. No need to construct deep object graphs.

  4. Better Abstraction: Customer is responsible for customer things. Order doesn't need to know about wallets.

  5. Maintainability: When requirements change, changes are localized to the responsible class.

  6. Readability: customer.calculateTaxFor(amount) is clearer than customer.address.country.taxRules.calculateTax(amount).

  7. Flexibility: Can change internal implementations without affecting clients. Customer could switch from wallet to payment service.