DDD & CQRS Tutorial with the Order Service
Learn how Domain-Driven Design and CQRS work inside the Order microservice
What is DDD?
Domain-Driven Design is an approach to building software where real business logic sits at the center of the design — not the database, not the framework, not the UI. The "domain" is the problem space the software exists for. In an online shop that's orders, products, customers, stock levels and rules like "an order must have at least one line" or "a cancelled order cannot be edited".
The idea was introduced by Eric Evans in the book "Domain-Driven Design" (2003). A common problem in large projects is that code slowly turns into a pile of Controllers, Services and Repositories with business logic scattered and duplicated, making it hard for the team to understand. DDD says: understand the domain first, then build the code around it.
Key DDD principles
- Ubiquitous Language: developers and business people share the same vocabulary. If a meeting says "the order was cancelled", the code has
Cancel()— notUpdateStatus(3). - Layered Architecture: Domain (pure rules), Application (scenario coordination), Infrastructure (database and external APIs) and Api (HTTP entry) are kept separate.
- Entity and Value Object: an Entity is identified by Id (e.g. an order line). A Value Object is identified by its value (e.g. money or an address).
- Aggregate and Aggregate Root: a group of related objects that form one unit. Changes go only through the "root" — like an order and its line items.
- Domain Event: when something important happens in the business (e.g. "order placed") we record it as an event so other parts can react without tight coupling.
OrderAggregate and the controller just says "create an order" — the code reads closer to the business language and is easier to test.
In this tutorial you'll see these concepts step by step through a real Order service in .NET 8: four layers, aggregates, CQRS, MediatR and communication with User and Product microservices. If you're new, start with "What does the Order service do?"
1. What does the Order service do?
The Order service is responsible for managing orders. Its main jobs are:
- Create a new order (with multiple line items)
- View a single order or all orders of a user
- Cancel an order
But here's the key point: Order doesn't work alone. Before creating an order it must ask:
- Does this user exist and is active? (asks the User service)
- Is this product available with enough stock? (asks the Product service)
2. The four project layers
The Order project is split into four projects (layers). Each layer has a single responsibility:
🟢 Order.Domain — Domain
Business rules live here. It has no dependency on a database or framework.
Contains: OrderAggregate, OrderLine, OrderStatus, Events, base classes (Entity, AggregateRoot)
🔵 Order.Application — Application
Coordinates the scenarios: "when an order comes in, what should happen and in what order?"
Contains: Commands, Queries, Handlers, Validators, DTOs, Interfaces
🟣 Order.Infrastructure — Infrastructure
The real implementation of the database and communication with other services.
Contains: OrderDbContext, Repository, Configurations, HttpClients
🟡 Order.Api — HTTP interface
Receives the HTTP request and hands it to Application.
Contains: OrdersController, Request DTOs, Program.cs
All arrows point toward Domain. The heart of the project must not know what a database or an API is.
3. DDD concepts with real examples
3.1 Entity
An Entity is something identified by an Id, not by its content. Even if all of its attributes change, as long as the Id is the same it is still the same entity.
Example: an "order line" (OrderLine). Two order lines, even if both are "2 laptops", are still two different entities because they have separate Ids.
// Base class: every entity has an Id
public abstract class Entity
{
public Guid Id { get; protected set; }
protected Entity() { }
}
// OrderLine is an Entity
public sealed class OrderLine : Entity
{
public Guid ProductId { get; private set; }
public string ProductName { get; private set; }
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
public decimal LineTotal => UnitPrice * Quantity; // computed
// can only be created from within this assembly (internal)
internal static OrderLine Create(Guid productId, string name, int qty, decimal price)
{
if (qty <= 0) throw new ArgumentException("Quantity must be > 0");
if (price < 0) throw new ArgumentException("Price cannot be negative");
return new OrderLine { Id = Guid.NewGuid(), /* ... */ };
}
}
private. That means values cannot be changed from outside — only through controlled methods. This is encapsulation.
3.2 Value Object
A Value Object is the opposite of an Entity: it is identified by its content, not an Id. Two Value Objects with the same values are completely equal.
The classic example is money. "$10" is always equal to "$10". In the Product service we have this:
public sealed class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
// Two Money instances with the same Amount and Currency are equal
}
decimal. But in Product the Money concept is implemented as a Value Object so you can see the difference.
| Trait | Entity | Value Object |
|---|---|---|
| Identified by | Id | Content |
| Mutable? | Yes (controlled) | No (immutable) |
| Example | OrderLine, OrderAggregate | Money, Email |
3.3 Aggregate & Aggregate Root
An Aggregate is a group of related objects that together form one unit. In our service:
OrderAggregate + several OrderLines inside it
The Aggregate Root is the "boss" of this group. The important rule:
OrderLine directly. They must ask the OrderAggregate.
See how it's implemented:
public sealed class OrderAggregate : AggregateRoot
{
// List of lines: it's private! Not visible from outside
private readonly List<OrderLine> _lines = [];
public Guid UserId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
// Outside only sees a read-only copy
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private OrderAggregate() { } // private constructor
// The only way to create an order: this factory method
public static OrderAggregate Create(Guid userId, IEnumerable<...> items)
{
var lineItems = items.ToList();
if (lineItems.Count == 0)
throw new ArgumentException("Order must contain at least one line item.");
var order = new OrderAggregate
{
Id = Guid.NewGuid(),
UserId = userId,
Status = OrderStatus.Confirmed,
CreatedAt = DateTime.UtcNow
};
// lines are created only from inside the root
foreach (var item in lineItems)
order._lines.Add(OrderLine.Create(item.ProductId, item.ProductName,
item.Quantity, item.UnitPrice));
order.TotalAmount = order._lines.Sum(l => l.LineTotal); // compute total
order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
return order;
}
public void Cancel()
{
if (Status == OrderStatus.Cancelled)
throw new InvalidOperationException("Order is already cancelled.");
Status = OrderStatus.Cancelled;
UpdatedAt = DateTime.UtcNow;
RaiseDomainEvent(new OrderCancelledEvent(Id));
}
}
TotalAmount might not match the sum of the lines, creating corrupt data. The root guarantees the order always stays in a valid state.
The AggregateRoot base class
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent e) => _domainEvents.Add(e);
public void ClearDomainEvents() => _domainEvents.Clear();
}
The difference between AggregateRoot and Entity: besides an Id, it can record Domain Events. That's why OrderAggregate inherits from AggregateRoot while OrderLine only inherits from Entity.
3.4 Domain Event
A Domain Event means "an important business thing happened". For example "an order was placed" or "an order was cancelled". These events are facts from the past, so their names are always in past tense.
public interface IDomainEvent
{
DateTime OccurredOn { get; } // when it happened
}
public sealed class OrderPlacedEvent : IDomainEvent
{
public Guid OrderId { get; }
public Guid UserId { get; }
public decimal TotalAmount { get; }
public DateTime OccurredOn { get; }
// ...
}
When an order is created, this line runs inside Create:
order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
_domainEvents list). In real projects you can publish these events after persistence so that, for example, a confirmation email is sent or the warehouse service is notified. This creates loose coupling between parts of the system.
4. CQRS with real examples
CQRS means separating writes from reads:
| Command | Query | |
|---|---|---|
| Job | Changes state (write) | Only reads |
| Example | Create order, cancel order | View order |
| Output | Usually just an Id or nothing | Data (DTO) |
For the implementation we use the MediatR library. The controller does not call the Handler directly; it gives a message (Command/Query) to MediatR, and MediatR finds the right Handler itself.
4.1 Command
Each Command has three parts in one file: the command itself, the validation rules, and the handler.
// 1) The command itself: just data (a simple record)
public sealed record CreateOrderCommand(
Guid UserId,
IReadOnlyList<CreateOrderLineRequest> Lines) : IRequest<Guid>;
// ^^^^^^^^^^^^^ output: order Id
// 2) Validation rules
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.Lines).NotEmpty();
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}
4.2 Handler
The Handler is where the scenario logic runs. This is the most important part of the Order service:
public sealed class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _orderRepository;
private readonly IUserServiceClient _userServiceClient;
private readonly IProductServiceClient _productServiceClient;
// (dependency injection via the constructor)
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
{
// 1) Does the user exist? (call the User service)
var user = await _userServiceClient.GetUserAsync(request.UserId, ct)
?? throw new KeyNotFoundException($"User '{request.UserId}' not found.");
// 2) Is the user active?
if (!user.IsActive)
throw new InvalidOperationException("Cannot create order for inactive user.");
// 3) For each item, fetch the product from the Product service and check stock
var orderItems = new List<(Guid, string, int, decimal)>();
foreach (var line in request.Lines)
{
var product = await _productServiceClient.GetProductAsync(line.ProductId, ct)
?? throw new KeyNotFoundException($"Product '{line.ProductId}' not found.");
if (product.StockQuantity < line.Quantity)
throw new InvalidOperationException(
$"Insufficient stock for '{product.Name}'.");
orderItems.Add((product.Id, product.Name, line.Quantity, product.Price));
}
// 4) Now that everything is valid, create the order (logic inside the aggregate)
var order = OrderAggregate.Create(request.UserId, orderItems);
// 5) Persist to the database
await _orderRepository.AddAsync(order, ct);
return order.Id;
}
}
OrderAggregate. The Handler does not duplicate domain logic.
4.3 Query
A Query only reads and returns a DTO (not the aggregate itself):
public sealed record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
public sealed class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IOrderRepository _repository;
public async Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken ct)
{
var order = await _repository.GetByIdAsync(request.OrderId, ct);
return order?.ToDto(); // convert the aggregate to a DTO
}
}
public sealed record OrderDto(
Guid Id, Guid UserId, OrderStatus Status, decimal TotalAmount,
DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<OrderLineDto> Lines);
4.4 FluentValidation & ValidationBehavior
Question: we wrote the Validator, but who runs it? The answer: ValidationBehavior.
This is a "pipeline behavior" in MediatR. Every Command/Query passes through this pipeline before reaching the Handler:
public sealed class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
if (!_validators.Any()) return await next(); // no validators? skip
var context = new ValidationContext<TRequest>(request);
var results = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct)));
var failures = results.SelectMany(r => r.Errors).ToList();
if (failures.Count != 0)
throw new ValidationException(failures); // errors? don't reach the Handler
return await next(); // all good? go to the next Handler
}
}
This behavior is registered in DI so it's active for every request:
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
5. The full flow of creating an order
Now let's put it all together. When the client sends a POST /api/orders:
CreateOrderCommand and calls _mediator.Send(...)OrderPlacedEvent is recorded201 Created response with the order IdAnd the controller maps errors to the correct HTTP status codes like this:
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
try
{
var id = await _mediator.Send(new CreateOrderCommand(
request.UserId,
request.Lines.Select(l => new CreateOrderLineRequest(l.ProductId, l.Quantity)).ToList()), ct);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (KeyNotFoundException ex) { return NotFound(new { title = ex.Message }); } // 404
catch (InvalidOperationException ex) { return Conflict(new { title = ex.Message }); } // 409
}
6. Persistence
In OrderDbContext we have only one DbSet — for the root:
public sealed class OrderDbContext : DbContext
{
public DbSet<OrderAggregate> Orders => Set<OrderAggregate>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
6.1 How does EF Core find the Configuration class?
You might ask: we wrote the OrderAggregateConfiguration class, but nowhere did we new it or register it in DI. So how does EF Core know about it? The answer is this single line:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);
This line tells EF Core:
Order.Infrastructure project), find every class that implements IEntityTypeConfiguration<T>, create an instance of it and call its Configure method."
The contract of a Configuration class
For EF Core to discover a class automatically, only three conditions are required:
- The class is
public - It implements the
IEntityTypeConfiguration<T>interface - It lives in the same assembly that
ApplyConfigurationsFromAssemblywas called on
// any name contract ↓ for which entity ↓
public sealed class OrderAggregateConfiguration : IEntityTypeConfiguration<OrderAggregate>
{
public void Configure(EntityTypeBuilder<OrderAggregate> builder)
{
// EF Core calls this method itself
}
}
What does EF Core do behind the scenes?
Simplified, something like this runs:
// pseudo-code inside EF Core
foreach (var type in assembly.GetTypes())
{
if (type implements IEntityTypeConfiguration<SomeEntity>)
{
var instance = Activator.CreateInstance(type); // create an instance
instance.Configure(modelBuilder.Entity<SomeEntity>()); // run Configure
}
}
In our project two classes are found and executed:
| Configuration class | For which entity | Table |
|---|---|---|
| OrderAggregateConfiguration | OrderAggregate | Orders |
| OrderLineConfiguration | OrderLine | OrderLines |
The whole flow from the start
OrderDbContext is used for the first timeOnModelCreating methodApplyConfigurationsFromAssembly scans all IEntityTypeConfiguration classesConfigure method runs (mapping tables, keys, relationships)IEntityTypeConfiguration, it's discovered automatically. That's also why OrderLineConfiguration is applied even though it has no DbSet.
_lines relationship wouldn't map correctly, and DomainEvents and Lines might be mistakenly treated as database columns.
DbSet for OrderLine?
Because OrderLine is not an independent aggregate — it's part of the order aggregate. In DDD only roots get a DbSet. This prevents direct manipulation of the lines and preserves the aggregate boundary.
But OrderLine still has its own table (OrderLines). EF Core knows it through the relationship:
public void Configure(EntityTypeBuilder<OrderAggregate> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.TotalAmount).HasPrecision(18, 2);
// one-to-many relationship with lines; use the private _lines field
builder.HasMany<OrderLine>("_lines")
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade); // order deleted? its lines go too
builder.Navigation("_lines").UsePropertyAccessMode(PropertyAccessMode.Field);
builder.Ignore(o => o.Lines); // read-only property, not mapped
builder.Ignore(o => o.DomainEvents); // events are not stored in the database
}
So how is it saved without a DbSet? When you add the order, EF Core follows the entire connected graph:
await _context.Orders.AddAsync(order, ct); // we only added the root
await _context.SaveChangesAsync(ct); // EF inserts the inner lines itself
The resulting generated SQL:
INSERT INTO Orders (Id, UserId, Status, TotalAmount, CreatedAt) VALUES (...);
INSERT INTO OrderLines (Id, OrderId, ProductId, ProductName, Quantity, UnitPrice) VALUES (...);
INSERT INTO OrderLines (...) VALUES (...);
And when reading, we load the lines too with Include:
return await _context.Orders
.Include("_lines") // load the lines too
.FirstOrDefaultAsync(o => o.Id == id, ct);
7. Communicating with other services
To fetch User and Product data, the Order service talks to them over HTTP. But the Application layer must not know what HTTP is — so we define an interface:
// In the Application layer: just the contract
public interface IUserServiceClient
{
Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default);
}
public sealed record UserInfo(Guid Id, string Email, string FullName, bool IsActive);
And the real HTTP implementation lives in the Infrastructure layer:
// In the Infrastructure layer: the real implementation
public sealed class UserServiceClient : IUserServiceClient
{
private readonly HttpClient _httpClient;
public async Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default)
{
var response = await _httpClient.GetAsync($"/api/users/{userId}", ct);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<UserApiResponse>(ct);
return user is null ? null : new UserInfo(user.Id, user.Email, user.FullName, user.IsActive);
}
}
And they're wired together in DI:
services.AddHttpClient<IUserServiceClient, UserServiceClient>(client =>
{
client.BaseAddress = new Uri(userServiceUrl);
});
IUserServiceClient. Whether HTTP, gRPC, or even a Mock for testing is behind it doesn't matter. This keeps the code testable and changeable.
8. Running & testing
Because Order depends on User and Product, it's best to run all three.
Option 1: with Docker Compose (easier)
docker compose up --build
Option 2: manually
dotnet run --project src/User/User.Api
dotnet run --project src/Product/Product.Api
dotnet run --project src/Order/Order.Api
Then open the Order service Swagger and create an order:
POST /api/orders
{
"userId": "GUID of an active user",
"lines": [
{ "productId": "GUID of an in-stock product", "quantity": 2 }
]
}
| Method | Route | Job |
|---|---|---|
| POST | /api/orders | Create an order |
| GET | /api/orders/{id} | View a single order |
| GET | /api/orders/user/{userId} | A user's orders |
| PUT | /api/orders/{id}/cancel | Cancel an order |
Final summary
- Domain = the heart and the rules (OrderAggregate, OrderLine, Events) — independent of everything
- Application = coordinating scenarios (Command/Query/Handler) with MediatR and FluentValidation
- Infrastructure = the database and HTTP communication with other services
- Api = the HTTP entry door
- Aggregate Root guarantees the order always stays valid
- CQRS separates writes from reads
- Only the root gets a
DbSet; lines are saved through the relationship
This file was made to learn DDD and CQRS with the Order project — good luck! 🚀