Law of Demeter in Modern C#

The Law of Demeter (LoD), also known as the Principle of Least Knowledge, is a fundamental design guideline that helps developers write loosely coupled, maintainable code.

For backend developers working with .NET, understanding and applying LoD is crucial for building robust APIs, services, and domain logic that can evolve without breaking.

What is the Law of Demeter?

The Law of Demeter was formulated in 1987 at Northeastern University during the Demeter Project. It can be summarized simply:

"Only talk to your immediate friends, don't talk to strangers."

More formally, a method M of object O should only call methods on:

  1. O itself - The object's own methods
  2. M's parameters - Objects passed as arguments
  3. Objects created within M - Locally instantiated objects
  4. O's direct component objects - Fields/properties of the object
  5. Global objects accessible from M - Static/singleton instances (use sparingly)

The "Train Wreck" Anti-Pattern

The most common violation of LoD is the train wreck - a chain of method calls that reaches deep into object graphs:

// ❌ BAD: The Train Wreck Anti-Pattern
public class OrderProcessor
{
    public decimal CalculateShippingCost(Order order)
    {
        // This reaches through 4 levels of objects!
        var city = order.Customer.Address.City;
        var weight = order.Items.First().Product.ShippingDetails.Weight;
        
        return _shippingService.Calculate(city, weight);
    }
}

This code has several problems:

  • High Coupling: OrderProcessor knows about Customer, Address, City, Items, Product, and ShippingDetails
  • Fragile: Any change in these intermediate classes breaks OrderProcessor
  • Hard to Test: You need to mock an entire object graph
  • Null Reference Exceptions: Each dot is a potential NullReferenceException

Why Backend Developers Should Care

In backend development, LoD violations create real problems:

1. API Contract Instability

When your services reach deep into domain objects, internal refactoring breaks external contracts.

2. Testing Nightmare

Unit tests become integration tests because you can't isolate the system under test.

3. Debugging Complexity

Stack traces become cryptic when exceptions occur deep in chained calls.

4. Performance Issues

Each navigation might trigger lazy loading in ORMs like Entity Framework, causing N+1 query problems.

Applying LoD in Modern C#

Let's refactor the train wreck example using LoD-compliant patterns.

Pattern 1: Tell, Don't Ask

Instead of asking objects for their data and making decisions, tell objects what to do:

// ❌ BAD: Ask for data, make decisions externally
public class OrderProcessor
{
    public bool CanShipToCustomer(Order order)
    {
        var country = order.Customer.Address.Country;
        var isRestricted = order.Items
            .Any(i => i.Product.ShippingDetails.IsRestricted);
        
        return _shippingService.CanShip(country, isRestricted);
    }
}

// ✅ GOOD: Tell the Order to determine if it can be shipped
public class Order
{
    private readonly Customer _customer;
    private readonly List<OrderItem> _items;
    
    public bool CanBeShippedTo(IShippingPolicy policy)
    {
        return policy.CanShip(_customer.GetShippingDestination(), 
                              GetShippingRestrictions());
    }
    
    private ShippingRestrictions GetShippingRestrictions()
    {
        return new ShippingRestrictions(
            _items.Any(i => i.HasRestrictedProduct()));
    }
}

public class OrderProcessor
{
    private readonly IShippingPolicy _shippingPolicy;
    
    public bool CanShipToCustomer(Order order)
    {
        // Only talking to immediate friend (order)
        return order.CanBeShippedTo(_shippingPolicy);
    }
}

Pattern 2: Facade Methods

Create methods that encapsulate the navigation and return what clients actually need:

// ❌ BAD: Exposing internal structure
public class Customer
{
    public Address Address { get; set; }
    public PaymentInfo PaymentInfo { get; set; }
}

// Caller does this:
var billingCity = customer.Address.City;
var cardLast4 = customer.PaymentInfo.Card.Last4Digits;

// ✅ GOOD: Provide what callers need directly
public class Customer
{
    private Address _address;
    private PaymentInfo _paymentInfo;
    
    // Facade methods that hide internal structure
    public string GetBillingCity() => _address.City;
    
    public string GetMaskedPaymentMethod() => 
        _paymentInfo.GetMaskedDisplay();
    
    public ShippingDestination GetShippingDestination() =>
        new ShippingDestination(_address.City, _address.Country, _address.PostalCode);
}

Pattern 3: DTOs for Data Transfer

When you need to pass data across boundaries, use Data Transfer Objects:

// ✅ GOOD: Use DTOs to flatten the structure for consumers
public record OrderSummaryDto(
    string CustomerName,
    string ShippingCity,
    decimal TotalWeight,
    bool HasRestrictedItems);

public class Order
{
    public OrderSummaryDto ToSummary()
    {
        return new OrderSummaryDto(
            CustomerName: _customer.FullName,
            ShippingCity: _customer.GetShippingDestination().City,
            TotalWeight: _items.Sum(i => i.GetWeight()),
            HasRestrictedItems: _items.Any(i => i.HasRestrictedProduct())
        );
    }
}

public class ShippingController
{
    [HttpPost("calculate")]
    public ActionResult<ShippingQuote> CalculateShipping(int orderId)
    {
        var order = _orderRepository.GetById(orderId);
        var summary = order.ToSummary();
        
        // Now we work with a flat DTO, no train wrecks
        return _shippingService.GetQuote(summary);
    }
}

Pattern 4: Dependency Injection for Services

Use DI to inject only what a class needs, following the Interface Segregation Principle:

// ❌ BAD: Injecting a "god" service and reaching into it
public class InvoiceGenerator
{
    private readonly IApplicationServices _services;
    
    public Invoice Generate(Order order)
    {
        // Train wreck through services
        var taxRate = _services.Configuration.TaxSettings.GetRate(
            _services.GeoService.CurrentRegion.Country);
        // ...
    }
}

// ✅ GOOD: Inject only what you need
public interface ITaxRateProvider
{
    decimal GetTaxRate(string countryCode);
}

public class InvoiceGenerator
{
    private readonly ITaxRateProvider _taxRateProvider;
    
    public InvoiceGenerator(ITaxRateProvider taxRateProvider)
    {
        _taxRateProvider = taxRateProvider;
    }
    
    public Invoice Generate(Order order)
    {
        var taxRate = _taxRateProvider.GetTaxRate(
            order.GetShippingCountryCode());
        // ...
    }
}

LoD in Entity Framework and Repository Pattern

Backend developers using EF Core often violate LoD when querying. Here's how to apply it:

// ❌ BAD: Exposing navigation properties encourages LoD violations
public class OrderRepository
{
    public Order GetById(int id)
    {
        return _context.Orders
            .Include(o => o.Customer)
                .ThenInclude(c => c.Address)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
                    .ThenInclude(p => p.ShippingDetails)
            .FirstOrDefault(o => o.Id == id);
    }
}

// Caller then does: order.Customer.Address.City - train wreck!

// ✅ GOOD: Return exactly what the use case needs
public class OrderRepository
{
    public OrderForShipping? GetOrderForShipping(int id)
    {
        return _context.Orders
            .Where(o => o.Id == id)
            .Select(o => new OrderForShipping(
                o.Id,
                o.Customer.Address.City,
                o.Customer.Address.Country,
                o.Items.Sum(i => i.Product.ShippingDetails.Weight),
                o.Items.Any(i => i.Product.ShippingDetails.IsRestricted)
            ))
            .FirstOrDefault();
    }
}

public record OrderForShipping(
    int OrderId,
    string City,
    string Country,
    decimal TotalWeight,
    bool HasRestrictedItems);

Domain-Driven Design and LoD

The Law of Demeter aligns perfectly with DDD aggregates. Aggregates enforce LoD by design:

// ✅ GOOD: Aggregate root controls all access
public class OrderAggregate
{
    private readonly List<OrderLine> _lines = new();
    private OrderStatus _status;
    private Customer _customer;
    
    // External code cannot reach into _lines or _customer
    // They must go through the aggregate root
    
    public void AddItem(ProductId productId, int quantity, Money unitPrice)
    {
        if (_status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a confirmed order");
            
        var line = new OrderLine(productId, quantity, unitPrice);
        _lines.Add(line);
    }
    
    public Money GetTotal() => 
        Money.Sum(_lines.Select(l => l.GetSubtotal()));
    
    public ShippingInfo GetShippingInfo() =>
        _customer.CreateShippingInfo(_lines.Sum(l => l.GetWeight()));
}

Common Objections and Clarifications

"This creates too many wrapper methods!"

If you find yourself creating many facade methods, it might indicate:

  1. Your class has too many responsibilities (violates SRP)
  2. The caller needs a different abstraction (create a new class)
  3. You should use a DTO or projection

"What about fluent APIs and LINQ?"

Fluent APIs like LINQ are designed for method chaining and don't violate LoD because:

// This is fine - each method returns the same conceptual object
var result = customers
    .Where(c => c.IsActive)
    .OrderBy(c => c.Name)
    .Select(c => c.ToDto())
    .ToList();

The chain operates on the collection abstraction, not reaching into different types.

"What about configuration and options?"

Using the Options pattern is LoD-compliant when done correctly:

// ✅ GOOD: Inject only the options you need
public class EmailService
{
    private readonly EmailSettings _settings;
    
    public EmailService(IOptions<EmailSettings> options)
    {
        _settings = options.Value;
    }
    
    public void Send(string to, string body)
    {
        // _settings is a direct component, accessing its properties is fine
        var client = new SmtpClient(_settings.Host, _settings.Port);
        // ...
    }
}

Measuring LoD Compliance

You can use static analysis tools to detect LoD violations:

  • Roslyn Analyzers: Create custom analyzers for your codebase
  • Code Review: Look for multiple dots in a single statement

A simple heuristic: Count the dots. More than one dot (excluding fluent APIs and builders) often signals a violation.

Summary: LoD Best Practices for Backend Developers

  • Tell, Don't Ask => Push behavior into objects instead of pulling data out
  • Use Facade Methods => Expose only what callers need
  • Create DTOs => Flatten data for external consumers
  • Inject Interfaces => Depend on narrow, focused abstractions
  • Aggregate Boundaries => Use DDD aggregates to enforce encapsulation
  • Project in Queries => Return use-case-specific data from repositories

Conclusion

The Law of Demeter is more than an academic principle—it's a practical tool for building maintainable backend systems in .NET.

By following LoD:

  • Your code becomes easier to test because dependencies are explicit
  • Refactoring is safer because changes are localized
  • APIs are more stable because internal structures are hidden
  • Null reference exceptions decrease because you control the surface area

Start by looking for train wrecks in your codebase. Each chain of dots is an opportunity to improve your design. Apply the patterns we've discussed, and you'll find your code becoming more robust and easier to work with.

Remember: Only talk to your friends, and your code will thank you.

Additional Resources