End to End: Refactoring Bookstore's Ordering Context from Anemic CRUD to a Rich Model
The safe way to move anemic CRUD code to a rich model is to pin current behavior with characterization tests first, then take the language, value object, aggregate boundary, invariant, and domain event steps as separate commits that each stay green. We transform Bookstore's Ordering in 11 commits and show refactoring toward deeper insight with a buy-3-pay-for-2 campaign, plus before/after metrics, all with real output.
Version note: The code in this post was built and run with C# 14, .NET 10, and Entity Framework Core (EF Core) 10.
Guess First
Bookstore's Ordering code, in the layered monolith we've been calling "today's state" since the start of the series, looks like this: one entity, one service, one controller. Everything compiles and it has been running in production for months.
namespace Bookstore.Ordering.Entities;
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTime OrderDate { get; set; }
public int Status { get; set; } // 0 = new, 1 = paid, 2 = shipped, 3 = cancelled
public string Currency { get; set; } = "EUR";
public decimal Total { get; set; }
public List<OrderDetail> Details { get; set; } = new();
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Services;
public class OrderService(OrderingDbContext db)
{
public async Task<int> CreateOrder(CreateOrderDto dto)
{
if (dto.Lines.Count == 0)
throw new InvalidOperationException("Order must have at least one line.");
var order = new Order { CustomerId = dto.CustomerId, OrderDate = DateTime.Now, Status = 0 };
foreach (var line in dto.Lines)
AddOrMerge(order, line);
order.Total = order.Details.Sum(d => d.Price * d.Qty);
db.Orders.Add(order);
await db.SaveChangesAsync();
return order.Id;
}
public async Task AddItem(int orderId, OrderLineDto line)
{
var order = await db.Orders.Include(o => o.Details).FirstAsync(o => o.Id == orderId);
if (order.Status != 0)
throw new InvalidOperationException("Order cannot be changed.");
AddOrMerge(order, line);
order.Total = order.Details.Sum(d => d.Price * d.Qty);
await db.SaveChangesAsync();
}
public async Task RemoveItem(int orderId, int productId)
{
var order = await db.Orders.Include(o => o.Details).FirstAsync(o => o.Id == orderId);
if (order.Status != 0)
throw new InvalidOperationException("Order cannot be changed.");
var detail = order.Details.First(d => d.ProductId == productId);
db.OrderDetails.Remove(detail);
order.Total = order.Details.Where(d => d != detail).Sum(d => d.Price * d.Qty);
await db.SaveChangesAsync();
}
public async Task UpdateStatus(int orderId, int status)
{
var order = await db.Orders.FirstAsync(o => o.Id == orderId);
if (status == 3 && order.Status == 2)
throw new InvalidOperationException("Shipped orders cannot be cancelled.");
order.Status = status;
await db.SaveChangesAsync();
}
private static void AddOrMerge(Order order, OrderLineDto line)
{
var existing = order.Details.FirstOrDefault(d => d.ProductId == line.ProductId);
if (existing != null)
existing.Qty += line.Qty;
else
order.Details.Add(new OrderDetail
{
ProductId = line.ProductId, ProductName = line.ProductName, Price = line.Price, Qty = line.Qty
});
if (order.Details.Any(d => d.Qty > 10))
throw new InvalidOperationException("Max 10 copies per book.");
}
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Bookstore.Ordering.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Web.Controllers;
[ApiController]
[Route("api/orders")]
public class OrdersController(OrderService service, OrderingDbContext db) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto) => Ok(await service.CreateOrder(dto));
[HttpPost("{id:int}/items")]
public async Task<IActionResult> AddItem(int id, OrderLineDto line)
{
await service.AddItem(id, line);
return NoContent();
}
[HttpPut("{id:int}/status")]
public async Task<IActionResult> UpdateStatus(int id, [FromBody] int status)
{
await service.UpdateStatus(id, status);
return NoContent();
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, Order order)
{
db.Orders.Update(order);
await db.SaveChangesAsync();
return NoContent();
}
}
Three questions. In how many different ways can an order's Status change? What happens if the mobile app sends JSON (JavaScript Object Notation) containing "total": 1 to PUT /api/orders/42? And if marketing says "buy 3, pay for 2 for back to school" tomorrow, where do you write the code?
The Starting Point and a Safety Net
The answers are uncomfortable. Status changes in three ways: UpdateStatus accepts any number, the PUT endpoint writes the entity as is, and anyone in the code base who writes new Order { ... } sets whatever value they like. "total": 1 is written to the database; no rule runs. The campaign code will most likely go into OrderService, in one of the three places where the total is calculated.
In this post we'll move this code to a rich model using the pieces from posts 07–12 of the series. There is one rule: every step is a separate commit, and at every commit the whole solution builds and every test passes. The first job is to pin down current behavior before changing it. Michael Feathers calls this a characterization test in Working Effectively with Legacy Code (2004): it tests not what the code should do but what it does today. I wrote eight tests; the last two deliberately pin "wrong" behavior:
[Fact]
public async Task Removing_the_last_item_leaves_an_empty_order()
{
int id = await NewService().CreateOrder(new(7, [Dune()]));
await NewService().RemoveItem(id, productId: 1);
using OrderingDbContext db = NewDb();
Assert.Equal(0m, (await db.Orders.SingleAsync(o => o.Id == id)).Total);
}
[Fact]
public async Task Any_status_number_is_accepted()
{
int id = await NewService().CreateOrder(new(7, [Dune()]));
await NewService().UpdateStatus(id, 99);
using OrderingDbContext db = NewDb();
Assert.Equal(99, (await db.Orders.SingleAsync(o => o.Id == id)).Status);
}
The tests run on real SQLite, because all the rules are in OrderService and the service depends on DbContext; at this stage there is no other way. Deleting the last order line leaving an empty order and 99 counting as a valid status are today's behavior. We'll change them, but in separate commits and on purpose. We used the same technique for readability in the Clean Code series post "Clean Code in Practice: Cleaning Up a Legacy OrderService Step by Step"; here the goal isn't readability but the model itself.
Step 1: Language
The first commit only changes names: OrderDetail → OrderItem, ProductId → BookId, Qty → Quantity, OrderDate → PlacedAt, int Status → OrderStatus. As we discussed in post 02 of the series, "product" is an ambiguous word in this context; Ordering's lines are books. On the database side, nothing changes:
using Bookstore.Ordering.Entities;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Data;
public class OrderingDbContext(DbContextOptions<OrderingDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Domain language in code, old names in the schema: no migration needed
modelBuilder.Entity<Order>(order =>
{
order.Property(o => o.PlacedAt).HasColumnName("OrderDate");
order.HasMany(o => o.Items).WithOne().HasForeignKey(i => i.OrderId);
});
modelBuilder.Entity<OrderItem>(item =>
{
item.ToTable("OrderDetails");
item.Property(i => i.BookId).HasColumnName("ProductId");
item.Property(i => i.Title).HasColumnName("ProductName");
item.Property(i => i.UnitPrice).HasColumnName("Price");
item.Property(i => i.Quantity).HasColumnName("Qty");
});
}
}
Domain language in the code, old names in the schema. Writing the enum values' numbers explicitly is how the meaning of the existing 0/1/2/3 rows in the database is preserved. The second commit does change behavior and says so in its message: UpdateStatus(int) goes away, replaced by CancelAsync, MarkPaidAsync, and MarkShippedAsync. The characterization test Any_status_number_is_accepted is deleted and Cancel_sets_the_cancelled_status written in its place. Not hiding a behavior change inside a refactoring commit keeps the question "when did this break?" answerable later.
Step 2: Value Objects and Strongly Typed Ids
decimal Total and string Currency become a single Money: the value object from post 07 of the series, in Bookstore.SharedKernel. The ids gain types too, but not GUIDs (Globally Unique Identifiers) as in 07:
namespace Bookstore.Ordering.Entities;
// The existing int identity columns are kept; type safety lives in code
public readonly record struct OrderId(int Value);
public readonly record struct BookId(int Value);
public readonly record struct CustomerId(int Value);
The reason is the database: the tables are full of int identity columns, and the goal of this step is type safety, not a schema migration. A call that mixes up OrderId and BookId no longer compiles. Money is mapped as a complex type in EF Core; Order.Total sits on the existing Total and Currency columns, while OrderItem.UnitPrice adds a new Currency column to the OrderDetails table. That's the first schema change we accept on purpose in this post, and the commit message says so. The mapping details of complex types are in posts 26 and 28 of the EF Core series.
Step 3: The Aggregate Boundary
This commit is the biggest one. The setters become private, the item list moves behind the _items backing field, and the rules in the service move to their owner:
public void AddItem(BookId bookId, string title, Money unitPrice, int quantity)
{
EnsureEditable();
OrderItem? existing = _items.FirstOrDefault(i => i.BookId == bookId);
int newQuantity = (existing?.Quantity ?? 0) + quantity;
if (newQuantity > MaxQuantityPerBook)
throw new InvalidOperationException($"Max {MaxQuantityPerBook} copies per book.");
if (existing is null)
_items.Add(new OrderItem(bookId, title, unitPrice, quantity));
else
existing.Increase(quantity);
RecalculateTotal();
}
public void RemoveItem(BookId bookId)
{
EnsureEditable();
_items.Remove(_items.First(i => i.BookId == bookId));
RecalculateTotal();
}
DbSet<OrderItem> is deleted; a line can now only be reached through Order, and items load together with the order via AutoInclude. OrderService takes a per-aggregate IOrderRepository instead of DbContext. This is the boundary we drew in posts 09 and 10 of the series. The controller's PUT /api/orders/{id} goes away in this commit too: an endpoint that writes the entity wholesale from outside means there is no aggregate boundary. The tests are still eight and all green; the behavior didn't change, only where it lives.
Step 4: Invariants and a Factory
Now that the rules are gathered in one place, the gaps became visible. The check preventing an empty order was in OrderService; the Order.Place factory brings it into the model. The error model is the decision from post 08 of the series: rule violations the caller can know in advance are a DomainException with a code.
public static Order Place(CustomerId customerId, IReadOnlyCollection<CartLine> lines, TimeProvider clock)
{
if (lines.Count == 0)
throw new DomainException("order.empty", "An order needs at least one item.");
Order order = new(customerId, clock.GetUtcNow().UtcDateTime);
foreach (CartLine line in lines)
order.Add(line);
return order;
}
public void AddItem(CartLine line)
{
EnsureStatus(OrderStatus.Placed, "order.not-editable");
Add(line);
}
public void RemoveItem(BookId bookId)
{
EnsureStatus(OrderStatus.Placed, "order.not-editable");
OrderItem item = _items.Find(i => i.BookId == bookId)
?? throw new DomainException("order.item-not-found", $"Book {bookId.Value} is not in the order.");
if (_items.Count == 1)
throw new DomainException("order.last-item", "The last item cannot be removed; cancel the order instead.");
_items.Remove(item);
RecalculateTotal();
}
Two behavior changes are in this commit and in its message: the last line can no longer be removed (if the customer wants nothing, that's a cancellation), and an unpaid order can't be shipped. The related characterization test was updated to the new behavior. The real gain, though, is the shape of the tests. Since the rules are in Order, we can exercise them without a database:
[Fact]
public void The_last_item_cannot_be_removed() =>
Assert.Equal("order.last-item", CodeOf(() => Place(Dune()).RemoveItem(new BookId(1))));
[Fact]
public void An_unpaid_order_cannot_be_shipped() =>
Assert.Equal("order.not-shippable", CodeOf(() => Place(Dune()).MarkShipped()));
Eight new domain tests were added, 16 in total. When I measured, each domain test stayed under 3 ms; the first of the characterization tests on SQLite took 383 ms on its own because of EF Core's model building.
Step 5: Domain Events and Who Generates the Identity?
I took the AggregateRoot, dispatcher, and interceptor from post 12 as they are; Order.Place raises an OrderPlaced and Order.Cancel an OrderCancelled. On the first attempt I looked at the OrderId inside the event:
Before SaveChanges: OrderId = 0
After SaveChanges: OrderId = 1
With an int identity column, the database generates the id during the INSERT. The event, however, is created at Place time, before the save, and carries 0; telling Inventory that "order number 0 was placed" is useless. Here the refactoring exposed a gap in the model: the order's identity should be known the moment the order exists. The fix is the split from the Solution Patterns series post "Exposing Ids: int Identity, GUID v7 and the Public Id Split": OrderId is now a public id the domain generates with Guid.CreateVersion7(), stored in a PublicId column with a unique index; the int Id stays in a shadow property as a technical key only. The API (Application Programming Interface) routes became {id:guid}. That's a separate commit; the events come in the commit after it, and an integration test verifies that OrderPlaced is dispatched exactly once, after the commit, with the right id.
Refactoring Toward Deeper Insight: Buy 3, Pay for 2
Evans titled the third part of his book "Refactoring Toward Deeper Insight": the model changes not only as code gets cleaned up but as the domain is better understood. To see that, we need a new rule. Marketing wants a back-to-school campaign: buy three books, the cheapest is free. The first attempt is code written in a hurry that still passes its tests:
// Back-to-school campaign: buy 3 books, the cheapest is free
public void ApplyThreeForTwo()
{
if (_items.Sum(i => i.Quantity) < 3)
return;
OrderItem cheapest = _items.MinBy(i => i.UnitPrice.Amount)!;
if (cheapest.Quantity == 1)
_items.Remove(cheapest);
else
cheapest.Decrease(1);
_items.Add(new OrderItem(cheapest.BookId, cheapest.Title, Money.Zero(cheapest.UnitPrice.Currency), 1));
RecalculateTotal();
}
Dune 30, Emma 20, The Hobbit 15 EUR: The Hobbit turns into a free line, the total is 50, the test is green. Committed. The next day, while going through examples with the person who designed the campaign, she asks: "What happens if the customer removes Dune before paying? Two books are left; the campaign condition is broken." I turned her sentence into a test as is:
Assert.Equal() Failure: Values differ
Expected: 35.00 EUR
Actual: 20.00 EUR
Failed! - Failed: 1, Passed: 20, Skipped: 0, Total: 21, Duration: 408 ms - Bookstore.Ordering.Tests.dll (net10.0)
The customer should pay 35 EUR and pays 20; The Hobbit is still sitting on the free line. Looking at the code reveals another bug: if one more Hobbit were added, Add would grow the first line with the same BookId, which is the free line. These can be fixed one by one, but the real problem is in the model: the discount is modeled as a line. In the domain expert's language the discount isn't a line but a result calculated from the basket as it currently stands; and what gets written onto the order isn't the discount but the campaign's terms. The model changed to match that sentence:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Entities;
// "Buy 3, pay for 2": for every Buy books, the cheapest (Buy - PayFor) are free
public sealed record BundlePromotion
{
public BundlePromotion(int buy, int payFor)
{
if (payFor < 1 || payFor >= buy)
throw new DomainException("promotion.invalid", $"Buy {buy} pay {payFor} is not a bundle.");
(Buy, PayFor) = (buy, payFor);
}
public int Buy { get; }
public int PayFor { get; }
public Money DiscountFor(IReadOnlyList<OrderItem> items)
{
int freeBooks = items.Sum(i => i.Quantity) / Buy * (Buy - PayFor);
return items
.SelectMany(i => Enumerable.Repeat(i.UnitPrice, i.Quantity))
.OrderBy(price => price.Amount)
.Take(freeBooks)
.Aggregate(Money.Zero(items[0].UnitPrice.Currency), (sum, price) => sum + price);
}
}
Order stores the campaign terms as they were at the time of ordering in its Promotion property; even if the campaign ends tomorrow, this order lives by its own terms. Discount isn't a field but a property calculated from the items on every call; Total is still kept in a column for reports, but only Order itself updates it. ApplyThreeForTwo and the free line were deleted entirely. The application service asks an IPromotionCalendar whether a campaign is active and hands the result to Order.Place; this is the same policy object as in post 11. In the mapping, Promotion sits on two nullable columns thanks to EF Core 10's optional complex type support:
modelBuilder.Entity<Order>(order =>
{
order.Property<int>("DbId").HasColumnName("Id");
order.HasKey("DbId");
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value)).HasColumnName("PublicId");
order.HasIndex(o => o.Id).IsUnique();
order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
order.Property(o => o.PlacedAt).HasColumnName("OrderDate");
order.ComplexProperty(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("Total");
money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
order.Ignore(o => o.Subtotal);
order.Ignore(o => o.Discount);
order.ComplexProperty(o => o.Promotion, promotion =>
{
promotion.Property(p => p.Buy).HasColumnName("PromotionBuy");
promotion.Property(p => p.PayFor).HasColumnName("PromotionPayFor");
});
order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
order.Navigation(o => o.Items).HasField("_items").AutoInclude();
});
[Fact]
public void Removing_a_book_recalculates_the_bundle()
{
Order order = PlaceInCampaign(Dune(), Emma(), Hobbit());
order.RemoveItem(new BookId(1)); // removing Dune breaks the campaign condition
Assert.Equal(new Money(35m, "EUR"), order.Total);
}
[Fact]
public void Adding_a_cheaper_book_moves_the_discount_to_it()
{
Order order = PlaceInCampaign(Dune(), Emma(), Hobbit());
order.AddItem(new CartLine(new BookId(4), "Middlemarch", new Money(10m, "EUR"), 1));
Assert.Equal(new Money(10m, "EUR"), order.Discount);
Assert.Equal(new Money(65m, "EUR"), order.Total);
}
Passed! - Failed: 0, Passed: 23, Skipped: 0, Total: 23, Duration: 400 ms - Bookstore.Ordering.Tests.dll (net10.0)
The expert's example now comes to 35 EUR. There's also a new test showing that when a cheaper book is added the discount moves to it; that's the second example that came up while talking with the expert. This time the code behaved correctly on its own, because the concept landed in the right place.
The Result: Every Commit Green, Where Is the Rule?
I verified the promise from the start of the post with a script: it checks out every commit in order, builds the solution, and runs the tests.
#!/bin/zsh
# Check out every commit in order, build the solution, run the tests
for c in $(git rev-list --reverse main); do
git checkout -q $c
errors=$(dotnet build 2>&1 | grep -cE " error ")
tests=$(dotnet test --no-build 2>&1 | grep -E "^(Passed|Failed)!" | sed -E 's/^(Passed|Failed)!.*Failed: +([0-9]+), Passed: +([0-9]+).*/\3 passed, \2 failed/')
printf "%s errors: %s tests: %-20s %s\n" $(git log -1 --format=%h) $errors "${tests:-none}" "$(git log -1 --format=%s | cut -c1-60)"
done
git checkout -q main
b24fbc1 errors: 0 tests: none Baseline: anemic CRUD ordering
f817b8d errors: 0 tests: 8 passed, 0 failed Pin current behavior with characterization tests
9c81aee errors: 0 tests: 8 passed, 0 failed Rename to the ubiquitous language (schema unchanged)
492266c errors: 0 tests: 8 passed, 0 failed Replace UpdateStatus(int) with Cancel/MarkPaid/MarkShipped (
d090ca7 errors: 0 tests: 8 passed, 0 failed Introduce Money and strongly typed ids (adds OrderDetails.Cu
4a79e6d errors: 0 tests: 8 passed, 0 failed Make Order the aggregate root: private setters, backing fiel
14c604c errors: 0 tests: 16 passed, 0 failed Protect invariants with a factory and guarded transitions (b
a7d13c9 errors: 0 tests: 16 passed, 0 failed Let the domain generate OrderId (Guid v7 PublicId column; in
9199e8b errors: 0 tests: 19 passed, 0 failed Raise OrderPlaced and OrderCancelled; dispatch them on SaveC
1f8926f errors: 0 tests: 20 passed, 0 failed Add the back-to-school 3-for-2 campaign (first attempt: a fr
621056a errors: 0 tests: 23 passed, 0 failed Model the bundle as an order-level promotion (refactoring to
1d2ad2d errors: 0 tests: 23 passed, 0 failed Keep OrderDetails.OrderId required with cascade delete (caug
There's also a bug the script didn't catch but the schema diff I compared at the last step did. While turning the foreign key into a shadow property in step 3, I forgot IsRequired(); the tests passed, but the schema EnsureCreated generated looked like this:
CREATE TABLE "OrderDetails" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_OrderDetails" PRIMARY KEY AUTOINCREMENT,
"ProductId" INTEGER NOT NULL,
"ProductName" TEXT NOT NULL,
"Qty" INTEGER NOT NULL,
"OrderId" INTEGER NULL,
"Price" TEXT NOT NULL,
"Currency" TEXT NOT NULL,
CONSTRAINT "FK_OrderDetails_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id")
);
OrderId had become nullable and ON DELETE CASCADE was gone. The last commit fixes it. The lesson: during a refactoring, the schema is an output too; compare GenerateCreateScript() or the migration diff step by step. The resulting metrics, between the starting commit (f817b8d) and the last commit (1d2ad2d):
| Measure | Start | End |
|---|---|---|
| .cs files under src | 7 | 18 |
| src lines (excluding blank and comment lines) | 134 | 422 |
OrderService lines | 60 | 34 |
Order + OrderItem lines | 21 | 108 |
| Tests (without a database) | 8 (0) | 23 (13) |
Bookstore.Ordering line / branch coverage | 89.2% / 87.5% | 97.7% / 90.5% |
The code tripled, and there's no point hiding it. Part of the growth is a new feature (the campaign), part is infrastructure (event dispatch, repository, shared kernel), and part is rules that were never written before: the last line, status transitions, currency. A rich model doesn't promise less code; it promises that the rule lives in one place and that place speaks the domain's language. Where the rules live:
| Rule | Before | After |
|---|---|---|
| At least one line | OrderService.CreateOrder | Order.Place |
| At most 10 per book | OrderService.AddOrMerge | Order.Add |
| Lines don't change after payment | Two copies in OrderService | Order.EnsureStatus |
| Status transitions | None; PUT writes any value | MarkPaid, MarkShipped, Cancel |
| Order total | Three separate calculations | Order.RecalculateTotal |
| Campaign discount | None | BundlePromotion.DiscountFor |
Order and the application service in their final form:
using Bookstore.BuildingBlocks;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Entities;
// The numbers must match the existing rows in the database
public enum OrderStatus { Placed = 0, Paid = 1, Shipped = 2, Cancelled = 3 }
public sealed record CartLine(BookId BookId, string Title, Money UnitPrice, int Quantity);
public class Order : AggregateRoot
{
public const int MaxQuantityPerBook = 10;
private readonly List<OrderItem> _items = [];
private Order() { } // EF Core
private Order(CustomerId customerId, DateTime placedAt)
{
Id = OrderId.New();
CustomerId = customerId;
PlacedAt = placedAt;
Status = OrderStatus.Placed;
}
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public DateTime PlacedAt { get; private set; }
public OrderStatus Status { get; private set; }
public BundlePromotion? Promotion { get; private set; }
public Money Subtotal => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.LineTotal);
public Money Discount => Promotion?.DiscountFor(_items) ?? Money.Zero(_items[0].UnitPrice.Currency);
public Money Total { get; private set; } = Money.Zero("EUR");
public IReadOnlyList<OrderItem> Items => _items;
public static Order Place(CustomerId customerId, IReadOnlyCollection<CartLine> lines,
BundlePromotion? promotion, TimeProvider clock)
{
if (lines.Count == 0)
throw new DomainException("order.empty", "An order needs at least one item.");
// The campaign terms are written onto the order as they were at the time of ordering
Order order = new(customerId, clock.GetUtcNow().UtcDateTime) { Promotion = promotion };
foreach (CartLine line in lines)
order.Add(line);
order.Raise(new OrderPlaced(order.Id, customerId, order.Total, clock.GetUtcNow()));
return order;
}
public void AddItem(CartLine line)
{
EnsureStatus(OrderStatus.Placed, "order.not-editable");
Add(line);
}
public void RemoveItem(BookId bookId)
{
EnsureStatus(OrderStatus.Placed, "order.not-editable");
OrderItem item = _items.Find(i => i.BookId == bookId)
?? throw new DomainException("order.item-not-found", $"Book {bookId.Value} is not in the order.");
if (_items.Count == 1)
throw new DomainException("order.last-item", "The last item cannot be removed; cancel the order instead.");
_items.Remove(item);
RecalculateTotal();
}
public void MarkPaid()
{
EnsureStatus(OrderStatus.Placed, "order.not-payable");
Status = OrderStatus.Paid;
}
public void MarkShipped()
{
EnsureStatus(OrderStatus.Paid, "order.not-shippable");
Status = OrderStatus.Shipped;
}
public void Cancel(CancellationReason reason, TimeProvider clock)
{
if (Status is not (OrderStatus.Placed or OrderStatus.Paid))
throw new DomainException("order.not-cancellable", $"Order is {Status} and cannot be cancelled.");
Status = OrderStatus.Cancelled;
Raise(new OrderCancelled(Id, reason, clock.GetUtcNow()));
}
private void Add(CartLine line)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(line.Quantity);
if (_items.Count > 0 && _items[0].UnitPrice.Currency != line.UnitPrice.Currency)
throw new DomainException("order.currency-mismatch",
$"Order is in {_items[0].UnitPrice.Currency}, '{line.Title}' is priced in {line.UnitPrice.Currency}.");
OrderItem? existing = _items.Find(i => i.BookId == line.BookId);
int newQuantity = (existing?.Quantity ?? 0) + line.Quantity;
if (newQuantity > MaxQuantityPerBook)
throw new DomainException("order.max-quantity",
$"At most {MaxQuantityPerBook} copies of '{line.Title}' per order, requested {newQuantity}.");
if (existing is null)
_items.Add(new OrderItem(line.BookId, line.Title, line.UnitPrice, line.Quantity));
else
existing.Increase(line.Quantity);
RecalculateTotal();
}
private void EnsureStatus(OrderStatus expected, string code)
{
if (Status != expected)
throw new DomainException(code, $"Order is {Status}, expected {expected}.");
}
// The discount is not a line: it is recalculated from the current items on every change
private void RecalculateTotal() => Total = Subtotal - Discount;
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Services;
// Application service: load, delegate to the domain, save
public class OrderService(IOrderRepository orders, IPromotionCalendar promotions, TimeProvider clock)
{
public async Task<Guid> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default)
{
BundlePromotion? promotion = promotions.ActiveBundle(clock.GetUtcNow());
Order order = Order.Place(new CustomerId(request.CustomerId), [.. request.Items.Select(ToCartLine)], promotion, clock);
orders.Add(order);
await orders.SaveChangesAsync(ct);
return order.Id.Value;
}
public Task AddItemAsync(Guid orderId, OrderItemRequest item, CancellationToken ct = default) =>
UpdateAsync(orderId, order => order.AddItem(ToCartLine(item)), ct);
public Task RemoveItemAsync(Guid orderId, int bookId, CancellationToken ct = default) =>
UpdateAsync(orderId, order => order.RemoveItem(new BookId(bookId)), ct);
public Task MarkPaidAsync(Guid orderId, CancellationToken ct = default) =>
UpdateAsync(orderId, order => order.MarkPaid(), ct);
public Task MarkShippedAsync(Guid orderId, CancellationToken ct = default) =>
UpdateAsync(orderId, order => order.MarkShipped(), ct);
public Task CancelAsync(Guid orderId, CancellationToken ct = default) =>
UpdateAsync(orderId, order => order.Cancel(CancellationReason.CustomerRequest, clock), ct);
private async Task UpdateAsync(Guid orderId, Action<Order> change, CancellationToken ct)
{
Order order = await orders.GetAsync(new OrderId(orderId), ct);
change(order);
await orders.SaveChangesAsync(ct);
}
private static CartLine ToCartLine(OrderItemRequest item) =>
new(new BookId(item.BookId), item.Title, new Money(item.UnitPrice, "EUR"), item.Quantity);
}
Common Pitfalls
The big bang
The decision "let's rewrite Ordering from scratch with DDD (Domain-Driven Design)" means months of two parallel systems and, at the end, forgotten corners of the old behavior. Each of the eleven commits above is deployable on its own; even if the work stops halfway, the code base is better than where it started.
Changing structure and behavior in the same commit
In Kent Beck's phrase for refactoring, there are two hats: you either change the structure or the behavior, not both at once. Each behavior change in this post (arbitrary status codes, the last line, shipping before payment) is in its own commit, and its message says "behavior change".
Deleting characterization tests too early
Once domain tests arrive, it's tempting to delete the tests that run on the database. But they also exercise the mapping, AutoInclude, and the repository; in the end, 8 of the 23 tests are still them. Update them to the new behavior instead of deleting them.
Not treating the schema as an output
The lost ON DELETE CASCADE broke no test. In production it would have been noticed when an order was deleted and its lines were left orphaned. Look at the schema diff at every step that touches the mapping.
Refactoring without the domain expert
The free-line model could have been fixed technically; what showed it was wrong was a single example from the person who designed the campaign. Deeper insight doesn't come from the keyboard; it comes as an example, a question, and a sentence turned into a test.
A Small Experiment
★☆☆ Run check-commits.sh on a branch of your own project. If you find a red commit, would you use git rebase or git revert to isolate it?
★★☆ The expert brings new examples: "A customer buying four books still gets one book free; one buying six gets two." Turn these two examples into tests for BundlePromotion. Do they pass? If not, is the model wrong or the code?
★★★ The campaign terms are written onto the order, but the OrderPlaced event knows nothing about the discount. What does the Payments context need when calculating a refund amount? Write down the trade-off between adding Discount to the event and having Payments query Order, looking at the context map in post 05 of the series.
We did an end-to-end transformation with the tactical pieces. In the last post of the series we'll step back and talk about twenty years of DDD: from Evans's book to EventStorming, from Vernon's aggregate rules to the Dynamic Consistency Boundary that questions the aggregate altogether; and the most common mistakes along the way.