Back to blog
Backend Systemsintermediate

SOLID in .NET — With Real Code, Not Textbook Examples

Five principles demonstrated through an OrderFlow API — not shapes and animals. See how SRP, OCP, LSP, ISP, and DIP apply to real command handlers, payment providers, and repositories. Plus when to skip them.

LearnixoApril 14, 20267 min read
.NETC#SOLIDClean ArchitectureDesign PatternsASP.NET Core
Share:𝕏

The Context: OrderFlow API

All examples come from a simplified order management API with commands, payment processing, and notifications. No bank account shapes. No animal hierarchies.


S — Single Responsibility Principle

One reason to change. If your class changes for two different reasons, it has two responsibilities.

Before:

C#
// Bad: handles business logic AND sends email AND logs
public class OrderService
{
    public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
    {
        // 1. Business logic
        var order = new Order(cmd.CustomerId, cmd.Items);
        await _repository.AddAsync(order);

        // 2. Email — changes when template changes
        await _emailClient.SendAsync(cmd.Email, "Order Confirmation", BuildEmailBody(order));

        // 3. Logging — changes when log format changes
        _logger.LogInformation("Order {Id} placed at {Time}", order.Id, DateTime.UtcNow);
    }
}

After — split into a thin handler that orchestrates, and separate services:

C#
// Handler: only knows about the order creation sequence
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>
{
    private readonly IOrderRepository _orders;
    private readonly IPublisher _publisher;

    public PlaceOrderHandler(IOrderRepository orders, IPublisher publisher)
    {
        _orders    = orders;
        _publisher = publisher;
    }

    public async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = new Order(cmd.CustomerId, cmd.Items);
        await _orders.AddAsync(order, ct);
        await _publisher.Publish(new OrderPlacedEvent(order.Id, cmd.Email), ct);
        return order.Id;
    }
}

// Email handler — only knows about sending confirmations, changes with email logic
public class SendOrderConfirmationHandler : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
        => await _emailService.SendOrderConfirmationAsync(notification.Email, notification.OrderId, ct);
}

Each class has exactly one axis of change.


O — Open/Closed Principle

Open for extension, closed for modification. Add new behavior without touching existing code.

Adding a new payment method should not require touching PaymentService.

Before:

C#
// Bad: every new payment method requires modifying this switch
public class PaymentService
{
    public Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        return request.Method switch
        {
            "stripe"  => ProcessStripeAsync(request),
            "paypal"  => ProcessPayPalAsync(request),
            "crypto"  => throw new NotImplementedException(), // always coming "soon"
            _         => throw new InvalidOperationException("Unknown payment method.")
        };
    }
}

After — introduce an abstraction and let DI wire in new providers:

C#
// Abstraction
public interface IPaymentProvider
{
    string Method { get; }
    Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct);
}

// Existing providers — never modified when new ones are added
public class StripePaymentProvider : IPaymentProvider
{
    public string Method => "stripe";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

public class PayPalPaymentProvider : IPaymentProvider
{
    public string Method => "paypal";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

// New provider: zero changes to existing code
public class CryptoPaymentProvider : IPaymentProvider
{
    public string Method => "crypto";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

// Dispatcher — also unchanged when providers are added
public class PaymentService
{
    private readonly Dictionary<string, IPaymentProvider> _providers;

    public PaymentService(IEnumerable<IPaymentProvider> providers)
        => _providers = providers.ToDictionary(p => p.Method);

    public Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct)
    {
        if (!_providers.TryGetValue(request.Method, out var provider))
            throw new InvalidOperationException($"No provider for method '{request.Method}'.");

        return provider.ProcessAsync(request, ct);
    }
}

Register all providers once:

C#
builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, PayPalPaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, CryptoPaymentProvider>();
builder.Services.AddScoped<PaymentService>();

L — Liskov Substitution Principle

Subtypes must be substitutable for their base type without the caller caring which concrete type it has.

Violation — ReadOnlyOrderRepository throws on a write method from the interface:

C#
// Bad: subtype breaks the contract
public class ReadOnlyOrderRepository : IOrderRepository
{
    public Task<Order?> GetByIdAsync(int id) => _db.Orders.FindAsync(id).AsTask();

    public Task AddAsync(Order order)
        => throw new NotSupportedException("This repository is read-only."); // LSP violation
}

Fix — split the interface (see ISP below), so ReadOnlyOrderRepository never implements a method it can't support:

C#
public interface IOrderReadRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId, CancellationToken ct);
}

public interface IOrderWriteRepository
{
    Task AddAsync(Order order, CancellationToken ct);
    Task UpdateAsync(Order order, CancellationToken ct);
}

// Read-only implementation: only implements what it can support
public class ReadOnlyOrderRepository : IOrderReadRepository { ... }

// Full implementation: substitutable anywhere IOrderReadRepository is expected
public class OrderRepository : IOrderReadRepository, IOrderWriteRepository { ... }

I — Interface Segregation Principle

Clients should not depend on methods they don't use. Fat interfaces force classes to implement no-ops or throw.

Before:

C#
// Bad: everything gets all these methods even if it needs one
public interface INotificationService
{
    Task SendEmailAsync(string to, string subject, string body);
    Task SendSmsAsync(string phone, string message);
    Task SendPushAsync(string deviceToken, string message);
    Task SendSlackAsync(string channel, string message);
}

// SMS service forced to implement irrelevant methods
public class SmsNotificationService : INotificationService
{
    public Task SendSmsAsync(string phone, string message) { ... }
    public Task SendEmailAsync(...)  => throw new NotImplementedException();
    public Task SendPushAsync(...)   => throw new NotImplementedException();
    public Task SendSlackAsync(...) => throw new NotImplementedException();
}

After:

C#
public interface IEmailSender  { Task SendEmailAsync(string to, string subject, string body, CancellationToken ct); }
public interface ISmsSender    { Task SendSmsAsync(string phone, string message, CancellationToken ct); }
public interface IPushSender   { Task SendPushAsync(string deviceToken, string message, CancellationToken ct); }
public interface ISlackSender  { Task SendSlackAsync(string channel, string message, CancellationToken ct); }

// Each class implements only what it does
public class SmsNotificationService : ISmsSender { ... }
public class EmailNotificationService : IEmailSender { ... }

// Composed service if you need multi-channel
public class OrderNotifier
{
    private readonly IEmailSender _email;
    private readonly ISmsSender   _sms;

    public OrderNotifier(IEmailSender email, ISmsSender sms)
    {
        _email = email;
        _sms   = sms;
    }
}

D — Dependency Inversion Principle

Depend on abstractions, not concretions. High-level modules (business logic) should not depend on low-level modules (EF Core, SMTP clients). Both depend on interfaces.

ASP.NET Core's DI container is exactly this principle at the infrastructure level.

Before:

C#
// Bad: OrderService creates its own dependencies — can't be unit tested, can't swap implementations
public class OrderService
{
    private readonly AppDbContext _db = new AppDbContext(new DbContextOptions<AppDbContext>());
    private readonly SmtpEmailClient _email = new SmtpEmailClient("smtp.company.com", 587);
}

After:

C#
// Good: depends on interfaces, injected by the container
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly IEmailSender     _email;

    public OrderService(IOrderRepository orders, IEmailSender email)
    {
        _orders = orders;
        _email  = email;
    }
}

// Registrations in Program.cs — the composition root
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();

// In tests — swap for fakes without changing OrderService at all
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<IEmailSender, FakeEmailSender>();

The key insight: OrderService has no using directive for Entity Framework or any SMTP library. It only knows about interfaces defined in the domain layer.


When NOT to Follow SOLID

SOLID is a tool, not a religion. Over-engineering is real:

  • Single endpoint microservice: SRP through 5 layers of abstraction for GET /health is absurd
  • Throwaway scripts and migration utilities: interfaces and DI add zero value in a 50-line script
  • CRUD endpoints with no business logic: a repository that just proxies EF Core is often fine to use directly
  • Small teams, early-stage products: premature abstraction makes code harder to read and change, not easier

The signal to apply SOLID: you've already felt the pain of not having it — tests are hard to write, adding a new case requires touching five files, or bugs keep appearing in unrelated places when you change something.

Key Takeaways

  • SRP: each class changes for one reason — split by axis of change, not by type of code
  • OCP: use a strategy/provider pattern behind an interface; registration in the DI container is the extension point
  • LSP: if a subtype can't honour the interface contract, split the interface
  • ISP: small, focused interfaces beat one large fat interface — compose them where needed
  • DIP: depend on abstractions; the DI container is your composition root — production and test code only swap registrations

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.