Learnixo

.NET & C# Development · Lesson 108 of 229

Scaling .NET Systems — Clean Architecture Meets High Load

Scaling .NET Systems — Clean Architecture Meets High Load

Clean Architecture gives you boundaries. Scalability requires exploiting those boundaries. This article maps Clean Architecture's layers to concrete scaling techniques.


The Clean Architecture Scaling Advantage

Clean Architecture separates concerns into layers:

Domain Layer:        pure business logic — stateless, infinitely scalable
Application Layer:   use cases (CQRS handlers) — stateless, horizontally scalable
Infrastructure:      databases, caches, message queues — the bottlenecks
API Layer:           HTTP endpoints — horizontally scalable with a load balancer

The rule: only Infrastructure is stateful. Everything else can scale horizontally
without coordination because it has no local state to synchronise.

Step 1: Make Everything Stateless

C#
// BAD: Static or in-memory state — breaks horizontal scaling
public class OrderService
{
    private static readonly List<Order> _cache = new();   // dies on pod restart
    private static int _requestCount = 0;   // not shared across pods
}

// GOOD: All state lives in infrastructure (Redis, DB, message broker)
public class CreateOrderHandler(
    IOrderRepository repo,       // PostgreSQL
    IDistributedCache cache,     // Redis
    IEventBus eventBus)          // RabbitMQ / Azure Service Bus
    : IRequestHandler<CreateOrderCommand, int>
{
    public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Items);
        await repo.AddAsync(order, ct);
        // State goes to DB or cache — any pod can serve the next request
        await cache.RemoveAsync($"orders:customer:{cmd.CustomerId}", ct);
        await eventBus.PublishAsync(new OrderCreatedEvent(order.Id), ct);
        return order.Id;
    }
}

Step 2: CQRS Read/Write Separation for Database Scaling

C#
// Separate read and write database connections
// Write: primary (strong consistency, lower throughput)
// Read:  replicas (eventual consistency, higher throughput, more parallelism)

public class DatabaseOptions
{
    public string WriteConnection { get; init; } = "";
    public string ReadConnection  { get; init; } = "";
}

// Command handler — always uses write connection
public class CreateOrderHandler(IDbConnectionFactory dbFactory)
    : IRequestHandler<CreateOrderCommand, int>
{
    public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
    {
        using var conn = dbFactory.CreateWriteConnection();
        const string sql = "INSERT INTO Orders (CustomerId, Total) VALUES (@CustomerId, @Total) RETURNING Id";
        return await conn.QuerySingleAsync<int>(sql, new { cmd.CustomerId, Total = 0 });
    }
}

// Query handler — uses read replica (can be slightly stale, but faster)
public class GetOrdersByCustomerHandler(IDbConnectionFactory dbFactory)
    : IRequestHandler<GetOrdersByCustomerQuery, List<OrderDto>>
{
    public async Task<List<OrderDto>> Handle(GetOrdersByCustomerQuery q, CancellationToken ct)
    {
        using var conn = dbFactory.CreateReadConnection();   // replica connection
        const string sql = "SELECT Id, Total, Status FROM Orders WHERE CustomerId = @CustomerId";
        return (await conn.QueryAsync<OrderDto>(sql, new { q.CustomerId })).ToList();
    }
}

// Connection factory — clean abstraction in Application, implemented in Infrastructure
public interface IDbConnectionFactory
{
    IDbConnection CreateWriteConnection();
    IDbConnection CreateReadConnection();
}

public class NpgsqlConnectionFactory(DatabaseOptions opts) : IDbConnectionFactory
{
    public IDbConnection CreateWriteConnection() => new NpgsqlConnection(opts.WriteConnection);
    public IDbConnection CreateReadConnection()  => new NpgsqlConnection(opts.ReadConnection);
}

Step 3: Caching at the Application Layer

C#
// Cache-aside pattern in a CQRS pipeline behaviour
public class CachingBehaviour<TRequest, TResponse>(IDistributedCache cache)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICacheable
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        string key = request.CacheKey;
        var cached = await cache.GetStringAsync(key, ct);

        if (cached is not null)
            return JsonSerializer.Deserialize<TResponse>(cached)!;

        var response = await next();

        var json = JsonSerializer.Serialize(response);
        await cache.SetStringAsync(key, json, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = request.CacheDuration
        }, ct);

        return response;
    }
}

// Queries opt in by implementing ICacheable
public interface ICacheable
{
    string   CacheKey      { get; }
    TimeSpan CacheDuration { get; }
}

public record GetProductCatalogueQuery : IRequest<List<ProductDto>>, ICacheable
{
    public string   CacheKey      => "catalogue:all";
    public TimeSpan CacheDuration => TimeSpan.FromMinutes(10);
}

// Commands invalidate cache in a separate behaviour or domain event handler
public class CacheInvalidationBehaviour<TRequest, TResponse>(IDistributedCache cache)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IInvalidatesCache
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var response = await next();
        foreach (var key in request.CacheKeysToInvalidate)
            await cache.RemoveAsync(key, ct);
        return response;
    }
}

Step 4: Offload Work with Async Messaging

C#
// Commands that do expensive work should be async — respond 202 Accepted
// Keep the API responsive under load

// Endpoint: accept the request, publish to queue, return immediately
[HttpPost("orders")]
public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request)
{
    var command = new PlaceOrderCommand(request.CustomerId, request.Items);
    var jobId   = Guid.NewGuid().ToString();

    await _messageBus.PublishAsync(new PlaceOrderMessage(jobId, command));

    return Accepted(new { JobId = jobId, StatusUrl = $"/orders/jobs/{jobId}" });
}

// Worker processes order asynchronously
public class OrderPlacementWorker(IMediator mediator) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var message in _bus.ConsumeAsync<PlaceOrderMessage>(ct))
        {
            try
            {
                var orderId = await mediator.Send(message.Command, ct);
                await _jobStore.CompleteAsync(message.JobId, orderId);
            }
            catch (Exception ex)
            {
                await _jobStore.FailAsync(message.JobId, ex.Message);
            }
        }
    }
}

Step 5: Horizontal Scaling with Kubernetes

YAML
# Clean Architecture APIs scale horizontally because they are stateless
apiVersion: apps/v1
kind: Deployment
metadata:
  name: systemforge-api
spec:
  replicas: 3   # 3 pods, all stateless  traffic load balanced
  selector:
    matchLabels:
      app: systemforge-api
  template:
    spec:
      containers:
      - name: api
        image: systemforge-api:latest
        env:
        - name: ConnectionStrings__Write
          valueFrom:
            secretKeyRef:
              name: db-secrets
              key: write-connection
        - name: ConnectionStrings__Read
          valueFrom:
            secretKeyRef:
              name: db-secrets
              key: read-connection
        - name: Redis__ConnectionString
          valueFrom:
            secretKeyRef:
              name: cache-secrets
              key: redis-connection
---
# Autoscale based on CPU/memory
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
  scaleTargetRef:
    name: systemforge-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

Step 6: Database Scalability Patterns

C#
// Connection pooling — critical for high-load APIs
// Npgsql pools connections by default — configure pool size
"ConnectionString": "Host=localhost;Database=systemforge;Max Pool Size=100;Min Pool Size=5"

// Avoid N+1 queries — the #1 database scalability killer
// BAD: N+1
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
    order.Customer = await db.Customers.FindAsync(order.CustomerId);   // N additional queries

// GOOD: single JOIN query
var orders = await db.Orders
    .Include(o => o.Customer)    // single query with JOIN
    .ToListAsync();

// Or: bulk load with Dapper
const string sql = """
    SELECT o.*, c.Name AS CustomerName
    FROM Orders o
    JOIN Customers c ON c.Id = o.CustomerId
    """;
var orders = await conn.QueryAsync<OrderWithCustomer>(sql);

// Pagination — never return unbounded result sets
public record GetOrdersQuery(int Page = 1, int PageSize = 20) : IRequest<PagedResult<OrderDto>>;

// Apply at query level — never load all and paginate in memory
var orders = await db.Orders
    .OrderByDescending(o => o.CreatedAt)
    .Skip((query.Page - 1) * query.PageSize)
    .Take(query.PageSize)
    .ToListAsync(ct);

Scaling Anti-Patterns to Avoid

ANTI-PATTERN 1: Distributed Monolith
  Splitting into microservices without clean boundaries → services tightly coupled at the DB or API level
  Better: clean modular monolith first, extract services only when necessary

ANTI-PATTERN 2: Chatty Microservices
  Service A calls B calls C calls D synchronously → cascading latency
  Better: async messaging for non-critical paths, aggregate data in read models

ANTI-PATTERN 3: Shared Database Between Services
  Two services write to the same table → coupling, locks, migration hell
  Better: each service owns its data; share via API or events

ANTI-PATTERN 4: Fat Controllers
  Business logic in controllers → untestable, not scalable to teams
  Better: thin controllers → CQRS handlers → domain layer

ANTI-PATTERN 5: Synchronous calls for everything
  Every user request waits for emails, notifications, analytics
  Better: publish events for non-critical side effects; respond immediately

Interview Answer

"Clean Architecture supports scalability because its layers are stateless by design — Domain and Application layers have no persistent state, so they scale horizontally without coordination. The key techniques: (1) Stateless API pods behind a load balancer — all state in Redis or the database; (2) CQRS read/write split — commands go to the write primary, queries go to read replicas with higher throughput; (3) Caching at the Application layer via a MediatR pipeline behaviour — queries opt in with a cache key and TTL; (4) Async messaging — expensive commands publish to a queue and return 202 Accepted immediately; workers process at their own pace; (5) Avoiding N+1 queries — the most common database killer, fixed with EF Core Include or a single Dapper JOIN. The biggest trap: premature microservice extraction (distributed monolith) — boundaries without clean contracts make coordination worse than a monolith. Start with a clean modular monolith; extract services only when a specific service has scaling needs that differ from the rest of the system."