.NET Architecture & Design Patterns

.NET Architecture & Design Patterns

Explore .net architecture & design patterns code snippets and tutorials

.NET Architecture & Design Patterns

Decorator pattern in ASP.NET Core: adding HybridCache caching to a C# service without changes

With a Decorator: a class that implements the same interface, wraps the original and adds behavior (caching, logging, metrics, retry).

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ======================================================================
// Q: How would you add caching to a service without changing it?
//
// With a Decorator: a class that implements the same interface, wraps the
// original and adds behavior (caching, logging, metrics, retry). The original
// code doesn't change (Open/Closed).
// ======================================================================

public interface IPriceService
{
    Task<decimal> GetPriceAsync(string sku, int customerId, CancellationToken ct);
}

public class CachedPriceService(IPriceService inner, HybridCache cache) : IPriceService
{
    public async Task<decimal> GetPriceAsync(string sku, int customerId, CancellationToken ct) =>
        await cache.GetOrCreateAsync(
            $"price:{customerId}:{sku}",                      // price per customer (B2B price lists)
            async token => await inner.GetPriceAsync(sku, customerId, token),
            cancellationToken: ct);
}

// Registration without a library
builder.Services.AddHybridCache();                                    // .NET 9
builder.Services.AddScoped<ErpPriceService>();
builder.Services.AddScoped<IPriceService>(sp => new CachedPriceService(
    sp.GetRequiredService<ErpPriceService>(),
    sp.GetRequiredService<HybridCache>()));

// or with Scrutor:  builder.Services.Decorate<IPriceService, CachedPriceService>();
.NET Architecture & Design Patterns

Repository and Unit of Work over EF Core: per-aggregate repositories vs generic IRepository<T>

DbContext is already a Unit of Work and DbSet is already a repository.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ======================================================================
// Q: Repository and Unit of Work on top of EF Core: yes or no?
//
// DbContext is already a Unit of Work and DbSet is already a repository. A
// generic IRepository<T> that exposes IQueryable adds nothing. A repository per
// aggregate root (DDD) is worthwhile, though: it always loads the whole
// aggregate and keeps the domain independent of EF.
// For reads (lists, reports) you don't need a repository: DbContext with
// AsNoTracking() and projection to a DTO (Q1). For reusable query criteria there
// is the Specification pattern (e.g. Ardalis.Specification).
// ======================================================================

public interface IOrderRepository                        // in Domain / Application
{
    Task<Order?> GetAsync(int id, CancellationToken ct);
    void Add(Order order);
}
public interface IUnitOfWork { Task<int> SaveChangesAsync(CancellationToken ct = default); }

public class EfOrderRepository(AppDbContext db) : IOrderRepository   // in Infrastructure
{
    public Task<Order?> GetAsync(int id, CancellationToken ct) =>
        db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);

    public void Add(Order order) => db.Orders.Add(order);
}

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options), IUnitOfWork
{
    public DbSet<Order> Orders => Set<Order>();
}

builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());  // same scoped instance
.NET Architecture & Design Patterns

TDD in C# with xUnit and NSubstitute: red-green-refactor on a domain aggregate

Red -> Green -> Refactor: first I write a failing test that describes the behavior, then the minimum code to make it pass, then I refactor with the test as …

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// ======================================================================
// Q: Do you practice TDD? Show me.
//
// Red -> Green -> Refactor: first I write a failing test that describes the
// behavior, then the minimum code to make it pass, then I refactor with the test
// as a safety net. Benefits: it pushes toward good design (testable code is
// loosely coupled), it serves as living documentation and it protects against
// regressions.
// - Test pyramid: many fast unit tests (domain), fewer integration tests
//   (WebApplicationFactory + Testcontainers, Q13), a few E2E tests.
// - Tools: xUnit / NUnit, NSubstitute or Moq, Testcontainers, Verify (snapshot
//   tests), Stryker.NET (mutation testing: checks whether the tests actually
//   catch bugs).
// - Mock only at the boundaries (ERP, email, time), not the DbContext. Test
//   names describe behavior: Behavior_condition_expected.
// TDD with AI: I write the tests (they are the spec) and the agent implements
// until they are green. If the AI writes both the tests and the code, the tests
// merely confirm whatever it wrote (Q2).
// ======================================================================

// Domain unit tests (red-green-refactor)
public class OrderTests
{
    [Fact]                                                     // 1. RED: the rule does not exist yet
    public void Cannot_add_line_to_placed_order()
    {
        // Arrange
        var order = OrderBuilder.Draft().WithLine("SKU-1", 2).Build();
        order.Place();

        // Act
        var act = () => order.AddLine("SKU-2", 1, new Money(10m, "EUR"));

        // Assert
        Assert.Throws<DomainException>(act);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-3)]
    public void Quantity_must_be_positive(int qty)
    {
        var order = OrderBuilder.Draft().Build();
        Assert.Throws<DomainException>(() => order.AddLine("SKU-1", qty, new Money(10m, "EUR")));
    }
}
// 2. GREEN: add the check to Order.AddLine (Q19)
// 3. REFACTOR: clean up the code, the tests stay green

// Application service test, with fakes only at the boundaries
[Fact]
public async Task Placing_an_order_saves_it_and_raises_OrderPlaced()
{
    var order = OrderBuilder.Draft().WithLine("SKU-1", 2).Build();
    var repo  = Substitute.For<IOrderRepository>();          // NSubstitute
    repo.GetAsync(order.Id, Arg.Any<CancellationToken>()).Returns(order);
    var uow   = Substitute.For<IUnitOfWork>();

    var sut = new PlaceOrderHandler(repo, uow);
    await sut.Handle(new PlaceOrder(order.Id), CancellationToken.None);

    Assert.Equal(OrderStatus.Placed, order.Status);
    Assert.Contains(order.Events, e => e is OrderPlaced);
    await uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
.NET Architecture & Design Patterns

Domain-Driven Design in .NET: aggregates, value objects and EF Core owned types in C#

Strategic DDD (the most important part):

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// ======================================================================
// Q: What is DDD? How do you apply it in .NET?
//
// Strategic DDD (the most important part):
// - Ubiquitous language: the code uses the business terms (Backorder, PriceList,
//   CreditLimit).
// - Bounded contexts: Ordering, Catalog, Pricing, Shipping. "Product" means
//   something different in each, and each context has its own model.
// - Anti-corruption layer towards the ERP / PIM (Q11): their model does not
//   enter ours.
// Tactical DDD:
// - Entity: has an identity (Id) and changes over time.
// - Value Object: no identity, immutable, equality by value, so a record in C#.
// - Aggregate / Aggregate Root: a group of objects with a single entry point
//   that protects the invariants. Lines change only through the Order. One
//   transaction changes one aggregate.
// - Domain events: OrderPlaced, and other parts of the system react (e.g.
//   outbox, Q10).
// - Repository: one per aggregate root (Q22).
// Layers (Clean / Onion Architecture): Domain (entities, value objects, events,
// with no dependencies) <- Application (use cases, interfaces such as
// IErpClient) <- Infrastructure (EF Core, HTTP clients) <- Api (controllers,
// Program.cs).
// An anemic domain model (only getters/setters, all logic in services) is the
// usual anti-pattern. And DDD is not needed for simple CRUD: it pays off where
// there are complex rules, such as B2B pricing, credit limits and partial
// deliveries.
// ======================================================================

public record Money(decimal Amount, string Currency)          // Value Object
{
    public static Money operator +(Money a, Money b) =>
        a.Currency == b.Currency ? new(a.Amount + b.Amount, a.Currency)
                                 : throw new DomainException("Currency mismatch");
}

public class Order                                            // Aggregate Root
{
    private readonly List<OrderLine> _lines = [];
    private readonly List<IDomainEvent> _events = [];
    private Order() { }                                       // for EF Core

    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public IReadOnlyCollection<IDomainEvent> Events => _events;

    public static Order Create(int customerId) => new() { CustomerId = customerId };  // factory method

    public void AddLine(string sku, int qty, Money unitPrice)
    {
        if (Status != OrderStatus.Draft) throw new DomainException("Order is not editable");
        if (qty <= 0)                    throw new DomainException("Quantity must be positive");
        if (_lines.Count >= 50)          throw new DomainException("ERP accepts max 50 lines");
        _lines.Add(new OrderLine(sku, qty, unitPrice));
    }

    public void Place()
    {
        if (_lines.Count == 0) throw new DomainException("Empty order");
        Status = OrderStatus.Placed;
        _events.Add(new OrderPlaced(Id, CustomerId));
    }
}

// EF Core mapping: no public setters, value objects as owned types
modelBuilder.Entity<Order>(b =>
{
    b.OwnsMany(o => o.Lines, l => l.OwnsOne(x => x.UnitPrice));
    b.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);  // backing field _lines
    b.Ignore(o => o.Events);
});
.NET Architecture & Design Patterns

SOLID principles in C#: SRP, Open/Closed, Liskov, ISP and DIP with .NET examples

Five principles for code that is easy to change and to test.

csharp
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// ======================================================================
// Q: Explain SOLID with C# examples.
//
// Five principles for code that is easy to change and to test. For each one: a
// bad example (✗) and the fix (✓).
// S: Single Responsibility. A class has one reason to change (one "actor"
// requesting changes).
// O: Open/Closed. Open for extension, closed for modification. New behavior = a
// new class, not a change to working code.
// ASP.NET Core itself is OCP: you add middleware, filters and health checks
// without changing the framework.
// L: Liskov Substitution. A subtype must be usable in place of its base type
// without breaking its promises: no stricter preconditions, no weaker
// postconditions, no unexpected exceptions.
// An example from .NET itself: ReadOnlyCollection<T> implements ICollection<T>
// but Add throws NotSupportedException (which is why IsReadOnly exists). The
// classic interview example is Square : Rectangle.
// I: Interface Segregation. Small, focused interfaces. A client does not depend
// on methods it doesn't use.
// D: Dependency Inversion. High-level modules (business logic) do not depend on
// low-level details (HTTP, SQL, the clock). Both depend on abstractions, and the
// abstraction is defined by the high-level code.
// DIP is not DI: Dependency Inversion is the principle (the direction of
// dependencies). Dependency Injection is the technique (you receive them through
// the constructor). The DI container is just a tool for the technique.
// Don't make it dogma. An interface for every class with a single implementation
// is over-engineering. Add abstractions where there is a real point of change or
// an external system (ERP, email, time). In the demo project:
// Application/Shipping (OCP), Application/Abstractions (DIP, ISP), handlers
// (SRP).
// ======================================================================

// S: Single Responsibility
// ✗ validation, persistence, PDF and email in one class: 4 reasons to change
public class OrderService(AppDbContext db, SmtpClient smtp)
{
    public async Task PlaceAsync(Order order)
    {
        if (order.Lines.Count == 0) throw new Exception("Empty order");
        db.Orders.Add(order);
        await db.SaveChangesAsync();
        var pdf = BuildInvoicePdf(order);           // changes when accounting changes
        await smtp.SendMailAsync(BuildMail(order, pdf)); // changes when marketing changes
    }
}

// ✓ the handler orchestrates, the rules live in the aggregate, side effects in event handlers
public class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork uow)
{
    public async Task Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var order = await orders.GetAsync(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Place();                    // rules + OrderPlaced domain event
        await uow.SaveChangesAsync(ct);   // → SendConfirmationEmail, CreateInvoicePdf handlers
    }
}

// O: Open/Closed (Strategy)
// ✗ new courier = change here, re-test all couriers
public decimal Quote(string carrier, decimal kg) => carrier switch
{
    "dhl" => 5.90m + 1.20m * kg,
    "ups" => 7.50m + 0.90m * kg,
    _     => throw new NotSupportedException(carrier)
};

// ✓ Strategy: one class per courier, the code that uses them never changes
public interface IShippingCalculator
{
    string Carrier { get; }
    Money Calculate(Shipment s);
}
public class DhlCalculator : IShippingCalculator { /* ... */ }
public class UpsCalculator : IShippingCalculator { /* ... */ }

builder.Services.AddKeyedSingleton<IShippingCalculator, DhlCalculator>("dhl");
builder.Services.AddKeyedSingleton<IShippingCalculator, UpsCalculator>("ups");
// new courier: one new class + one registration line

public class ShippingService(IServiceProvider sp)
{
    public Money Quote(Shipment s) =>
        sp.GetRequiredKeyedService<IShippingCalculator>(s.Carrier).Calculate(s);
}

// L: Liskov Substitution
// ✗ the subclass "disables" a parent method
public class FileStorage
{
    public virtual Task<Stream> ReadAsync(string path) { /* ... */ }
    public virtual Task SaveAsync(string path, Stream data) { /* ... */ }
}
public class ArchiveStorage : FileStorage
{
    public override Task SaveAsync(string path, Stream data) =>
        throw new NotSupportedException();   // anyone expecting a FileStorage blows up
}

// ✓ the hierarchy follows the real capabilities
public interface IFileReader { Task<Stream> ReadAsync(string path); }
public interface IFileWriter { Task SaveAsync(string path, Stream data); }
public class BlobStorage : IFileReader, IFileWriter { /* ... */ }
public class ArchiveStorage : IFileReader { /* ... */ }

// I: Interface Segregation
// ✗ "fat" interface: the catalog page also depends on import and pricing
public interface IProductService
{
    Task<ProductDto?> GetAsync(string sku);
    Task<IReadOnlyList<ProductDto>> SearchAsync(string text);
    Task UpdatePriceAsync(string sku, decimal price);
    Task ImportFromPimAsync(Stream file);
    Task DeleteAsync(string sku);
    // ... 15 more. Every fake in tests must implement all of them
}

// ✓ roles: each consumer asks only for what it needs
public interface IProductReader { Task<ProductDto?> GetAsync(string sku); /* Search */ }
public interface IPriceUpdater  { Task UpdatePriceAsync(string sku, decimal price); }
public interface IPimImporter   { Task ImportAsync(Stream file); }

// one class can implement several
public class ProductService : IProductReader, IPriceUpdater { /* ... */ }

// D: Dependency Inversion
// ✗ hard-coded dependencies: no unit tests, cannot swap the ERP
public class OrderSync
{
    public async Task SyncAsync(Order order)
    {
        var client = new SapRfcClient("sap-prod:3300");          // concrete
        if (DateTime.Now.Hour < 6) return;                      // hidden dependency: the clock
        await client.CreateSalesOrderAsync(order);
    }
}

// ✓ Application layer: defines what it needs
public interface IErpClient { Task<string> SendOrderAsync(Order order, CancellationToken ct); }

public class OrderSync(IErpClient erp, TimeProvider clock)
{
    public async Task SyncAsync(Order order, CancellationToken ct)
    {
        if (clock.GetLocalNow().Hour < 6) return;     // in tests: FakeTimeProvider
        await erp.SendOrderAsync(order, ct);
    }
}

// Infrastructure layer: the implementation. DI wires them together in Program.cs
public class SapErpClient(HttpClient http) : IErpClient { /* ... */ }
builder.Services.AddHttpClient<IErpClient, SapErpClient>();
builder.Services.AddSingleton(TimeProvider.System);