Every time I start a new microservices project, the same thing happens.
I spend the first three weeks not building features, but scaffolding. Wiring up MediatR. Configuring EF Core aggregate mappings. Setting up the event bus. Writing the Dockerfile. Then the Bicep templates. Then the Terraform. Then the CI/CD pipeline. Then realizing my "domain events" are just anemic DTOs being passed around.
By week four, I've written zero business logic and I'm already burned out.
Sound familiar?
After doing this dance across half a dozen projects, I finally built the starter kit I wish someone had handed me on day one. It's a fully wired, 108-file .NET 8 solution with three microservices, real DDD patterns, CQRS, infrastructure as code, and documentation that actually explains why things are the way they are.
This article walks through the key architecture decisions and code. If you want the full thing, the DDD Microservices Starter Kit is on Gumroad.
The Architecture at a Glance
Three bounded contexts: Order, Inventory, and Notification - each with its own database and its own deployable. They communicate through domain events published over Azure Service Bus (or RabbitMQ for local dev). No shared databases. No temporal coupling. Just messages.
Clean Architecture: Four Layers, Zero Shortcuts
Each microservice follows the same four-layer structure:
src/
Services/
Order/
Order.Domain/ # Entities, Value Objects, Domain Events, Repository interfaces
Order.Application/ # Commands, Queries, Handlers, Validators, DTOs
Order.Infrastructure/ # EF Core, Service Bus, External APIs
Order.API/ # Controllers, Middleware, DI configuration
The dependency rule is enforced by project references. Domain references nothing. Application references only Domain. Infrastructure references Application and Domain. API wires it all together.
This isn't just a folder convention, it's compile-time enforcement. If someone tries to reference Infrastructure from Domain, the build fails.
The Domain Layer: Where the Real DDD Lives
Most "DDD" starter kits I've seen put an Entity base class in the domain and call it a day. That's not DDD. That's an anemic model with extra steps.
Here's what the Order aggregate actually looks like:
public class Order : AggregateRoot<OrderId>
{
private readonly List<OrderLine> _lines = new();
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // EF Core
public static Order Create(CustomerId customerId, Address shippingAddress)
{
var order = new Order
{
Id = OrderId.Create(),
CustomerId = customerId,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero("USD")
};
order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new OrderDomainException("Can only add lines to draft orders.");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
RecalculateTotal();
}
public void Submit()
{
if (!_lines.Any())
throw new OrderDomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
AddDomainEvent(new OrderSubmittedEvent(Id, CustomerId, TotalAmount));
}
private void RecalculateTotal()
{
TotalAmount = _lines.Aggregate(
Money.Zero("USD"),
(sum, line) => sum.Add(line.TotalPrice));
}
}
Notice:
- No public setters. State changes go through methods that enforce invariants.
-
Value Objects like
Money,OrderId,CustomerId, andAddress— not primitive types scattered everywhere. - Domain events are raised inside the aggregate, not bolted on from the outside.
-
A factory method (
Create) instead of a public constructor, the aggregate controls its own creation.
The Money value object prevents an entire class of bugs:
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative.");
Amount = amount;
Currency = currency;
}
public static Money Zero(string currency) => new(0, currency);
public static Money Of(decimal amount, string currency) => new(amount, currency);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}
No more accidentally adding USD to EUR. No more negative totals sneaking in through a careless assignment. The type system catches it.
CQRS with MediatR: Commands, Queries, and Pipeline Behaviors
Every use case is a discrete command or query, dispatched through MediatR:
// Command
public record SubmitOrderCommand(Guid OrderId) : IRequest<Result<OrderDto>>;
// Handler
public class SubmitOrderCommandHandler
: IRequestHandler<SubmitOrderCommand, Result<OrderDto>>
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _uow;
public SubmitOrderCommandHandler(IOrderRepository orders, IUnitOfWork uow)
{
_orders = orders;
_uow = uow;
}
public async Task<Result<OrderDto>> Handle(
SubmitOrderCommand request, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(OrderId.From(request.OrderId), ct);
if (order is null) return Result.NotFound();
order.Submit();
await _uow.CommitAsync(ct); // Dispatches domain events after save
return Result.Success(order.ToDto());
}
}
The handler is 15 lines. No logging boilerplate, no validation checks, no try-catch. That's because pipeline behaviors handle cross-cutting concerns:
// Validation: runs before every handler
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}
There's also a LoggingBehavior and a PerformanceBehavior (logs warnings for slow handlers) wired up the same way. Add your own, it's just another IPipelineBehavior.
Validation rules use FluentValidation and live next to their commands:
public class SubmitOrderCommandValidator : AbstractValidator<SubmitOrderCommand>
{
public SubmitOrderCommandValidator()
{
RuleFor(x => x.OrderId).NotEmpty().WithMessage("OrderId is required.");
}
}
Domain Events: From Aggregate to Event Bus
When order.Submit() is called, it adds an OrderSubmittedEvent to the aggregate's internal event list. The magic happens in the UnitOfWork:
public async Task CommitAsync(CancellationToken ct)
{
// 1. Save to database
await _dbContext.SaveChangesAsync(ct);
// 2. Dispatch domain events (in-process via MediatR)
var domainEvents = _dbContext.GetDomainEvents();
foreach (var domainEvent in domainEvents)
await _mediator.Publish(domainEvent, ct);
// 3. Publish integration events (cross-service via event bus)
await _eventBus.PublishPendingAsync(ct);
}
Domain events stay in-process. Integration events cross service boundaries. An in-process handler maps between them:
public class OrderSubmittedDomainEventHandler
: INotificationHandler<OrderSubmittedEvent>
{
private readonly IEventBus _eventBus;
public OrderSubmittedDomainEventHandler(IEventBus eventBus)
=> _eventBus = eventBus;
public Task Handle(OrderSubmittedEvent notification, CancellationToken ct)
{
_eventBus.Enqueue(new OrderSubmittedIntegrationEvent(
notification.OrderId.Value,
notification.TotalAmount.Amount,
notification.TotalAmount.Currency));
return Task.CompletedTask;
}
}
The IEventBus abstraction swaps between Azure Service Bus in production and RabbitMQ in Docker Compose, with zero code changes in the domain or application layers.
EF Core: Mapping Aggregates Without Compromising the Domain
EF Core configuration lives entirely in Infrastructure. The domain never knows about persistence:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(id => id.Value, val => OrderId.From(val));
builder.OwnsOne(o => o.TotalAmount, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount");
money.Property(m => m.Currency).HasColumnName("TotalCurrency");
});
builder.OwnsMany(o => o.Lines, line =>
{
line.WithOwner().HasForeignKey("OrderId");
line.Property(l => l.ProductId)
.HasConversion(id => id.Value, val => ProductId.From(val));
line.OwnsOne(l => l.UnitPrice);
line.OwnsOne(l => l.TotalPrice);
});
builder.Metadata.FindNavigation(nameof(Order.Lines))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
Value Objects are OwnsOne / OwnsMany. Private collections are accessed via backing fields. The domain model stays clean and the database schema stays sane.
Infrastructure as Code: Pick Your Weapon
The starter kit ships with three IaC options:
| Tool | Use Case | Files |
|---|---|---|
| Docker Compose | Local development |
docker-compose.yml, docker-compose.override.yml
|
| Bicep | Azure-native deployment |
infra/bicep/: AKS, Service Bus, SQL, Container Registry |
| Terraform | Multi-cloud / team preference |
infra/terraform/: same resources, HCL syntax |
Plus Kubernetes manifests in k8s/ for when you outgrow Docker Compose, and a GitHub Actions pipeline that builds, tests, and deploys on every push to main.
# .github/workflows/ci-cd.yml (simplified)
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet build --configuration Release
- run: dotnet test --configuration Release --no-build
deploy:
needs: build-and-test
if: github.ref == 'refs/heads/main'
steps:
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# Build images, push to ACR, deploy to AKS...
The Documentation Nobody Writes (But Should)
The kit includes documentation that goes beyond READMEs:
- Event Storming Guide: how to run a session, what the sticky note colors mean, how to translate the output into bounded contexts and aggregates.
- Context Map: visual diagram of how Order, Inventory, and Notification relate (Customer-Supplier, Published Language, etc.).
-
Architecture Decision Records (ADRs) : why MediatR over raw
IServiceProvider? Why separate integration events from domain events? Why EF Core over Dapper? Each decision is documented with context, options considered, and the rationale. - Strangler Fig Migration Guide: a step-by-step playbook for incrementally migrating a monolith to this architecture, because nobody starts greenfield.
What's in the Box
Here's the full inventory:
✅ 3 microservices with Clean Architecture (Order, Inventory, Notification)
✅ Rich domain models: Aggregates, Value Objects, Domain Events
✅ CQRS via MediatR with validation and logging pipeline behaviours
✅ FluentValidation for command/query validation
✅ EF Core with proper aggregate mapping (owned types, backing fields)
✅ Dual event bus: Azure Service Bus + RabbitMQ
✅ Docker Compose for one-command local dev
✅ Bicep + Terraform for Azure deployment
✅ Kubernetes manifests
✅ GitHub Actions CI/CD pipeline
✅ Unit tests + integration tests
✅ Event Storming guide, Context Map, ADRs, Strangler Fig migration guide
✅ 108 files, all wired and working
Who This Is For
- Senior devs starting a new microservices project who don't want to spend weeks on scaffolding
- Team leads who need a reference architecture the team can actually follow
- Architects evaluating how DDD patterns translate to .NET 8
- Anyone migrating a monolith who wants a target architecture with a migration playbook
This is not a tutorial. It's not a toy. It's the codebase you'd end up with after months of iteration, minus the months.
Get the Starter Kit
Stop scaffolding. Start building.
👉 Get the DDD Microservices Starter Kit on Gumroad
The repo is also on GitHub: tysoncung/ddd-microservices-azure-starter
Questions? Hit me up on X: @tscung
Built by Tyson Cung, software architect, DDD practitioner, and recovering scaffolding addict.


Top comments (2)
good job
Thank you.