CQRS and Domain-Driven Design with AI: A Prompt Engineering Approach
How to use Claude for Domain-Driven Design and CQRS: event storming facilitation, rich domain entities, and full CQRS vertical slices — with prompt patterns and a worked example that avoids anemic models.
Domain-Driven Design and CQRS reward precision — bounded contexts, aggregates, and command/query separation are all about drawing careful lines. That makes them a good fit for AI assistance, but also a place where an underspecified prompt produces exactly the anti-pattern DDD exists to prevent: anemic domain models with all the logic pushed into services.
The failure mode: AI-generated anemic models
Ask an LLM for "a User entity" with no constraints and you will typically get a plain data bag — public getters and setters, no invariants, no behavior. That's because most training data skews toward CRUD-style code. To get a rich domain model, the prompt has to explicitly demand invariant enforcement, private state, and behavior-carrying methods.
A prompt pattern for rich domain entities
- State the invariants explicitly ("an Order cannot transition to Shipped unless it has at least one line item and a valid address").
- Require the constructor/factory to reject invalid states rather than allowing them to be set later.
- Ban public setters for anything that represents a business rule, not just a field.
- Ask for domain events to be raised on meaningful state transitions if you're heading toward event sourcing or CQRS.
Worked example: from a vague ask to a real aggregate
"Create an Order class for an e-commerce system." This produces a class with a List<LineItem>, an Add method with no validation, and a public Status setter — a bug waiting to happen, since nothing stops Status being set to Shipped with zero line items.
Context: C#/.NET 8, using the aggregate pattern. Order is the aggregate root; LineItem is a child entity, never accessed directly from outside the aggregate.
Task: Design the Order aggregate.
Constraints:
- Invariant: Order cannot transition to Shipped unless it has at least one LineItem and a non-null ShippingAddress.
- Invariant: LineItems cannot be added once Order.Status is Shipped or Cancelled.
- No public setters on Status or LineItems — state changes only through named methods (AddLineItem, MarkAsShipped, Cancel) that enforce invariants and throw a domain-specific exception (OrderInvariantViolationException) on violation.
- Raise an OrderShipped domain event (with OrderId and ShippedAt) when MarkAsShipped succeeds, to be dispatched by the repository after save.
Output format: The Order class, the LineItem class, and the OrderInvariantViolationException — no repository or persistence code.public sealed class Order
{
private readonly List<LineItem> _lineItems = new();
public IReadOnlyList<LineItem> LineItems => _lineItems;
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public Address? ShippingAddress { get; private set; }
public void AddLineItem(LineItem item)
{
if (Status is OrderStatus.Shipped or OrderStatus.Cancelled)
throw new OrderInvariantViolationException("Cannot add items after shipping or cancellation.");
_lineItems.Add(item);
}
public void MarkAsShipped()
{
if (_lineItems.Count == 0 || ShippingAddress is null)
throw new OrderInvariantViolationException("Order needs a line item and address to ship.");
Status = OrderStatus.Shipped;
// raise OrderShipped domain event here
}
}Event storming before code
Before generating any entity or command handler, AI is genuinely useful for facilitating an event-storming pass: surfacing candidate domain events, commands, and bounded-context boundaries from a plain-English description of the business process. This front-loads the architecture decisions that are expensive to change later.
Context: We're modeling the order-fulfillment process for an e-commerce platform: a customer places an order, payment is authorized, warehouse picks and packs, carrier ships, customer receives.
Task: Act as an event-storming facilitator. From this description, produce: (1) the candidate domain events in past tense, in chronological order, (2) the commands that trigger each event, (3) a first guess at bounded-context boundaries (e.g. Ordering, Payments, Fulfillment, Shipping), flagging any event that looks like it crosses a context boundary and might need an integration event instead of a domain event.Generate the full vertical slice, not just the model
A CQRS feature is only useful end-to-end: command, handler, domain change, event, read-model projection. Prompting for the full slice in one structured request keeps the pieces consistent with each other rather than generating them in isolated, potentially mismatched calls.
- Command DTO and validator (e.g. ShipOrderCommand + FluentValidation rules).
- Handler that loads the aggregate, calls the domain method, persists, and dispatches raised events.
- Event handler(s) that update the read model (e.g. OrderShipped → update OrderSummaryReadModel.Status).
- A test that exercises the full slice: send the command, assert the read model reflects the change.
The New Project — Advanced program covers this full arc: event storming, rich domain modelling, and full CQRS vertical slices, alongside microservices and distributed-systems patterns.