Post

Hands-on DDD and Event Sourcing [5/6]: Wrapping up backend infrastructure

Hands-on DDD and Event Sourcing [5/6]: Wrapping up backend infrastructure


In the previous post, I talked about persisting domain events into the event store, projecting and reading them, all using Marten. Now it’s time to wrap up everything I’ve covered so far, add any missing infrastructure, and finish the backend.


Docker containers


I couldn’t wrap up this series without highlighting the importance of providing an out-of-the-box developer experience. All you need to run the project is to have Docker installed. No extra setup, no dependency hell.

If you’re new to Docker, it’s the most widely used open-source platform for building, deploying, and managing containerized applications.

In this project, the Docker Compose setup is split into three YAML files:

  1. docker-compose.yml for building and configuring each service, including the Angular SPA under the frontend profile. It also pulls in the infrastructure file through include.
  2. docker-compose.infra.yml for the third-party infrastructure images, like PostgreSQL, pgAdmin, Kafka, Kafka UI, Mailpit and the Aspire dashboard.
  3. docker-compose.override.yml for developer tooling, such as regenerating the Kiota API clients, under the tools profile.

Compose loads the override file automatically, so spinning up the full environment is as simple as running:

1
2
# Backend only: starts all microservices, databases, Kafka, and infrastructure:
 $ docker compose up
1
2
# Backend + Frontend: also builds and serves the Angular SPA at http://localhost:4200:
 $ docker compose --profile frontend up


Ocelot - API Gateway


Given this microservices architecture, we have multiple APIs, typically one per service. From the frontend SPA (Single Page Application) perspective, calling each service individually is not only impractical but also undesirable. Each API lives on a separate port and inside a different Docker container.

We don’t want the SPA to know anything about internal infrastructure, such as which microservice handles what or where each microservice runs. Instead, we use an API Gateway to abstract this complexity.

For this, I chose Ocelot, a lightweight API Gateway for .NET. It allows us to centralize all routing behind a single entry point: localhost:5000. That’s the only address the SPA needs to be aware of.

The routes are defined in one file per service, where I used the Docker service name as the downstream host. Here’s the current structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
├── Crosscutting
│   └── EcommerceDDD.ApiGateway
│        └── Ocelot
│             ├── ocelot.accounts.json
│             ├── ocelot.customerManagement.json
│             ├── ocelot.global.json
│             ├── ocelot.inventoryManagement.json
│             ├── ocelot.orderProcessing.json
│             ├── ocelot.paymentProcessing.json
│             ├── ocelot.productCatalog.json
│             ├── ocelot.quoteManagement.json
│             ├── ocelot.shipmentProcessing.json
│             └── ocelot.signalr.json
ocelot.customerManagement.json

⚠️ Ocelot reads a single ocelot.json, a bundle of all these individual configuration files, merged together when the gateway starts. That’s why it isn’t committed to the repository. Organizing routes through smaller files is a good way to keep it all atomic and organized. The automatic merge is done in the Program.cs like this:

1
2
3
4
5
6
7
8
9
10
builder.Configuration
	.SetBasePath(Directory.GetCurrentDirectory())
	.AddOcelot(
		folder: "Ocelot",
		env: builder.Environment,
		mergeTo: MergeOcelotJson.ToFile,
		primaryConfigFile: "Ocelot/ocelot.json",
		reloadOnChange: true
	)
	.AddEnvironmentVariables();

Refer to Ocelot’s official documentation for more options and advanced configurations.


EcommerceDDD.IdentityServer


When registering a new customer, the system requires an email and password. These are authentication concerns, not part of the customer’s domain model, and are handled in a separate project: Crosscutting/EcommerceDDD.IdentityServer.

CustomerManagement registers the customer first, then asks IdentityServer to create the user under the same id. If that second step fails, repeating the registration picks up the existing customer and finishes it.

💡New accounts must confirm their e-mail before signing in, but no real e-mail is actually sent, since both confirmation and password-reset messages land in a Mailpit inbox at http://localhost:8025.

ASP.NET Core Identity

ASP.NET Core Identity: It is an API that supports user interface (UI) login functionality. Manages users, passwords, profile data, roles, claims, tokens, email confirmation, and more.

I configured it using the same PostgreSQL instance used elsewhere in the project, through IdentityApplicationDbContext.

Duende IdentityServer

The most flexible and standards-compliant OpenID Connect and OAuth framework for ASP.NET Core.

Duende IdentityServer is well-suited for authentication and can be easily integrated with ASP.NET Core Identity. Check out the Program.cs below and notice how I made it support the application using its .AddAspNetIdentity extension method:

IdentityServer adds two more contexts for its own persistence, ConfigurationDbContext and PersistedGrantDbContext. Each of the three contexts has its migrations in the Migrations folder, and they’re all applied when the project starts, which also seeds the Customer role, plus the clients, resources and scopes from IdentityConfiguration.

Signing key rotation

I originally set it up by chaining the AddDeveloperSigningCredential() extension method, which generates an RSA key and writes it to a local tempkey.jwk file. Inside a container, that file lives in the container’s writable layer, so recreating the container (a rebuild, or docker compose down/up) discards the key and invalidates every token already issued. That would break existing UI (frontend SPA) sessions that were running at that moment.

To fix that, I switched to Duende’s automatic key management (opt.KeyManagement.Enabled = true), which rotates the signing key on a schedule, announces the next key at the JWKS (JSON Web Key Set) endpoint ahead of time, and keeps the retired key published for a retention period, so tokens already issued keep validating while the new key takes over. Keys are stored in the Keys table of the operational store.

Duende encrypts those keys using ASP.NET Data Protection. If the Data Protection keys themselves are ephemeral, the service can’t decrypt the signing keys it just persisted, defeating the whole point. That’s why AddDataProtection().PersistKeysToDbContext<IdentityApplicationDbContext>() was added alongside it, storing those keys in the same database.

⚠️ Important: KeyManagement.Enabled is a licensed Duende feature. Development, testing and personal projects like this one can run without a license (IdentityServer only logs a warning), but check which edition covers it before using it in production.

Issuing tokens

Once everything is running, the ecommerceddd-identityserver container is available at http://localhost:5001. It exposes an AccountsController used to request tokens, confirm e-mails and reset passwords. Creating users is restricted to machine-to-machine calls from CustomerManagement.

Through the gateway, a login request looks like this:

The controller relies on the IIdentityManager service, which in turn uses an ITokenRequester service that wraps the logic of requesting user tokens and application tokens. It simplifies both authentication and service-to-service (or machine-to-machine/M2M) communication.

Also, ITokenRequester relies on TokenIssuerSettings, a configuration record matching the section in appsettings.json of each microservice (the user client’s settings live only in IdentityServer’s own), and from there, it can gather important information for issuing tokens:

User Token

1
2
3
4
5
6
"TokenIssuerSettings": {
  "Authority": "http://ecommerceddd-identityserver",
  "ClientId": "ecommerceddd.user_client",
  "ClientSecret": "secret234554^&%&^%&^f2%%%",
  "Scope": "openid email read write delete"
}

Application Token

1
2
3
4
5
6
"TokenIssuerSettings": {
  "Authority": "http://ecommerceddd-identityserver",
  "ClientId": "ecommerceddd.application_client",
  "ClientSecret": "secret33587^&%&^%&^f3%%%",
  "Scope": "ecommerceddd-api.scope read write delete"
}

User tokens are generated during the authentication process for a specific user. They represent the user’s identity and contain information such as the user ID, claims, and other data. Application tokens, by contrast, authenticate the application itself rather than a specific user. Since a machine client can silently re-request a token through the client_credentials flow, it can afford a shorter lifespan without hurting the user experience, narrowing the window a leaked token stays useful.

These lifespans aren’t framework defaults but a project decision, set per client via AccessTokenLifetime in IdentityConfiguration.cs: 4 hours for ecommerceddd.user_client, 1 hour for ecommerceddd.application_client.

Scopes, Roles, and Policies

The scopes defined in the token settings are more than just metadata. They directly control what operations the token bearer is authorized to perform. Each API endpoint is protected by [Authorize] attributes that enforce access rules based on roles and policies.

For example:

1
	[Authorize(Roles = Roles.Customer, Policy = Policies.CanRead)]

This ensures that only authenticated users with the Customer role and CanRead policy can access the endpoint. I also defined CanWrite and CanDelete, and applied them where it makes sense.

For machine-to-machine communication, application tokens are restricted similarly:

1
	[Authorize(Roles = Roles.M2MAccess)]

By combining scopes, roles, and policies, you can create a fine-grained security model that controls access both at the user level and the system level. These policies (each simply requiring the matching scope claim, see AuthPolicyBuilder) are registered via ASP.NET Core’s AddAuthorization in the Program.cs of each microservice.

⚠️ Important: The ClientSecret values above are hardcoded for simplicity. This is a demonstration project. In a real application, secrets should never be stored in appsettings.json. Use environment variables, .NET Secret Manager, or a dedicated secrets management service such as Azure Key Vault instead.


Kafka topics + Wolverine


Apache Kafka is an open-source distributed event streaming platform used by thousands of companies for high-performance data pipelines, streaming analytics, data integration, and mission-critical applications.

One last but essential aspect of the infrastructure is allowing different bounded contexts to communicate using a message broker. I mentioned integration events in earlier posts. They’re marked with the IIntegrationEvent interface:

I’m using Kafka as a message broker here, but there are other good options, such as RabbitMQ, Azure Service Bus and others.

The idea is simple. PaymentProcessing and ShipmentProcessing produce integration events, and OrderProcessing is the only one consuming them, since the saga that orchestrates the order lives there. Both sides are wired in the Program.cs of each microservice through Wolverine’s Kafka transport. The producing side declares which message goes to which topic, and the consuming side declares which topics it listens to.

PaymentProcessing
1
2
3
4
5
options.UseKafka(builder.Configuration["Kafka:ConnectionString"]!)
    .AutoProvision();

options.PublishMessage<PaymentFinalized>().ToKafkaTopic("payments").UseDurableOutbox();
options.PublishMessage<CustomerReachedStoreCreditLimit>().ToKafkaTopic("payments").UseDurableOutbox();
ShipmentProcessing
1
2
3
4
5
options.UseKafka(builder.Configuration["Kafka:ConnectionString"]!)
    .AutoProvision();

options.PublishMessage<ShipmentFinalized>().ToKafkaTopic("shipments").UseDurableOutbox();
options.PublishMessage<ShipmentNotDelivered>().ToKafkaTopic("shipments").UseDurableOutbox();
OrderProcessing
1
2
3
4
5
options.UseKafka(builder.Configuration["Kafka:ConnectionString"]!)
    .AutoProvision();

options.ListenToKafkaTopic("payments").UseDurableInbox();
options.ListenToKafkaTopic("shipments").UseDurableInbox();

After starting the application, kafka-ui at localhost:8080 lets you browse these topics.

The durable inbox and outbox

The outbox isn’t exclusive to integration events. Every message a handler passes to AppendEventsAndCommitAsync is staged in the same transaction as the aggregate’s events (the Outbox pattern, covered in Part 4), and only dispatched once that commit succeeds. Where it goes from there depends on its route.

For integration events, the route is Kafka. The UseDurableOutbox() on the publishing routes of PaymentProcessing and ShipmentProcessing keeps the message in the outbox table until Kafka acknowledges it, and if the service crashes before that, its durability agent picks the message up and relays it.

On the other end, the UseDurableInbox() on both listeners means an integration event arriving from payments or shipments is recorded before the handler runs, and is only marked as handled once that handler completes. If the process dies mid-flow, the message gets picked back up instead of vanishing along with the consumer. Since each message is recorded by its envelope id, a redelivery arriving shortly after (Wolverine keeps handled envelopes for a few minutes by default) is recognized as a duplicate and refused. That covers broker redeliveries, not every possible duplicate.

For messages handled inside the same service, such as the domain events driving the saga, the route is a local queue. UseDurableLocalQueues() gives them the same guarantees, persisting each message so a crash can’t strand an order between two steps.

The inbox and outbox tables are created alongside Marten’s schema by a single IntegrateWithWolverine() call in MartenConfigExtension.

On top of that, Wolverine’s error policies retry technical failures (transient ones immediately, then on a schedule, anything else on a schedule), while a broken business rule or a rejected request skips retries. A message that still fails lands in a dead-letter table instead of vanishing, where it can be inspected and replayed (OrderProcessing exposes this through Wolverine’s MapDeadLettersEndpoints).

💡 The caveat is that a handler may run more than once. A retry runs it again even when part of its work already went through, so command handlers must be safe to re-execute.


Final thoughts


This post wrapped up the backend by stitching together the infrastructure that makes the architecture actually run. Docker eliminates setup friction, Ocelot gives the frontend a clean and unified entry point, IdentityServer handles authentication and machine-to-machine authorization, Kafka decouples bounded contexts through integration events, and Wolverine’s transactional inbox and outbox ensure messages are delivered reliably even under failure.

None of these pieces are free. Each one adds operational complexity, and microservices demand that you embrace it deliberately. The payoff (independent deployability, bounded failure domains, and per-service scalability) is real, but only when the domain is complex enough to justify the cost. For smaller systems, a well-structured monolith will serve you better.

With the backend fully assembled, we’re ready for the next and final post, where I’ll cover the Angular SPA that brings all of this to life. See you there!


Check the project on GitHub





This post is licensed under CC BY 4.0 by the author.