TL;DR
  • Composition in C# helps you follow all SOLID principles naturally.
  • Use composition to break responsibilities into focused services and inject dependencies.
  • Add new features by injecting new implementations, not modifying existing code.
  • Rely on interfaces for flexibility, testability, and avoiding inheritance pitfalls.
  • Composition leads to modular, maintainable, and robust C# applications.

You’ve probably heard that composition is better than inheritance, but have you noticed how it naturally makes your code follow SOLID principles? Let’s break down why choosing composition over inheritance leads to cleaner, more maintainable code without you even trying.

What We Mean by Composition

Instead of saying “a class IS-A something” (inheritance), composition says “a class HAS-A something.” Rather than extending base classes, you inject dependencies and delegate work to other objects.

// Inheritance approach
public class EmailNotificationService : NotificationService
{
    // Inherits everything, good or bad
}

// Composition approach
public class NotificationManager
{
    private readonly IEmailSender _emailSender;
    private readonly ILogger _logger;
    
    public NotificationManager(IEmailSender emailSender, ILogger logger)
    {
        _emailSender = emailSender;
        _logger = logger;
    }
}

With composition, your class focuses on its main job and delegates specialized work to injected services.

Single Responsibility: Each Class Has One Job

Inheritance often leads to bloated base classes that do too much. Composition forces you to break responsibilities into focused services:

public class OrderProcessor
{
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;
    private readonly INotificationService _notificationService;
    
    public async Task ProcessOrder(Order order)
    {
        await _paymentService.ProcessPayment(order.Payment);
        await _inventoryService.ReserveItems(order.Items);
        await _notificationService.SendConfirmation(order.CustomerId);
    }
}

OrderProcessor coordinates the workflow, it doesn’t handle payments, inventory, or notifications itself. Each service has one clear responsibility.

Open/Closed: Extend Without Modifying

Need a new notification method? With composition, you just create a new implementation:

public interface INotificationService
{
    Task SendNotification(string message, string recipient);
}

// Add SMS without touching existing code
public class SmsNotificationService : INotificationService
{
    public Task SendNotification(string message, string recipient) =>
        // SMS implementation
        Task.CompletedTask;
}

Your OrderProcessor doesn’t need to change, just inject the new service.

Liskov Substitution: Avoid Inheritance Surprises

Inheritance hierarchies often break when child classes change expected behavior. Composition sidesteps this by depending on stable interfaces:

// Instead of fragile inheritance chains
public interface IReportGenerator
{
    byte[] GenerateReport(ReportData data);
}

public class ReportService
{
    private readonly IReportGenerator _generator;
    
    // Works with any generator that follows the contract
    public byte[] CreateReport(ReportData data) => _generator.GenerateReport(data);
}

No surprise behaviors, just predictable interfaces.

Interface Segregation: Small, Focused Contracts

Composition naturally leads to smaller, more focused interfaces. Instead of one fat interface, you inject multiple specialized ones:

public class UserService
{
    private readonly IUserValidator _validator;
    private readonly IPasswordHasher _hasher;
    private readonly IUserRepository _repository;
    
    // Each dependency has a specific, narrow purpose
}

Each service only depends on the interfaces it actually needs.

Dependency Inversion: Depend on Abstractions

Composition makes dependency inversion automatic. Your high-level classes depend on abstractions (interfaces), not concrete implementations:

public class CustomerService
{
    private readonly ICustomerRepository _repository;  // Abstraction
    private readonly IEmailValidator _validator;       // Abstraction
    
    // High-level policy doesn't depend on low-level details
}

The CustomerService doesn’t care if data comes from SQL Server, MongoDB, or a web API, it just depends on ICustomerRepository.

How Composition Helps Each SOLID Principle

PrincipleHow Composition Helps
Single ResponsibilityEach composed service has one focused job
Open/ClosedAdd new behaviors by injecting new implementations
Liskov SubstitutionAvoid inheritance pitfalls with stable interface contracts
Interface SegregationInject multiple small, focused interfaces instead of fat ones
Dependency InversionHigh-level classes depend on injected abstractions, not concrete types

The Takeaway

Composition keeps your code like LEGO bricks, easy to rearrange, test, and extend. Instead of fighting inheritance hierarchies and trying to force SOLID compliance, composition makes it natural. Your classes become coordinators that delegate work to specialized services, and SOLID principles just happen.

Next time you’re tempted to create a base class, ask yourself: “Could I inject this behavior instead?” Your future self will thank you.

Frequently Asked Questions

How does composition support the SOLID principles in C#?

Composition supports SOLID by encouraging small, focused classes that depend on abstractions. Each service or dependency is injected, making it easy to follow single responsibility, open/closed, interface segregation, and dependency inversion principles. This leads to flexible, maintainable, and testable code.

What is the difference between composition and inheritance?

Inheritance models “is-a” relationships and can lead to rigid, fragile hierarchies. Composition models “has-a” relationships, allowing classes to delegate work to injected dependencies. Composition is more flexible and avoids many pitfalls of deep inheritance.

How does composition help with the Single Responsibility Principle?

Composition allows you to break complex logic into small, focused services. Each class has one job and delegates other responsibilities to injected dependencies. This makes code easier to understand, test, and maintain.

How does composition enable the Open/Closed Principle?

With composition, you can add new behaviors by injecting new implementations of interfaces, without modifying existing code. This makes your system extensible and reduces the risk of introducing bugs when adding features.

How does composition avoid Liskov Substitution Principle violations?

Composition relies on stable interfaces rather than fragile inheritance chains. By depending on contracts, you avoid surprises from overridden methods and ensure that any implementation can be substituted safely.

How does composition relate to the Interface Segregation Principle?

Composition encourages the use of small, focused interfaces. Classes depend only on the interfaces they need, rather than large, general-purpose ones. This reduces coupling and makes code more modular.

How does composition support the Dependency Inversion Principle?

High-level classes depend on abstractions (interfaces), not concrete implementations. Dependencies are injected, making it easy to swap implementations and test code in isolation.

Can you give a real-world example of composition in C#?

Yes. For example:

public class OrderProcessor
{
    private readonly IPaymentService _paymentService;
    private readonly IInventoryService _inventoryService;
    public OrderProcessor(IPaymentService paymentService, IInventoryService inventoryService)
    {
        _paymentService = paymentService;
        _inventoryService = inventoryService;
    }
}

Here, OrderProcessor delegates payment and inventory logic to injected services.

Why is composition preferred over inheritance for maintainable code?

Composition makes it easier to change, extend, and test code. You can add or replace behaviors without modifying existing classes, reducing the risk of bugs and making your codebase more adaptable to change.

How does composition improve testability in C# applications?

By injecting dependencies as interfaces, you can easily substitute mocks or stubs in unit tests. This allows you to test each class in isolation and verify behavior without relying on complex inheritance hierarchies.
See other oops posts