.NET Architecture & Design Patterns

.NET Architecture & Design Patterns

Explore .net architecture & design patterns code snippets and tutorials

.NET Architecture & Design Patterns

N-tier architecture in .NET: a layered BLL/DAL example in C# and its problems

Layer = logical separation in the code.

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
// ======================================================================
// Q: Show me an N-tier application in .NET. What's the problem with it?
//
// Layer = logical separation in the code. Tier = physical separation (a
// different process or server). The classic 3-tier setup: browser, application
// server, database server. In practice, "N-tier" usually means the layers.
// - Problem 1: the business logic depends on the DAL. Changing the database or
//   ORM affects the BLL.
// - Problem 2: unit testing OrderManager without a database is hard (concrete
//   repository).
// - Problem 3: anemic entities: the rules are scattered across managers, and
//   Status is changed from everywhere.
// - Advantage: simple, everyone understands it. For CRUD without special rules
//   it is enough.
// ======================================================================

// Classic N-tier layering: Web -> BLL -> DAL
Shop.Web  →  Shop.BLL  →  Shop.DAL  →  SQL
(controllers)  (managers)    (DbContext, entities, repositories)

// DAL: EF entities and repositories
public class OrderRepository(ShopDbContext db)
{
    public Task<OrderEntity?> GetAsync(int id) =>
        db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id);
    public Task SaveAsync() => db.SaveChangesAsync();
}

// BLL: depends directly on the DAL and the EF entities
public class OrderManager(OrderRepository repo)
{
    public async Task CancelAsync(int id)
    {
        var order = await repo.GetAsync(id) ?? throw new NotFoundException();
        if (order.Status == "Shipped") throw new BusinessException("Already shipped");
        order.Status = "Cancelled";      // anemic entity: anyone can change it
        await repo.SaveAsync();
    }
}

// Presentation
[HttpPost("{id}/cancel")]
public Task Cancel(int id) => orderManager.CancelAsync(id);
.NET Architecture & Design Patterns

Builder pattern in C#: Test Data Builder for xUnit tests of a domain aggregate

When an object has many optional parameters or is built in steps.

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
// ======================================================================
// Q: Builder pattern: when do you use it?
//
// When an object has many optional parameters or is built in steps. In .NET:
// WebApplication.CreateBuilder(), StringBuilder, UriBuilder. In practice I use
// it a lot in tests (Test Data Builder), like the OrderBuilder from Q20.
// In the interview don't just name patterns. Say what problem it solved and when
// you don't use it. A pattern without a reason to exist is over-engineering.
// ======================================================================

// Test Data Builder
public class OrderBuilder
{
    private int _customerId = 1;
    private readonly List<(string Sku, int Qty)> _lines = [];

    public static OrderBuilder Draft() => new();
    public OrderBuilder ForCustomer(int id)            { _customerId = id; return this; }
    public OrderBuilder WithLine(string sku, int qty)  { _lines.Add((sku, qty)); return this; }

    public Order Build()
    {
        var order = Order.Create(_customerId);
        foreach (var (sku, qty) in _lines)
            order.AddLine(sku, qty, new Money(10m, "EUR"));
        return order;                                  // always valid, goes through the domain rules
    }
}
.NET Architecture & Design Patterns

CQRS, Mediator and Observer in C#: command handlers and domain events without MediatR

CQRS: commands change state through the aggregate.

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
// ======================================================================
// Q: Explain Mediator / CQRS and Observer (domain events).
//
// CQRS: commands change state through the aggregate. Queries read directly with
// AsNoTracking() and projection. You don't need two databases: separation in the
// code is enough. Mediator: the controller doesn't know who executes the
// command, it just sends it.
// Observer (domain events): many handlers react to the same event, without the
// Order knowing about them.
// - Events are dispatched in SaveChangesAsync (e.g. with an EF Core
//   SaveChangesInterceptor).
// - Anything that leaves the process (ERP, another service) goes through the
//   outbox (Q10), not in-memory, so it isn't lost.
// - MediatR became commercial (commercial license) in 2025. Alternatives: your
//   own simple dispatcher as above, Wolverine, or a source-generated mediator.
// ======================================================================

// CQRS command, handler and thin controller
public record PlaceOrder(int OrderId);

public interface ICommandHandler<in TCommand>
{
    Task Handle(TCommand command, CancellationToken ct);
}

public class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork uow) : ICommandHandler<PlaceOrder>
{
    public async Task Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var order = await orders.GetAsync(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Place();                    // the business logic lives in the aggregate
        await uow.SaveChangesAsync(ct);   // domain events are dispatched here
    }
}

// Controller: thin
[HttpPost("{id:int}/place")]
public async Task<IActionResult> Place(int id, ICommandHandler<PlaceOrder> handler, CancellationToken ct)
{
    await handler.Handle(new PlaceOrder(id), ct);
    return NoContent();
}

// Observer: multiple domain event handlers for OrderPlaced
public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
{
    Task Handle(TEvent e, CancellationToken ct);
}

public class SendOrderConfirmation(IEmailSender email) : IDomainEventHandler<OrderPlaced> { /* ... */ }
public class WriteErpOutboxMessage(AppDbContext db)   : IDomainEventHandler<OrderPlaced> { /* ... */ }

builder.Services.AddScoped<IDomainEventHandler<OrderPlaced>, SendOrderConfirmation>();
builder.Services.AddScoped<IDomainEventHandler<OrderPlaced>, WriteErpOutboxMessage>();
.NET Architecture & Design Patterns

Chain of Responsibility in ASP.NET Core: middleware pipeline and HttpClient DelegatingHandler

The middleware pipeline: each middleware does something, then calls next or stops the chain (short-circuit).

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: Where is Chain of Responsibility in ASP.NET Core?
//
// The middleware pipeline: each middleware does something, then calls next or
// stops the chain (short-circuit). The same applies to DelegatingHandler in
// HttpClient.
// ======================================================================

app.UseExceptionHandler();      // order matters: first so it catches everything
app.UseAuthentication();        // before Authorization
app.UseAuthorization();

app.Use(async (ctx, next) =>    // custom middleware
{
    var sw = Stopwatch.StartNew();
    await next(ctx);            // the next link in the chain
    app.Logger.LogInformation("{Path} took {Ms} ms", ctx.Request.Path, sw.ElapsedMilliseconds);
});

// Outgoing: chain in HttpClient
public class CorrelationIdHandler(IHttpContextAccessor accessor) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
    {
        req.Headers.TryAddWithoutValidation("X-Correlation-Id", accessor.HttpContext?.TraceIdentifier);
        return base.SendAsync(req, ct);
    }
}
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddHttpClient<IErpClient, ErpClient>().AddHttpMessageHandler<CorrelationIdHandler>();
.NET Architecture & Design Patterns

Factory, Strategy and Adapter patterns in C#: keyed services and an ERP anti-corruption layer

Strategy + Factory: each courier is a strategy (Q18).

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
// ======================================================================
// Q: Show me Factory, Strategy and Adapter in a real scenario.
//
// Strategy + Factory: each courier is a strategy (Q18). The factory picks the
// right one at runtime, here with keyed services (.NET 8).
// Adapter: translates a foreign interface (the ERP SDK) into ours. This is also
// the anti-corruption layer.
// ======================================================================

// Strategy + Factory with keyed services (.NET 8)
builder.Services.AddKeyedScoped<IShippingCalculator, DhlCalculator>("dhl");
builder.Services.AddKeyedScoped<IShippingCalculator, UpsCalculator>("ups");
builder.Services.AddScoped<IShippingCalculatorFactory, ShippingCalculatorFactory>();

public class ShippingCalculatorFactory(IServiceProvider sp) : IShippingCalculatorFactory
{
    // IServiceProvider is allowed here: the factory is infrastructure, not business logic
    public IShippingCalculator Create(string carrier) =>
        sp.GetKeyedService<IShippingCalculator>(carrier)
        ?? throw new NotSupportedException($"Carrier '{carrier}' is not supported");
}

// Usage in an endpoint: [FromKeyedServices] when the key is known at compile time
app.MapGet("/quote/dhl", ([FromKeyedServices("dhl")] IShippingCalculator calc) => /* ... */);

// Adapter: ERP SDK behind our own interface (anti-corruption layer)
public class SapErpAdapter(SapClient sap) : IErpClient
{
    public Task SendOrderAsync(Order order, CancellationToken ct)
    {
        var sapOrder = new SapSalesOrder
        {
            KUNNR = order.CustomerId.ToString(),            // SAP field names stay in here
            ITEMS = order.Lines.Select(l => new SapItem { MATNR = l.Sku, KWMENG = l.Quantity }).ToList()
        };
        return sap.CreateSalesOrderAsync(sapOrder, ct);
    }
}