.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
// 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
// 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
// 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
// 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
# 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: 70Step 6: Database Scalability Patterns
// 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 immediatelyInterview 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."