C# · .NET 8 · Clean Architecture · NATS JetStream

EventDispatcher
A Clean Architecture Starting Point

Setting up a business application and not sure where your objects belong — or which way the references should point? This is a clean architecture example done the right way: generic enough to extend, real enough to run. It listens to a warehouse event bus and forwards every event, validated and mapped, to a write API.

10projects, one dependency rule
3message types today — easy to extend
0dependencies in the Domain
4layers, glued by a composition root

What it does

The WMS publishes its domain events on a central NATS bus; the dispatcher turns each one into a validated command and hands it to the system that owns the data.

   WMS (per warehouse)          NATS JetStream             EventDispatcher host
 ┌──────────────────────┐    ┌─────────────────┐    ┌────────────────────────────────┐
 │  despatch events     │    │ stream:         │    │  NatsSubscriber (Background)   │
 │  stock movements     │───▶│   messages      │───▶│  parse → persist → registry    │
 │  app metrics         │    │ subject:        │    │  → MediatR command             │
 └──────────────────────┘    │   messages.>    │    └───────────────┬────────────┘
                             └─────────────────┘                    │ validate → map → POST
                                                                    ▼
                                                     Internal Write API (per warehouse)
      

Every event arrives as a small JSON envelope: an id, a type name and a data payload. The dispatcher stores the raw envelope first, looks up which DTO the type name belongs to, and dispatches a command through MediatR. A FluentValidation pipeline runs before the handler; the handler maps the DTO to a domain model and posts it to the internal Write API of the target warehouse.

The host also exposes a small API of its own: Swagger, health checks for the NATS connection and the Write API, and a query endpoint to inspect the messages it received.

That is all it does — deliberately. The value is in how it is layered.

The clean architecture part

One idea carries the whole solution: source-code dependencies point inward, toward the business. Everything at the edge — brokers, HTTP, databases — is a replaceable detail.

Presentation — Worker host · Api

One process: hosts the NATS subscriber, controllers, Swagger and health endpoints. Wires every layer together.

Infrastructure — NATS · HTTP · persistence

Implements the ports: JetStream consumer, Write-API client, message repository, Key Vault config, health checks.

Application — use cases

MediatR handlers, one per message type, plus DTOs, validators, mappers and the MessageProcessor orchestrating them.

Domain — entities & events

The event envelope, the stored message and the despatch / stock / metric models. Depends on nothing.

References may only point inward. The Domain never knows that NATS, HTTP or even .NET packages exist — the edges know the center, never the other way around.

The projects and their seams

LayerProjectResponsibilityMay depend on
Domain.DomainEvent envelope (WmsMsg), stored message, despatch / stock / metric models
Application.ApplicationMessageProcessor, MediatR handlers, DTOs, validators, AutoMapper profilesDomain, Interfaces, SharedKernel, Contracts
Infrastructure.InfrastructureJetStream subscriber, Write-API client, persistence, configuration, health checksDomain, Interfaces, SharedKernel
Presentation.Worker + .ApiThe single host: subscriber + API in one process; Swagger, health, telemetryApplication, Infrastructure
Ports.InterfacesEvery interface the core consumes and the edges implement (messaging, persistence, services)Domain
SharedKernel.SharedKernel[MessageType]/[RequestType] attributes + registries, ValidationBehavior, ErrorOr helpersbase packages only
Contracts.ContractsThe response shapes the API publishes — the only thing that leaks out

Four rules that make it clean

1 · References point inward

Application calls IMessageRepository and IInternalWriteApiClient from .Interfaces; Infrastructure implements them. Application never references Infrastructure — the DI container glues them at runtime.

2 · Interfaces live with their consumer

Each port is declared where it is used, not where it is implemented. The core owns its contracts; the edges fulfil them.

3 · The wire shape is a detail

DTOs per message type stay inside Application; .Contracts only holds what the API returns. Nothing internal leaks across the boundary.

4 · The host is dumb

Program.cs is four Add*Services() calls. Swapping NATS for another broker, or the Write API for a database, is one registration line and one Infrastructure class.

Why bother? You can unit-test the core without NATS or HTTP (Moq the ports), replace an edge without touching a single use case, and any new developer can see within minutes where a new object belongs. This is the part that scales with the project — the layers stay honest long after the first release.

Project structure

Ten projects, each with one reason to exist.

Caldro.EventDispatcher/
├─ src/
│  ├─ WmsEventDispatcher.Worker/            single host — subscriber + Api in one process
│  ├─ WmsEventDispatcher.Api/               controllers, Swagger, health, App Insights
│  ├─ WmsEventDispatcher.Application/       MediatR handlers, DTOs, validators, mappers
│  ├─ WmsEventDispatcher.Domain/            entities & event envelope — zero dependencies
│  ├─ WmsEventDispatcher.Infrastructure/    NATS JetStream, Write-API client, persistence
│  ├─ WmsEventDispatcher.Interfaces/        every port: messaging, persistence, services
│  ├─ WmsEventDispatcher.SharedKernel/      attributes, registries, pipeline behavior
│  ├─ WmsEventDispatcher.Contracts/         API response shapes
│  ├─ WmsEventDispatcher.UnitTests/         xUnit + Moq
│  ├─ WmsEventDispatcher.IntegrationTests/  .http request files
│  └─ deploy/helm/                          Helm chart: Api + Worker deployments
├─ Docker/                                  compose files & Dockerfiles
├─ Documentatie/                            drawio diagram sources & docs
└─ Caldro.EventDispatcher.sln
      

Under the hood: MediatR for dispatch, FluentValidation for the pipeline, AutoMapper for DTO ↔ model mapping, ErrorOr for results, NATS.Net for JetStream, plus Application Insights, Azure Key Vault and Swashbuckle. Tests run with xUnit and Moq.

How a message flows

Six steps from the WMS bus to the write API — and every step lives in its own layer.

STEP 1

Publish

The WMS publishes despatch preparations, stock movements and app metrics as JSON onto NATS subjects (messages.>), persisted in the messages stream.

STEP 2

Subscribe

A BackgroundService keeps one durable JetStream consumer alive: reconnects on failure with a 10s backoff, ACKs success, NAKs failure so the server redelivers.

STEP 3

Parse & store

The envelope (id, type, data) is parsed and the raw message is persisted first — nothing is lost, even if a handler fails.

STEP 4

Resolve

The MessageTypeRegistry scans assemblies for [MessageType("Preparation")] and maps the wire name to its DTO. No if/else chains, no central switch to maintain.

STEP 5

Validate & dispatch

The DTO is wrapped in a BaseCommand<TDto> and sent through MediatR. A FluentValidation ValidationBehavior runs every registered validator first.

STEP 6

Map & write

The generic BaseCommandHandler maps DTO → domain model (AutoMapper) and POSTs it through a typed client to the internal Write API; a URL factory builds the target address.

The nice part: step 4 to 6 are one class per message type. The processor, the registry and the base handler never change — they were written once, generically, in the Application and SharedKernel layers.

Key features

A starting point that is already operational.

One dependency rule

Every project arrow points inward; the Domain references nothing at all.

Attribute-driven dispatch

[MessageType] + assembly scan maps a wire name to a DTO — adding a type touches no existing code.

Durable by default

JetStream consumer with ACK/NACK, auto-reconnect and self-recreation after failures.

Raw-first persistence

Every envelope is stored before processing, so replay and forensics are always possible.

Generic handler base

BaseCommand<TDto> + BaseCommandHandler map and post; a new event is just new classes.

Validation pipeline

A MediatR pipeline behavior runs all FluentValidation validators before any handler.

Configurable targets

NATS servers, streams and the Write-API base URL all come from configuration — multi-warehouse friendly.

Secrets from Key Vault

Azure Key Vault with override-based appsettings; no secrets in the repo.

Observable

Application Insights telemetry plus health checks for the NATS connection and the Write API.

Ship-ready

Dockerfile, docker-compose and a Helm chart for Api + Worker are included.

Make it your own

The solution is meant to be extended — here is where the seams are.

EXTEND 1

Add a message type

Create a DTO with [MessageType("YourType")], a validator and an AutoMapper profile. Registry, MediatR and DI pick it up automatically — no central file to edit.

EXTEND 2

Change the destination

Implement IInternalWriteApiClient your way — another API, a queue, a database. Application never notices the difference.

EXTEND 3

Swap the transport

INatsSubscriber and INatsJetstreamClient are ports in .Interfaces. Implement them for any broker and keep the core untouched.

EXTEND 4

Rename & reuse

Namespaces are plain WmsEventDispatcher.*. Search & replace to your product's name and take it from there — that is the point.

About this project

Idea driven.

Ment for general integrations: f.e. a WMS pushed its events onto a central bus, and a dispatcher had to translate each one for the write API of the right site. The solution was deliberately kept generic — a starting point that shows where objects belong and which way references point in a clean architecture.

The draw.io diagram sources and docs ship in the Documentatie folder of the download, so you can adapt the pictures to your own project too.

Maybe look up “clean architecture” for some more background — this solution puts the idea into a working codebase.

Download the source

The complete Caldro.EventDispatcher solution — all ten projects, tests, Docker & Helm files and the diagram sources — in a single archive.

⬇  Download Caldro.EventDispatcher.zip
Complete source · .zip archive