Section 11

Architecture styles, microservice patterns & serverless

The shapes systems take, from monoliths to microservices to serverless, and the proven patterns for splitting services, sharing data, communicating and surviving failure.

11.1 Different types of architecture patterns

An architecture style is the overall shape of a system: how it's split into parts and how those parts talk.

🧱 Monolith

One deployable unit. Simple to build, test and deploy at first. Grows into a "big ball of mud".

🍰 Layered / N-tier

Presentation β†’ Business β†’ Data. Classic enterprise style. Watch out for "sinkhole" layers that only pass calls through.

πŸ“¦ Modular monolith

One deployable, with strict module boundaries inside. A great default before microservices.

πŸ”— SOA

Coarse-grained shared services, often through an ESB. The predecessor of microservices.

🧩 Microservices

Small, independently deployable services, each owning one business capability and its data.

⚑ Event-driven

Components communicate through events (broker or mediator topology). Highly decoupled.

☁️ Serverless

Functions and managed services. No servers to manage, pay per use.

⬑ Hexagonal / Clean / Onion

Domain at the centre. Ports and adapters isolate it from DBs, UIs and frameworks. Very testable. More in 11.2.

πŸ”Œ Microkernel (plug-in)

Core system + plug-ins (IDEs, rules engines, browsers).

🌌 Space-based

In-memory data grid and processing units, for extreme, spiky scale.

πŸ–Ό Micro-frontends

The UI split by domain, each part owned by its own team.

🧫 Cell-based

Copies of the full stack ("cells"), each serving a slice of users, so one failure has a limited blast radius.

Hexagonal (Ports & Adapters) Inbound (driving) adapters Outbound (driven) adapters β—† DOMAIN CORE β—† (no framework dependencies) REST API Queue CLI / Test Adapter Adapter Adapter Adapter Adapter Adapter SQL DB Kafka 3rd-party API β—‹ inbound ports outbound ports β—‹
How to implement it
StyleLibrary / APINotes
Layered / monolithbuilt-in ASP.NET Core + EF CoreOne project per layer, wired together with dependency injection
Modular monolithbuilt-in separate class library per module + internal types; WolverineFx for in-process messagesModules expose a small public contract and talk via interfaces or in-process events
Hexagonal / Cleanbuilt-in interfaces (ports) + DI (adapters)The domain project references no framework packages. Adapters live in outer projects
Enforce boundariesNetArchTest.Rules or TngTech.ArchUnitNETUnit tests that fail the build when a layer or module uses something it shouldn't
Microkernelbuilt-in AssemblyLoadContextLoad plug-in assemblies at runtime (and unload them if the context is collectible)
Event-drivenSee 10.3: Service Bus, Event Hubs, Kafka, WolverineBroker topology in .NET means the messaging SDKs and frameworks from section 10
// xUnit + NetArchTest: the domain must not depend on infrastructure or EF Core
[Fact]
public void Domain_has_no_infrastructure_dependencies()
{
    var result = Types.InAssembly(typeof(Account).Assembly)
        .That().ResideInNamespace("Bank.Domain")
        .ShouldNot().HaveDependencyOnAny("Bank.Infrastructure", "Microsoft.EntityFrameworkCore")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

11.2 Hexagonal, Clean & Onion architecture Added

Put the business rules in the middle and make every dependency point inwards. The database, UI and frameworks become replaceable plug-ins at the edge.

The ports-and-adapters diagram is in 11.1. This section covers the rule behind it, how the three names differ, and when the pattern is overkill.

Clean / Onion: concentric layers Frameworks & drivers Interface adapters Use cases Entities HTTP SQL DB Kafka UI / tests Frameworks & drivers Interface adapters Use cases (application) Entities (domain) ASP.NET Core, EF Core, Kafka client, Azure SDKs Controllers, repositories, mappers, gateways TransferFunds, OpenAccount: run the steps Account, Money, limits: pure business rules Source-code dependencies point inwards only. Inner rings never name outer ones.

πŸ”Œ Port

An interface the core owns. Inbound (driving) port: a use case the outside calls, like TransferFunds. Outbound (driven) port: something the core needs, like IAccountStore.

🧩 Adapter

The code at the edge that plugs into a port: an API endpoint or queue listener (inbound), an EF Core repository or HTTP client (outbound).

➑️ Dependency rule

Outer code may use inner code, never the reverse. The core declares an interface. Infrastructure implements it (dependency inversion).

Hexagonal (ports & adapters)OnionClean
Coined byAlistair Cockburn (2005)Jeffrey Palermo (2008)Robert C. Martin (2012)
PictureInside vs outside, ports on the edgeConcentric layersConcentric circles
Layers insideNot prescribed: just "the application"Domain model β†’ domain services β†’ application services β†’ UI / infrastructure / testsEntities β†’ use cases β†’ interface adapters β†’ frameworks & drivers
Main pointSwap adapters freely. Drive the app from testsDomain at the centre, infrastructure at the edgeThe dependency rule

Mostly the same idea with different names: domain in the middle, dependencies pointing inwards, I/O at the edge.

βœ… Benefits

  • Test business rules in milliseconds, with fakes and no database
  • Swap a database, broker or vendor API without touching the rules
  • Framework upgrades stay at the edge
  • Clear places for code to live

⚠️ Overkill risks

  • Simple CRUD drowning in mappers and pass-through layers
  • An interface for every class "just in case"
  • A repository wrapping EF Core, which is already a repository
  • Many projects and ceremony that slow small teams down

The alternative: vertical slice architecture

  • Explain it with one test: "Does my domain project reference EF Core or ASP.NET Core?" If yes, it isn't clean, whatever the folders are called.
  • Use it for complex, long-lived domains (payments, lending rules). For simple CRUD, vertical slices or a plain layered app is often better.
  • Enforce the rule with architecture tests in CI (example in 11.1).
How to implement it
NeedLibrary / APINotes
Rings as projectsbuilt-in separate projects: Domain, Application, Infrastructure, ApiProjectReferences only point inwards. The Domain project has no ASP.NET Core or EF Core packages
Ports & adaptersbuilt-in interfaces + Microsoft.Extensions.DependencyInjectionThe core declares IAccountStore. The Api project registers the EF Core adapter in DI
Architecture testsTngTech.ArchUnitNET (+ its xUnit / NUnit / MSTest extension)Actively maintained, rich rule language. Fails the build when a layer uses something it shouldn't
Architecture tests (simple)NetArchTest.RulesSmall fluent API (see 11.1), but no release since 1.3.2 in May 2021
Vertical slicesbuilt-in minimal APIs with MapGroup, one folder per feature; optional WolverineFx handlersEach feature maps its own endpoints. No shared service layer needed
// Application (inner): owns the port. No EF Core, no HTTP.
public interface IAccountStore                              // outbound port
{
    Task<Account?> FindAsync(Guid id, CancellationToken ct);
    Task SaveAsync(CancellationToken ct);                   // one unit of work
}

public sealed class TransferFunds(IAccountStore store)      // use case (inbound port)
{
    public async Task Handle(Guid from, Guid to, decimal amount, CancellationToken ct)
    {
        var source = await store.FindAsync(from, ct) ?? throw new KeyNotFoundException();
        var target = await store.FindAsync(to, ct) ?? throw new KeyNotFoundException();
        source.Withdraw(amount);                            // the rules live in the domain
        target.Deposit(amount);
        await store.SaveAsync(ct);
    }
}

// Infrastructure (outer): the adapter depends on the port, not the other way round
public sealed class EfAccountStore(BankDbContext db) : IAccountStore
{
    public Task<Account?> FindAsync(Guid id, CancellationToken ct) =>
        db.Accounts.FindAsync([id], ct).AsTask();
    public Task SaveAsync(CancellationToken ct) => db.SaveChangesAsync(ct);
}

// Api (outermost): wire it up
builder.Services.AddScoped<IAccountStore, EfAccountStore>();
builder.Services.AddScoped<TransferFunds>();

Clean, Onion, Hexagonal: three names, one rule πŸ§…

🎯 Business rules sit in the middle.

➑️ Every dependency points inwards. The core never names EF Core, ASP.NET Core or Kafka.

πŸ”Œ Ports are interfaces the core owns. Adapters are the DB, API and broker code at the edge.

πŸ§ͺ You can test the core in milliseconds, with no database.

⚠️ For simple CRUD it's ceremony. Try vertical slices instead.

Quick check: does your Domain project reference EF Core? Then it isn't clean, whatever the folders say.

#SoftwareArchitecture #CleanArchitecture #DotNet #DDD

11.3 Monolith vs Microservices Added

MonolithMicroservices
DeployEverything togetherEach service on its own
ScalingWhole appPer service
DataOne shared DBDB per service
TeamWorks for one teamMany autonomous teams
ComplexityInside the codeIn the network: distributed systems, ops, observability
FailureOne bug can take everything downIsolated, if designed with resilience patterns
TransactionsACID, easySagas, eventual consistency
  • Conway's Law: systems mirror the communication structure of the organisation. Design teams and services together.
  • "Don't start with microservices." Start with a modular monolith, then extract services once boundaries are proven and scaling or team needs demand it.
  • Watch for the distributed monolith anti-pattern: services that must be deployed together or share a DB.
How to implement it
Library / APIPackageUse it for
AspireAspire.Hosting.AppHost (Aspire AppHost SDK)Describe and run all services, databases and brokers locally with one command, plus a telemetry dashboard. Formerly ".NET Aspire"
Service defaultsMicrosoft.Extensions.ServiceDiscovery, Microsoft.Extensions.Http.Resilience, OpenTelemetryThe same discovery, resilience, health and telemetry setup in every service
YARPYarp.ReverseProxyA .NET reverse proxy / gateway in front of the services
DaprDapr.AspNetCore, Dapr.ClientSidecar building blocks: service invocation, pub/sub, state, secrets
Modular monolithbuilt-in class libraries + internal; WolverineFxKeep one deployable but module boundaries ready to extract later
// AppHost/Program.cs: the whole system in one place
var builder = DistributedApplication.CreateBuilder(args);

var sql = builder.AddSqlServer("sql");
var accountsDb = sql.AddDatabase("accountsdb");     // database per service
var paymentsDb = sql.AddDatabase("paymentsdb");

var accounts = builder.AddProject<Projects.Accounts_Api>("accounts")
    .WithReference(accountsDb).WaitFor(accountsDb);

builder.AddProject<Projects.Payments_Api>("payments")
    .WithReference(paymentsDb)
    .WithReference(accounts);                       // service discovery by name

builder.Build().Run();

Microservices don't remove complexity. They move it 🚚

From: tangled code in one app

To: network calls, retries, sagas, tracing, 40 pipelines and eventual consistency

Worth it when you have many teams, different scaling needs and clear domain boundaries. Otherwise, a modular monolith wins.

#Microservices #SoftwareArchitecture #SystemDesign

11.4 Microservice architecture patterns (map)

The common patterns, grouped by the problem each one solves.

βœ‚οΈ Decomposition

  • By business capability
  • By subdomain (DDD bounded context)
  • Strangler fig (migrate gradually)

πŸ—„ Data management

  • Database per service
  • Saga (distributed transactions)
  • CQRS
  • Event sourcing
  • API composition
  • Transactional outbox

πŸ“‘ Communication

  • API gateway
  • Backend for Frontend (BFF)
  • Service discovery (client- or server-side)
  • Async messaging
  • Service mesh / sidecar

πŸ›‘ Reliability

  • Circuit breaker
  • Bulkhead
  • Retry + backoff, timeout
  • Health check API

πŸ”­ Observability

  • Log aggregation
  • Distributed tracing
  • Application metrics
  • Correlation IDs
  • Audit logging

πŸš€ Deployment & cross-cutting

  • Service per container
  • Serverless deployment
  • Externalised config
  • Anti-corruption layer
  • Ambassador / sidecar

API gateway & BFF

πŸ“± Mobile
πŸ’» Web
🀝 Partner
β†’
Mobile BFF
Web BFF
Partner API
β†’
API Gateway
β†’
Accounts svc + DB
Payments svc + DB
Customers svc + DB

Gateway, BFF, composition, sync vs async and API contracts are explained in 11.5 Microservice API patterns.

  • Database per service keeps services independent. Share data through APIs or events, never through each other's tables.
  • Anti-corruption layer (ACL): a translation layer that stops a legacy model from leaking into your new domain.
How to implement it
PatternLibrary / APINotes
API gateway / BFFYarp.ReverseProxy; Duende.BFF commercial licenceRouting, auth and aggregation per channel. Duende BFF adds secure cookie-based SPA auth (free community edition)
Service discoveryMicrosoft.Extensions.ServiceDiscovery (+ .Yarp)Resolve https+http://accounts from config, Aspire or Kubernetes DNS
ResilienceMicrosoft.Extensions.Http.Resilience (Polly v8)Retry with backoff, timeout, circuit breaker and bulkhead on HttpClient
Health check APIbuilt-in AddHealthChecks / MapHealthChecks; AspNetCore.HealthChecks.*Liveness and readiness endpoints, with community checks for SQL, Redis, Service Bus
ObservabilityOpenTelemetry.Extensions.Hosting + instrumentation; Azure.Monitor.OpenTelemetry.AspNetCoreDistributed traces, metrics and logs with correlation IDs across services
Database per serviceEF Core, one DbContext and schema per serviceShare data via APIs or events, never another service's tables
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
    http.AddStandardResilienceHandler();  // retry, timeout, circuit breaker
    http.AddServiceDiscovery();           // resolve logical service names
});
builder.Services.AddHttpClient<AccountsClient>(c =>
    c.BaseAddress = new("https+http://accounts"));
builder.Services.AddHealthChecks();

var app = builder.Build();
app.MapHealthChecks("/health");

11.5 Microservice API patterns Added

How clients reach many services through one front door, and how services call each other without turning into a distributed monolith.

API composition with a slow service

1. Edge patterns: how clients get in

PatternWhat it doesUse whenWatch out
API gateway (routing + offloading)One entry point. Routes by path and handles TLS, token validation, rate limits and CORSYou expose more than a couple of servicesKeep business logic out. A "smart gateway" becomes the new monolith
Gateway aggregationThe gateway merges several backend calls into one responseSimple, read-only mergesLogic creeps in. Use a BFF for anything screen-specific
Backend for Frontend (BFF)One API per client type (mobile, web, partner), owned by that client's teamClients need very different shapes, or a SPA needs secure cookie authDon't copy domain rules into each BFF. Keep them in the services
API compositionA composer calls several services and joins the results in memoryRead queries that span servicesBig joins, sorting and paging get slow. Use a CQRS read model instead

2. Service-to-service: how services talk

PatternWhat it doesUse whenWatch out
Sync request/response (REST, gRPC)The caller waits for the answerThe caller needs the answer to continue, e.g. "is the balance enough?"Temporal coupling: if B is down, A fails. Always set a timeout
Chained callsA calls B, which calls C, which calls DRarely, and only short chainsLatency and failure multiply. More than 2–3 hops is a design smell
Scatter-gather (branch)Fan out in parallel, then combine the answersQuotes or search across several providersSet a total deadline and return partial results
Async request-reply202 Accepted + a status URL, then poll or webhookWork that takes seconds to minutes: exports, KYC checks, reportsMake the POST idempotent and expire old status records
Async messaging and eventsCommands to a queue, or events on a topicSide effects other teams care about: notify, ledger, analyticsEventual consistency. Needs the outbox, idempotent consumers and clear ordering rules

3. Finding and securing services

PatternWhat it doesUse whenWatch out
Server-side discoveryThe caller uses a stable name; a load balancer or Kubernetes Service picks the instanceKubernetes and most PaaS (the default)One extra hop, but the simplest option
Client-side discoveryThe caller looks up a registry and picks an instance itselfAspire, Consul, or smart client load balancingDiscovery logic lives in every client
Service meshSidecars handle mTLS, retries and traffic splitsMany services, many languages, zero trustReal operational cost. See Sidecar & Service Mesh
Token exchange (RFC 8693)The edge validates the user's token; each service swaps it for a narrower token for the next callZero trust and least privilegeDon't forward one all-powerful token through every service

4. Contracts: changing APIs without breaking anyone

PatternWhat it doesUse whenWatch out
Contract-firstWrite the OpenAPI or .proto file first, then generate servers and clientsMany consumers, teams working in parallelThe spec must be the source of truth and checked in CI
Tolerant readerConsumers ignore unknown fields and read only what they needAlwaysThis is what lets producers add fields safely
Expand and contract (parallel change)Add the new field or endpoint, move consumers over, then remove the old oneAny breaking changeTrack who still calls the old shape before removing it
Versioning (two in production)v1 and v2 run side by side for a fixed periodOnly for real breaking changesAnnounce the end date with the Deprecation and Sunset headers
Consumer-driven contract testsConsumers publish what they rely on; the provider's CI verifies itTo stop breaking consumers you didn't know aboutContracts need an owner and a broker

5. Message and payload patterns

Small decisions inside each API, from the book Patterns for API Design (Zimmermann et al.):

πŸ“„ Pagination

  • Cursor paging for big or changing lists
  • Offset paging only for small, stable lists

🎯 Wish list

  • Let callers pick fields: ?fields=id,balance
  • Smaller payloads for mobile

πŸ“¦ Request bundle

  • Send many items in one call
  • Return a result per item

πŸ” Conditional request

  • ETag + If-None-Match returns 304
  • If-Match stops lost updates

πŸ”— Embed or link

  • Embed small related data
  • Link to large or rarely used data

πŸ›‘ Safe retries and errors

  • Idempotency-Key on POST
  • Errors as Problem Details (RFC 9457)
  • 429 + Retry-After for rate limits

Smells to call out in a design review: chatty APIs (many calls per screen), synchronous chains of 4+ services, two services sharing one database, CRUD-per-table services, business rules in the gateway, breaking changes without a version, and retries on calls that aren't idempotent.

  • Design APIs around business capabilities and tasks, not database tables.
  • Use sync for answers you need now, and async for side effects.
  • Every remote call needs a timeout, retries only if it is idempotent, and a fallback.
  • Pass a deadline and a trace ID (W3C traceparent) through every hop.
  • Adding fields is free. A breaking change costs a version and a migration plan.
  • Prefer fewer, coarser calls across the network. Chatty is the enemy.
How to implement it
PatternLibrary / APINotes
API gatewayYarp.ReverseProxyRoutes and transforms; add auth, rate limiting and output caching with normal ASP.NET Core middleware
BFF / compositionbuilt-in Minimal APIs + typed HttpClient; Duende.BFF commercial licenceFan out in parallel, give each call its own timeout. Duende BFF adds secure cookie auth for SPAs
Service discoveryMicrosoft.Extensions.ServiceDiscoveryhttps+http://cards resolves from config, Aspire or Kubernetes DNS
ResilienceMicrosoft.Extensions.Http.ResilienceAddStandardResilienceHandler: timeout, retry and circuit breaker per client
gRPCGrpc.AspNetCore, Grpc.Net.ClientFactoryFast, typed internal calls with deadlines built in
Contract-firstbuilt-in Microsoft.AspNetCore.OpenApi (AddOpenApi, OpenAPI 3.1 in .NET 10); Refit or Kiota (Microsoft.OpenApi.Kiota tool) for clientsPublish the spec from the service and generate typed clients from it
VersioningAsp.Versioning.HttpURL, header or query versioning, and reports supported versions
Errorsbuilt-in AddProblemDetailsStandard RFC 9457 error bodies across all services
Contract testsPactNetConsumer-driven contracts, verified in the provider's CI
Events and commandsAzure.Messaging.ServiceBus; WolverineFx; Dapr.ClientPub/sub, queues and sidecar-based service invocation
// Dashboard BFF: one call from the app, two services behind it
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(h => h.AddServiceDiscovery());

builder.Services.AddHttpClient<AccountsClient>(c => c.BaseAddress = new("https+http://accounts"))
    .AddStandardResilienceHandler();
builder.Services.AddHttpClient<CardsClient>(c => c.BaseAddress = new("https+http://cards"))
    .AddStandardResilienceHandler(o => o.AttemptTimeout.Timeout = TimeSpan.FromMilliseconds(800));
builder.Services.AddProblemDetails();   // RFC 9457 errors
builder.Services.AddOpenApi();          // contract at /openapi/v1.json

var app = builder.Build();
app.MapOpenApi();

app.MapGet("/dashboard", async (ClaimsPrincipal user,
    AccountsClient accounts, CardsClient cards, CancellationToken ct) =>
{
    var id = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    var balancesTask = accounts.GetAsync(id, ct);       // required
    var cardsTask    = Try(cards.GetAsync(id, ct));     // optional tile, runs in parallel

    var balances = await balancesTask;                  // let a required call fail
    var myCards  = await cardsTask;
    return TypedResults.Ok(new DashboardView(balances, myCards, Degraded: myCards is null));
}).RequireAuthorization();

app.Run();

static async Task<T?> Try<T>(Task<T> call) where T : class
{
    try { return await call; }
    catch (Exception ex) when (ex is HttpRequestException
        or TimeoutRejectedException or BrokenCircuitException)
    { return null; }                                    // degrade, don't fail the page
}

Task.WhenAll on its own fails the whole response when one call fails. Decide per call: required calls may fail the request, optional calls degrade.

Most microservice pain shows up at the API layer, not in the code πŸ”Œ

5 API patterns I check in every design review:

1️⃣ Gateway: one front door. Routing, auth, rate limits. Zero business logic.
2️⃣ BFF: one API per client type. Mobile shouldn't pay for web's payload.
3️⃣ Composition: fan out in parallel, and return partial data when a tile is slow.
4️⃣ Async request-reply: 202 + a status URL for anything slower than a few seconds.
5️⃣ Events: publish what happened, and let others subscribe.

One smell to watch: if a request crosses 4 services synchronously, you've built a distributed monolith.

#Microservices #APIDesign #SoftwareArchitecture #SystemDesign

11.6 Saga pattern Added

A long business transaction split into local transactions. If one step fails, compensating actions undo the earlier steps.

How to implement it
Library / APIPackageUse it for
Durable FunctionsMicrosoft.Azure.Functions.Worker.Extensions.DurableTaskOrchestration sagas as plain C#: try the steps, compensate in catch
TemporalTemporalioDurable workflow sagas with retries and compensation on a Temporal cluster
Wolverine sagasWolverineFxMessage-driven sagas with state stored in Marten or EF Core
MassTransit state machinesMassTransit commercial licence (v9+)Event-driven saga state machines with many persistence options
NServiceBus sagasNServiceBus commercial licenceLong-running processes with timeouts and strong tooling
[Function(nameof(PaymentSaga))]
public static async Task PaymentSaga([OrchestrationTrigger] TaskOrchestrationContext ctx)
{
    var payment = ctx.GetInput<Payment>()!;
    var undo = new Stack<string>();
    try
    {
        await ctx.CallActivityAsync("ReserveFunds", payment); undo.Push("ReleaseFunds");
        await ctx.CallActivityAsync("DebitAccount", payment); undo.Push("CreditBack");
        await ctx.CallActivityAsync("SendPayment", payment);
        await ctx.CallActivityAsync("Notify", payment);
    }
    catch (TaskFailedException)
    {
        while (undo.TryPop(out var step))       // compensate in reverse order
            await ctx.CallActivityAsync(step, payment);
        throw;
    }
}

Compensating activities must be idempotent and should not fail. Give them their own retry policy.

11.7 CQRS & Event Sourcing Added

CQRS: separate the write model (commands) from the read model (queries). Event sourcing: store every change as an event and rebuild state from them.

Command"Transfer $100"
β†’
Write modelvalidates rules
β†’
Event storeDeposited Β· Withdrawn Β· …
β†’project
Read modelsbalance view, statement view, search
←
Query

βž• Benefits

  • Read and write sides scale independently
  • Read models tuned for each screen
  • Full audit trail and time travel (event sourcing)

βž– Costs

  • Eventual consistency between write and read
  • More moving parts
  • Event versioning, snapshots needed for long streams
  • CQRS doesn't require event sourcing, and you shouldn't apply either to simple CRUD domains.
How to implement it
Library / APIPackageUse it for
Plain handlersbuilt-in your own ICommandHandler<T> / IQueryHandler<T,R> + DICQRS needs no library: separate command and query classes is enough
MediatorMediator.SourceGenerator + Mediator.AbstractionsFree, source-generated mediator (martinothamar). Fast, no reflection
MediatRMediatR commercial licence (v13+)The classic mediator. Paid licence, with a free community tier for small companies
WolverineWolverineFxCommand/query handlers plus messaging, outbox and Marten integration
Event storeMarten (PostgreSQL); KurrentDB.Client (KurrentDB, formerly EventStoreDB)Append-only event streams, projections and snapshots
Read modelsEF Core with AsNoTracking(), or DapperDenormalised tables built by projections, tuned per screen
public record Deposited(decimal Amount);
public record Withdrawn(decimal Amount);

public sealed class Account                 // state is rebuilt from events
{
    public Guid Id { get; set; }
    public decimal Balance { get; set; }
    public void Apply(Deposited e) => Balance += e.Amount;
    public void Apply(Withdrawn e) => Balance -= e.Amount;
}

// Write side (Marten): append an event to the account's stream
await using var session = store.LightweightSession();
session.Events.Append(accountId, new Withdrawn(100m));
await session.SaveChangesAsync(ct);

// Read side: fold the stream into current state (or query a projected view)
var account = await session.Events.AggregateStreamAsync<Account>(accountId);

11.8 Strangler Fig pattern Added

Modernise a legacy system bit by bit: put a faΓ§ade in front, route one feature at a time to new services, and retire the old system once it's empty.

  • Pairs well with CDC (to keep data in sync), an anti-corruption layer, and feature flags for routing.
How to implement it
Library / APIPackageUse it for
YARPYarp.ReverseProxyThe façade: route each path to the new service or the legacy app
System.Web adaptersMicrosoft.AspNetCore.SystemWebAdapters (+ .CoreServices / .FrameworkServices)Migrate ASP.NET Framework to ASP.NET Core route by route, sharing session and auth between both apps
Feature flagsMicrosoft.FeatureManagement.AspNetCoreSwitch a route to the new implementation gradually, and back again if needed
Anti-corruption layerbuilt-in an adapter class + typed HttpClientTranslate the legacy model into your new domain model at the boundary
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpForwarder();
var app = builder.Build();

// Features already migrated are handled by the new app...
app.MapGet("/api/accounts/{id}", GetAccount);

// ...everything else still goes to the legacy system (lowest-priority route)
app.MapForwarder("/{**catch-all}", "https://legacy.bank.internal")
   .Add(b => ((RouteEndpointBuilder)b).Order = int.MaxValue);

app.Run();

11.9 Sidecar & Service Mesh Added

A sidecar is a helper container that runs next to your service. A service mesh puts a sidecar proxy next to every service to handle networking concerns.

Service A
⇄
Envoy proxy
═ mTLS ═
Envoy proxy
⇄
Service B

The proxies are managed by a control plane (Istio, Linkerd). They give you mTLS, retries, timeouts, traffic splitting (canary), and metrics and traces without changing service code.

How to implement it
Library / APIPackageUse it for
Service meshNo package: Istio, Linkerd or the Container Apps / AKS add-onsmTLS, retries and traffic splitting happen in the proxy. Your .NET code stays unchanged
DaprDapr.AspNetCore, Dapr.ClientA sidecar that gives you service invocation, pub/sub, state and secrets through one API
gRPCGrpc.AspNetCore, Grpc.Net.ClientEfficient HTTP/2 calls between services. Meshes understand and balance gRPC
TelemetryOpenTelemetry.Extensions.HostingPropagate trace context so mesh traces and app traces join up
builder.Services.AddDaprClient();
var app = builder.Build();
app.UseCloudEvents();
app.MapSubscribeHandler();   // tells the sidecar which topics we subscribe to

// Publish: the sidecar talks to the real broker (Service Bus, Kafka, Redis...)
app.MapPost("/payments", async (Payment p, DaprClient dapr) =>
{
    await dapr.PublishEventAsync("pubsub", "payments", p);
    return Results.Accepted();
});

// Subscribe: the sidecar delivers events here
app.MapPost("/on-payment", (Payment p) => Results.Ok())
   .WithTopic("pubsub", "payments");

If the mesh already retries, don't stack aggressive app-level retries on top. You'll multiply load during an outage.

11.10 Domain-Driven Design essentials Added

Model software around the business domain, and draw service boundaries where the business language changes.

Bounded context

A boundary inside which a model and its terms have one meaning. "Account" in Lending β‰  "Account" in Login.

Ubiquitous language

Business and tech use the same words in conversation and in code.

Subdomains

Core (your edge, so build it), supporting, generic (buy it).

Aggregate

A cluster of objects changed as one unit with one root (e.g. an Order and its OrderLines).

Entity vs value object

An entity has an identity (Customer #42). A value object is defined by its values (Money, Address).

Domain events & context map

Events link contexts. The context map shows relationships (ACL, shared kernel, customer/supplier).

How to implement it
ConceptLibrary / APINotes
Value objectbuilt-in C# record / readonly record structValue equality and immutability for free
Persist value objectsEF Core complex types (ComplexProperty) or owned types (OwnsOne)Store Money or Address as columns of the owning table, without its own identity
Aggregatebuilt-in private setters + methods that enforce rulesChange state only through the root. Load and save one aggregate per transaction
Domain eventsbuilt-in a list on the aggregate, dispatched in a SaveChanges interceptorOr hand them to Wolverine / Mediator for in-process handlers
SpecificationsArdalis.Specification (+ .EntityFrameworkCore) (optional)Reusable, named query rules for repositories
public sealed record Money(decimal Amount, string Currency)
{
    public Money Add(Money other) => other.Currency == Currency
        ? this with { Amount = Amount + other.Amount }
        : throw new InvalidOperationException("Currency mismatch");
}

public sealed class Account                       // aggregate root
{
    public Guid Id { get; private set; }
    public Money Balance { get; private set; } = new(0, "AUD");
    public List<object> DomainEvents { get; } = [];

    public void Deposit(Money amount)
    {
        if (amount.Amount <= 0) throw new ArgumentException("Must be positive");
        Balance = Balance.Add(amount);
        DomainEvents.Add(new MoneyDeposited(Id, amount));
    }
}

// EF Core: Money becomes Balance_Amount / Balance_Currency columns
modelBuilder.Entity<Account>().ComplexProperty(a => a.Balance);
modelBuilder.Entity<Account>().Ignore(a => a.DomainEvents);

11.11 Event storming Added

A fast sticky-note workshop (created by Alberto Brandolini) where business and tech people map a domain as a timeline of events, then find the boundaries in it.

The sticky-note legend

🟧 Domain event

Something that happened, in the past tense: "Payment settled". The backbone of the wall.

🟦 Command

An intent or decision that causes an event: "Submit payment".

🟨 Actor (small yellow)

The person or role who issues a command: "Customer", "Payments ops".

πŸŸͺ Policy (lilac)

A reaction rule: "Whenever fraud score is high, hold the payment".

πŸ”— External system (pink)

Something outside your control: a payment scheme, a credit bureau, a fraud engine.

🟩 Read model

The information someone looks at to make a decision: "Available balance".

🟨 Aggregate (large yellow)

The thing that accepts commands and enforces the rules: "Payment", "Account".

πŸŸ₯ Hotspot

A question, conflict or risk, often on a magenta or red note.

Colours vary between facilitators and tools. Agree a legend at the start and keep it visible on the wall.

Big pictureProcess modellingSoftware design
GoalExplore a whole business line. Find hotspots and boundariesModel one business process end to endDesign aggregates and services you'll build
WhoMany people: domain experts, ops, risk, techA smaller group for one processThe dev team plus domain experts
Notes usedEvents, hotspots, actors, external systems+ commands, policies, read models+ aggregates
OutputA shared timeline, pivotal events, candidate bounded contextsAn agreed flow with rules and unknownsAggregates, commands and events ready to code

How it feeds DDD (11.10)

Events on a timeline
β†’
Pivotal events & swimlanes
β†’
Language & ownership changes
β†’
Bounded contexts→ context map → services or modules
  • Events are past tense and in business language. "PaymentRow inserted" is not a domain event.
  • Invite people with the answers (domain experts) and people with the questions (engineers). Give them unlimited wall space, physical or a virtual board.
  • The output isn't a document. It's shared understanding and candidate bounded contexts.
How to implement it

Event storming is a workshop, so there's no library. This is how the stickies usually map to .NET code afterwards:

Sticky.NET building blockNotes
Command (blue)built-in a record + a handler class; or a WolverineFx handlerOne handler per command, named in the business language
Aggregate (yellow)built-in a class with private setters and rule-checking methodsSee the Account example in 11.10
Domain event (orange)built-in a past-tense record raised by the aggregatePublish to other contexts through an outbox (10.6)
Policy (lilac)An event handler that sends the next command (Wolverine, Service Bus subscriber)Long-running policies become sagas (11.6)

The fastest way to find your service boundaries? Sticky notes 🟧

🟧 Orange: what happened ("Payment settled")

🟦 Blue: what triggered it ("Submit payment")

πŸŸͺ Lilac: the rule ("Whenever the fraud score is high, hold it")

🟩 Green: what people look at to decide

πŸŸ₯ Hotspots: where everyone disagrees. That's the gold.

Where the language changes, draw a line. That's your bounded context.

#EventStorming #DDD #SoftwareArchitecture #Microservices

11.12 Serverless architecture

You write the code or configuration. The cloud runs, scales (even to zero) and bills it per execution. Servers still exist, you just don't manage them.

TriggerHTTP Β· queue Β· blob Β· timer Β· event
β†’
FunctionAzure Functions / AWS Lambda
β†’
Managed servicesCosmos DB Β· Service Bus Β· Blob Β· Event Grid

FaaS

Functions as a Service: Azure Functions, AWS Lambda, Google Cloud Functions.

BaaS / managed

Serverless DBs, queues, auth, storage (Cosmos DB serverless, DynamoDB, Entra ID).

Serverless containers & workflows

Azure Container Apps, Fargate, Cloud Run Β· Logic Apps, Durable Functions, Step Functions.

βž• Pros

  • No infrastructure to manage or patch
  • Automatic, fine-grained scaling
  • Pay per use. Cheap for spiky or low traffic
  • Fast to build event-driven glue

βž– Cons

  • Cold starts (latency on the first call)
  • Execution time and memory limits
  • Vendor lock-in
  • Harder local debugging and observability
  • Can overwhelm downstream DBs when it scales out
  • Costly at constant high load

Common serverless patterns

  • Cut cold starts with premium or provisioned plans, small packages and keeping functions warm.
  • Functions should be stateless, idempotent and short. Use Durable Functions or Logic Apps for long workflows.
  • Cap concurrency to protect downstream systems.
How to implement it
Library / APIPackageUse it for
Azure FunctionsMicrosoft.Azure.Functions.Worker + .Extensions.ServiceBus / .Timer / .Http.AspNetCoreIsolated worker model. Supports .NET 10 (GA). The in-process model ends on 10 Nov 2026 and never got .NET 10
Durable FunctionsMicrosoft.Azure.Functions.Worker.Extensions.DurableTaskLong-running workflows, fan-out/fan-in, timers
AWS LambdaAmazon.Lambda.AspNetCoreServer.HostingRun a minimal API on Lambda with one line: AddAWSLambdaHosting(...)
Serverless containersAzure Container Apps (no SDK needed)Run any ASP.NET Core container with scale-to-zero and KEDA scaling on queue length
public sealed class PaymentFunctions(ILogger<PaymentFunctions> log)
{
    // Queue-based load levelling: Service Bus trigger
    [Function("ProcessPayment")]
    public async Task Process(
        [ServiceBusTrigger("payments", Connection = "ServiceBus")] Payment payment)
    {
        log.LogInformation("Processing {PaymentId}", payment.Id);
        await Task.CompletedTask; // do the work; keep it short and idempotent
    }

    // Scheduled job: replaces a cron VM (02:00 every day)
    [Function("NightlyReconciliation")]
    public Task Reconcile([TimerTrigger("0 0 2 * * *")] TimerInfo timer) =>
        Task.CompletedTask;
}

.NET 10 can't run on the Linux Consumption plan: use Flex Consumption. Cap concurrency in host.json (e.g. Service Bus maxConcurrentCalls) to protect downstream databases.

"Serverless" doesn't mean no servers. It means no server worries ☁️

βœ… Scales to zero βœ… Pay per execution βœ… No patching

⚠️ Cold starts ⚠️ Time limits ⚠️ Lock-in ⚠️ Can DDoS your own database when it scales out

Perfect for event-driven glue and spiky workloads. Think twice for steady, heavy traffic.

#Serverless #Azure #CloudArchitecture

11.13 The 12-Factor App Added

Twelve principles for building cloud-native apps that are portable and scalable.

1. Codebase: one repo, many deploys7. Port binding: self-contained service
2. Dependencies: declared explicitly8. Concurrency: scale out via processes
3. Config: in the environment, not in code9. Disposability: fast start, graceful shutdown
4. Backing services: attached resources10. Dev/prod parity
5. Build, release, run: kept strictly separate11. Logs: written as event streams
6. Processes: stateless12. Admin processes: run as one-off tasks
How to implement it
FactorLibrary / APINotes
2. Dependenciesbuilt-in NuGet PackageReference + Central Package Management, lock filesEvery dependency declared and pinned
3. Configbuilt-in IConfiguration + options pattern; Microsoft.Extensions.Configuration.AzureAppConfiguration; Azure.Extensions.AspNetCore.Configuration.SecretsEnvironment variables (Payments__ApiUrl), App Configuration and Key Vault, validated at startup
5. Build, release, runbuilt-in dotnet publish -t:PublishContainerThe SDK builds an OCI container image with no Dockerfile
9. Disposabilitybuilt-in IHostApplicationLifetime, HostOptions.ShutdownTimeoutStop taking work on SIGTERM, finish in-flight requests, then exit
11. Logsbuilt-in ILogger console provider (JSON formatter); OpenTelemetryWrite to stdout. The platform collects and ships the stream
12. Admin processesbuilt-in EF Core migration bundles (dotnet ef migrations bundle)Run migrations as a one-off job, not on app startup
var builder = WebApplication.CreateBuilder(args);   // env vars are read by default

builder.Configuration.AddAzureAppConfiguration(o =>
    o.Connect(new Uri(builder.Configuration["AppConfig:Endpoint"]!),
              new DefaultAzureCredential()));

builder.Services.AddOptions<PaymentOptions>()
    .BindConfiguration("Payments")
    .ValidateDataAnnotations()
    .ValidateOnStart();                              // fail fast on bad config

builder.Services.Configure<HostOptions>(o =>
    o.ShutdownTimeout = TimeSpan.FromSeconds(30));   // graceful shutdown window
builder.Logging.AddJsonConsole();                    // logs as an event stream

11.14 Multi-tenancy patterns Added

One system serves many customers (tenants). The key design question is how much they share: everything (pool), nothing (silo), or a mix (bridge).

Where tenant data lives

Database per tenantSchema per tenantShared tables + TenantId
IsolationStrongestMediumCode-enforced
Cost per tenantHighest (elastic pools help)MediumLowest
Number of tenantsTens to thousandsHundredsUp to millions
Migrations & backupsRun per database: must be automatedRun per schemaOnce for everyone
Restore one tenant / data residencyEasyPossibleHard
Typical fitLarge, regulated customers that demand isolationMid-sized B2B SaaSSelf-service SaaS, free tiers

πŸ” Tenant isolation

Data: filters, row-level security, per-tenant encryption keys. Compute: dedicated pools or nodes for big tenants. Watch the noisy neighbour.

🧭 Tenant context

Resolve the tenant once at the edge (host name, token claim) and carry it everywhere: DB filter, logs, traces, message headers, cache keys.

🚦 Limits & quotas

Rate limits partitioned by tenant, and quotas per plan (requests, storage, users).

πŸ—οΈ Tenant-aware caching

Prefix every cache key with the tenant (t:42:account:7). One missing prefix leaks data across tenants.

πŸ€– Onboarding automation

One pipeline creates the database or schema, config, identity and DNS for a new tenant, and removes them on exit.

πŸ’° Cost per tenant

Tag resources and meter usage per tenant, so you know which customers and plans are profitable.

  • Most platforms end up tiered: a pool for the long tail of small tenants, silos for the big or regulated ones.
  • The tenant ID comes from the authenticated token or host name, never from the request body.
  • Write tests that try to read tenant B's data as tenant A, and run them in CI.
  • Put the tenant ID in logs and traces. Only use it as a metric label if the number of tenants is small (cardinality).
How to implement it
NeedLibrary / APINotes
Resolve the tenantFinbuckle.MultiTenant.AspNetCore (Apache-2.0)Find the tenant from host, route, header or claim, with per-tenant options and auth. v10 releases line up with .NET 10
Shared tablesbuilt-in EF Core global query filters (HasQueryFilter); EF Core 10 named filtersAdds WHERE TenantId = @t to every query. Named filters let you switch off soft delete without switching off the tenant filter. Finbuckle.MultiTenant.EntityFrameworkCore can set this up for you
Database per tenantbuilt-in AddDbContextFactory / IDbContextFactory<T>Pick the connection string for the current tenant from a tenant catalog or Key Vault
Per-tenant limitsbuilt-in Microsoft.AspNetCore.RateLimiting: PartitionedRateLimiterOne token bucket per tenant. Different plans get different limits
Tenant-aware cacheMicrosoft.Extensions.Caching.Hybrid (HybridCache)Prefix keys with the tenant ID and tag entries tenant:42, so RemoveByTagAsync clears one tenant
// EF Core 10: named filters, so soft delete can be ignored while the tenant filter stays
public sealed class BankDbContext(DbContextOptions<BankDbContext> options, ITenantContext tenant)
    : DbContext(options)
{
    private string TenantId => tenant.TenantId;
    public DbSet<Account> Accounts => Set<Account>();

    protected override void OnModelCreating(ModelBuilder b) =>
        b.Entity<Account>()
         .HasQueryFilter("Tenant", a => a.TenantId == TenantId)
         .HasQueryFilter("SoftDelete", a => !a.IsDeleted);
}
// db.Accounts.IgnoreQueryFilters(["SoftDelete"]) still only sees this tenant's rows

// One token bucket per tenant: a noisy tenant can't starve the others
builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetTokenBucketLimiter(
            ctx.User.FindFirst("tenant_id")?.Value ?? "anonymous",
            _ => new TokenBucketRateLimiterOptions
            {
                TokenLimit = 200, TokensPerPeriod = 100,
                ReplenishmentPeriod = TimeSpan.FromSeconds(1)
            }));
});

IgnoreQueryFilters() with no names turns off every filter, including the tenant one. Always pass the names you mean.

Multi-tenant SaaS: how much do your customers share? 🏒

🏠 Silo: a stack per tenant. Safest, and the most expensive.

🏬 Pool: everything shared, with a TenantId on every row. Cheapest, but isolation lives in your code.

πŸŒ‰ Bridge: shared app, a database per tenant. The common middle ground.

πŸ“’ Noisy neighbour? Per-tenant rate limits and quotas.

πŸ”‘ The tenant ID comes from the token, never the request body.

Most real platforms end up tiered: a pool for the long tail, silos for the big, regulated customers.

#SaaS #MultiTenancy #SystemDesign #CloudArchitecture

⚑ Quick-fire Q&A

How do you keep data consistent across microservices?

Sagas with compensating actions, the transactional outbox for reliable events, idempotent consumers, and accepting eventual consistency where the business allows it.

When would you NOT use microservices?

Small team, unclear domain boundaries, early-stage product, or no mature DevOps and observability. Use a modular monolith instead.

Serverless or containers?

Serverless for event-driven, spiky, short tasks. Containers for long-running, steady, latency-sensitive workloads or when you need full runtime control.

Hexagonal vs Clean vs Onion: what's the difference?

Very little. All three put the domain at the centre and make dependencies point inwards, with infrastructure at the edge. Hexagonal talks about ports and adapters; Onion and Clean draw concentric layers, and Clean names them (entities, use cases, interface adapters, frameworks). For simple CRUD, a vertical slice structure is often a better fit.

What do you get out of an event storming workshop?

A shared timeline of domain events, the commands, actors, policies and external systems around them, a list of hotspots (open questions and risks), and candidate bounded contexts where the language and ownership change. That feeds the DDD context map and the service boundaries.

How do you isolate tenants in a shared database?

Resolve the tenant from the token or host, never from the request body. Add a TenantId to every row and enforce it with an EF Core global query filter (named, so it's never switched off by accident) and ideally database row-level security as a second layer. Prefix cache keys with the tenant, rate limit per tenant, and test for cross-tenant leaks in CI. Move large or regulated tenants to their own database.

API gateway or BFF: which one?

Usually both. The gateway is the shared front door: routing, TLS, token validation and rate limits, with no business logic. A BFF sits behind it, one per client type, and shapes data for that client by calling several services. If one generic API keeps growing "mobile-only" or "web-only" fields, it is time for a BFF.

How do you query data owned by several microservices?

For simple reads, use API composition: call the owning services in parallel and merge the results, with a timeout per call and a partial response when an optional part is slow. For large joins, search, sorting or paging, build a CQRS read model fed by events. Never read another service's tables directly.

← 10 Β· Integration & Messaging