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, I used Docker Compose to orchestrate containers for each microservice. I also used public Docker images for things like PostgreSQL, Kafka and related services.

Once everything is defined in the docker-compose.yml file, spinning up the full environment is as simple as running:

1
2
# Backend only: starts all microservices, databases, Kafka, and infrastructure:
 $ docker compose up

or

1
2
# Backend + Frontend: also builds and serves the Angular SPA at http://localhost:4200:
 $ docker compose --profile frontend up


Ocelot - API Gateway


Given our microservices architecture, we have multiple APIs, typically one per service. From the frontend (SPA) 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 ocelot.json, 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
14
├── Crosscutting
│   └── EcommerceDDD.ApiGateway
│        └── Ocelot
│             ├── ocelot.json
│             ├── 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

⚠️ The ocelot.json is a bundle of all these individual configuration files, merged together. 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
11
builder.Configuration
	.SetBasePath(Directory.GetCurrentDirectory())
	.AddJsonFile("Ocelot/ocelot.json", optional: false, reloadOnChange: true)
	.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.

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. Migrations are generated using IdentityApplicationDbContext:

1
dotnet ef migrations add InitialIdentityMigration -c IdentityApplicationDbContext

You can find these migrations in the Migrations folder.

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:

Two more migrations were added to complete the persistence setup required by IdentityServer:

1
2
dotnet ef migrations add InitialConfigurationMigration -c ConfigurationDbContext -o Migrations/IdentityServer/ConfigurationDb
dotnet ef migrations add InitialPersistedGranMigration -c PersistedGrantDbContext -o Migrations/IdentityServer/PersistedGrantDb

With both migrations applied when the project runs, we should have this database structure:

Signing key rotation

I originally setup it with chaining the AddDeveloperSigningCredential() extension method to generate ephemeral RSA key at every startup, but restarting the container invalidates every token already issued, since the key that signed them no longer exists. 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 and publishes both the current and previous key at the JWKS endpoint during a propagation window, so tokens in flight 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, backed by an extra migration:

1
dotnet ef migrations add AddDataProtectionKeys -c IdentityApplicationDbContext

⚠️ Important: While I have this configured for demonstration, KeyManagement.Enabled is part of Duende’s paid tiers. Make sure you confirmed the licensing for your own environment before relying on it beyond development or non-commercial projects, like this.

Issuing tokens

Once everything is running, the ecommerceddd-identityserver container is available at http://localhost:5001. It exposes an AccountsController used to create users and request tokens.

The controller uses an ITokenRequester service that wraps the logic of requesting user tokens and application tokens. It simplifies both authentication and service-to-service communication.

ITokenRequester relies on TokenIssuerSettings.cs, a configuration record matching the section in appsettings.json of each microservice (the scope list varies per service, according to what each one actually needs), 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 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 are typically defined using ASP.NET Core’s AddAuthorization setup 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. Some microservices produce integration events, while others consume them. 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.

1
2
3
4
5
6
7
8
9
10
11
// Shared by every service that talks to the broker
options.UseKafka(builder.Configuration["Kafka:ConnectionString"]!)
    .AutoProvision();

// PaymentProcessing / ShipmentProcessing, publishing side
options.PublishMessage<PaymentFinalized>().ToKafkaTopic("payments").UseDurableOutbox();
options.PublishMessage<ShipmentFinalized>().ToKafkaTopic("shipments").UseDurableOutbox();

// OrderProcessing, consuming side
options.ListenToKafkaTopic("payments").UseDurableInbox();
options.ListenToKafkaTopic("shipments").UseDurableInbox();

The durable inbox and at-least-once delivery

💡 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 is still recorded and gets picked back up instead of vanishing along with the consumer. That guarantee is what covers the cross-service hops.

The caveat is that the same message may be delivered more than once, so the retry loop has to be safe. That means guarding command handlers against duplicate execution. RecordPaymentHandler, for example, checks whether the order is already Paid and in that case simply re-publishes OrderPaid to push the flow forward instead of recording the payment again or raising an exception.

The inbox and outbox tables these rely on are created alongside Marten’s schema by a single IntegrateWithWolverine() call in MartenConfigExtension. On the publishing side, an outgoing message is staged transactionally with the aggregate’s events (the Outbox Pattern, covered in Part 4), and once that commit succeeds Wolverine’s durability agent relays it to Kafka.

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

What makes this stack satisfying is how the pieces compose: the outbox guarantees events reach Kafka even if a service crashes mid-transaction, and the durable inbox ensures a message that failed mid-handling is picked back up after a restart. Together, they form a resilience loop that needs no manual replaying of lost events.

The OrderSaga

Finally, let’s look at how the successful and compensation flows from Part 3 are implemented.

The failure integration events (PaymentFailed, CustomerReachedCreditLimit, ProductWasOutOfStock, ShipmentFailed) ride the payments and shipments topics too. Both topics carry integration events between contexts, and nothing else.

The domain events of the ordering workflow (OrderPlaced, OrderProcessed, OrderPaid, OrderCanceled) never reach the broker, since their only consumer is the saga running in the very same process, so Wolverine’s local routing delivers them in-process.

When a message arrives from a topic, Wolverine deserializes it into the corresponding integration event and routes it to whichever handler accepts that type. For the ordering workflow, that handler is the OrderSaga.

It’s a partial Wolverine saga: each handler takes an incoming message and returns the next command, and Wolverine dispatches it. The saga’s identity is the order’s Id, the correlation key that ties every message in the flow back to the same instance.

The compensation handlers, one per failure integration event, live in OrderSaga.Compensation.cs:

Triggering a compensation

Try to either spend more than your credit limit or purchase more products than are available in stock. That will result in a canceled order:

A quick check in the events will show exactly the reason:

Each compensation event results in a cancellation command, including a reason and a reference. In real-world scenarios, you’d likely implement a more nuanced approach (e.g., backorders), but this implementation demonstrates the concept well.


Final thoughts


This post wrapped up the backend by stitching together the infrastructure that makes the architecture actually run in practice: Docker to eliminate setup friction, Ocelot to give the frontend a clean and unified entry point, IdentityServer to handle authentication and machine-to-machine authorization, Kafka to decouple bounded contexts through integration events, and Wolverine’s transactional inbox and outbox to ensure those events 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.