Persisting Aggregates with EF Core 10: Backing Fields, Owned/Complex Types, and Concurrency
When you persist an aggregate with EF Core 10, the root and its children are loaded as a whole and written with a single SaveChanges, the concurrency token sits on the root, and every change to a child must bump the root's version; otherwise an item can be cancelled on an order that has already shipped. We show backing fields, private constructors, AutoInclude, one repository per aggregate and the JSON complex collection alternative with real SQL and output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
We are writing the Order aggregate to SQLite with Entity Framework Core (EF Core) 10. The root has a concurrency token called Version, and MarkShipped increments it. CancelItem, on the other hand, only marks the relevant OrderItem as cancelled. In the warehouse Linus ships the order; in the same second, in customer service, Grace cancels the Dune item at Ada's request. Both loaded the order before it shipped:
await using (OrderingDbContext warehouse = new(Db))
await using (OrderingDbContext support = new(Db, log: true))
{
Order shipping = (await new EfOrderRepository(warehouse).FindAsync(orderId))!;
Order cancelling = (await new EfOrderRepository(support).FindAsync(orderId))!;
shipping.MarkShipped();
await warehouse.SaveChangesAsync();
cancelling.CancelItem(dune);
try
{
await support.SaveChangesAsync();
Console.WriteLine("support: saved");
}
catch (DbUpdateConcurrencyException ex) { Console.WriteLine($"support: {ex.Message}"); }
}
The rule is clear: an item can't be cancelled on an order that has shipped. Does Grace's SaveChangesAsync throw a DbUpdateConcurrencyException, or does the order end up "shipped, but Dune cancelled, refund it"?
Not Bending the Domain Model for EF Core
Let's set up the model first. To the Order from the previous two posts we added only three things for persistence: private parameterless constructors that EF Core uses for materialization, private set on the properties, and the aggregate's version. The domain still doesn't reference EF Core:
namespace Bookstore.Ordering;
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
}
public readonly record struct OrderItemId(Guid Value)
{
public static OrderItemId New() => new(Guid.CreateVersion7());
}
public readonly record struct BookId(Guid Value)
{
public static BookId New() => new(Guid.CreateVersion7());
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.CreateVersion7());
}
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public sealed class DomainException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed record CartLine(BookId BookId, string Title, Money UnitPrice, int Quantity);
public sealed class OrderItem
{
private OrderItem() { }
internal OrderItem(BookId bookId, string title, Money unitPrice, int quantity)
{
Id = OrderItemId.New();
BookId = bookId;
Title = title;
UnitPrice = unitPrice;
Quantity = quantity;
}
public OrderItemId Id { get; private set; }
public BookId BookId { get; private set; }
public string Title { get; private set; } = null!;
public Money UnitPrice { get; private set; }
public int Quantity { get; private set; }
public bool IsCancelled { get; private set; }
internal void Cancel() => IsCancelled = true;
}
public sealed class Order
{
public const int MaxQuantityPerBook = 10;
private readonly List<OrderItem> _items = [];
private Order() { }
private Order(CustomerId customerId, Address shippingAddress, DateTimeOffset placedAt)
{
Id = OrderId.New();
CustomerId = customerId;
ShippingAddress = shippingAddress;
PlacedAt = placedAt;
Status = OrderStatus.Placed;
}
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public Address ShippingAddress { get; private set; } = null!;
public DateTimeOffset PlacedAt { get; private set; }
public OrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public static Order Place(CustomerId customerId, Address shippingAddress,
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, shippingAddress, clock.GetUtcNow());
foreach (CartLine line in lines)
{
if (line.Quantity is < 1 or > MaxQuantityPerBook)
throw new DomainException("order.max-quantity", $"1..{MaxQuantityPerBook} copies of '{line.Title}' per order.");
order._items.Add(new OrderItem(line.BookId, line.Title, line.UnitPrice, line.Quantity));
}
return order;
}
public void MarkShipped()
{
if (Status is not (OrderStatus.Placed or OrderStatus.Paid))
throw new DomainException("order.not-shippable", $"Order is {Status}.");
Status = OrderStatus.Shipped;
Version++;
}
public void CancelItem(BookId bookId)
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered or OrderStatus.Cancelled)
throw new DomainException("order.item-not-cancellable", $"Order is {Status}; items can no longer be cancelled.");
OrderItem item = _items.Find(i => i.BookId == bookId && !i.IsCancelled)
?? throw new DomainException("order.item-not-found", "No active item for this book.");
item.Cancel();
Version++;
}
}
The items sit in the private readonly List<OrderItem> _items backing field and are exposed as IReadOnlyList<OrderItem>. During materialization EF Core doesn't need a public setter or a public constructor; it can call the private constructor and write values into fields. We discussed how getter-only properties and constructor binding shape the domain in the Clean, Onion and Hexagonal Architecture series post "Persistence Ignorance and Infrastructure Adapters: EF Core, HttpClient, and External Services". Money, as in post 07, is read through its constructor; we'll see one consequence of that in the pitfalls section.
Mapping: IEntityTypeConfiguration and AutoInclude
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.Extensions.Logging;
namespace Bookstore.Ordering.Infrastructure;
public sealed class OrderingDbContext(string connectionString, bool log = false) : DbContext
{
internal DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite(connectionString);
if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderingDbContext).Assembly);
}
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> order)
{
order.HasKey(o => o.Id);
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
order.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
order.Property(o => o.Version).IsConcurrencyToken();
order.ComplexProperty(o => o.ShippingAddress);
order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
order.Navigation(o => o.Items).HasField("_items").AutoInclude();
}
}
internal sealed class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> item)
{
item.ToTable("OrderItems");
item.HasKey(i => i.Id);
item.Property(i => i.Id).HasConversion(id => id.Value, value => new OrderItemId(value));
item.Property(i => i.BookId).HasConversion(id => id.Value, value => new BookId(value));
item.Property(i => i.Title).HasMaxLength(200);
item.ComplexProperty(i => i.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("UnitPrice").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
}
}
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct = default);
void Add(Order order);
Task SaveChangesAsync(CancellationToken ct = default);
}
internal sealed class EfOrderRepository(OrderingDbContext db) : IOrderRepository
{
public Task<Order?> FindAsync(OrderId id, CancellationToken ct = default) =>
db.Orders.SingleOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order);
public Task SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
}
The decisions one by one. Ids go down to Guid through converters; EF Core doesn't generate values for a key with a converter, the domain assigns Ids itself with Guid.CreateVersion7(). ShippingAddress and UnitPrice are complex types as in post 07. OrderItem lives in its own table, but there is no DbSet<OrderItem> on the DbContext, and even Orders is internal; access from outside goes through the repository. The foreign key is a shadow property (OrderId) invisible to the domain, made mandatory with IsRequired(); without it, the first attempt created the column as one that accepts NULL. Navigation(...).AutoInclude() makes sure the items are loaded whenever an Order is queried. The resulting schema:
CREATE TABLE "Orders" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
"CustomerId" TEXT NOT NULL,
"PlacedAt" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"ShippingAddress_City" TEXT NOT NULL,
"ShippingAddress_Country" TEXT NOT NULL,
"ShippingAddress_PostalCode" TEXT NOT NULL,
"ShippingAddress_Street" TEXT NOT NULL
);
CREATE TABLE "OrderItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY,
"BookId" TEXT NOT NULL,
"Title" TEXT NOT NULL,
"Quantity" INTEGER NOT NULL,
"IsCancelled" INTEGER NOT NULL,
"OrderId" TEXT NOT NULL,
"UnitPrice" TEXT NOT NULL,
"Currency" TEXT NOT NULL,
CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);
The repository's FindAsync doesn't write an Include, yet the generated SQL (Structured Query Language) still brings the items:
SELECT "o1"."Id", "o1"."CustomerId", "o1"."PlacedAt", "o1"."Status", "o1"."Version", "o1"."ShippingAddress_City", "o1"."ShippingAddress_Country", "o1"."ShippingAddress_PostalCode", "o1"."ShippingAddress_Street", "o0"."Id", "o0"."BookId", "o0"."IsCancelled", "o0"."OrderId", "o0"."Quantity", "o0"."Title", "o0"."UnitPrice", "o0"."Currency"
FROM (
SELECT "o"."Id", "o"."CustomerId", "o"."PlacedAt", "o"."Status", "o"."Version", "o"."ShippingAddress_City", "o"."ShippingAddress_Country", "o"."ShippingAddress_PostalCode", "o"."ShippingAddress_Street"
FROM "Orders" AS "o"
WHERE "o"."Id" = @id
LIMIT 2
) AS "o1"
LEFT JOIN "OrderItems" AS "o0" ON "o1"."Id" = "o0"."OrderId"
ORDER BY "o1"."Id"
An aggregate is loaded whole or not at all; on a half-loaded Order, the "at least one item" or "10 copies per book" checks give wrong answers. AutoInclude doesn't leave this to the developer remembering to write Include. For reads that don't need the items, such as reporting, use IgnoreAutoIncludes() or a projection; the details of Include and split queries are in the EF Core series post "Loading Related Data in EF Core: Include, Explicit and Lazy Loading, Split Queries".
Why not OwnsMany? In the Clean series post "Building a Clean Architecture Solution in .NET 10" we mapped the items with OwnsMany, and that's a valid route too: an owned entity can't have a DbSet and is loaded automatically with its owner, so it gives you aggregate semantics for free. But an owned type is an entity with a hidden key behind the scenes, and the EF Core 10 documentation now recommends complex types for table splitting and JSON (JavaScript Object Notation); for a collection in a separate table that leaves two options: OwnsMany, or, as we did here, a regular entity with an explicit key plus AutoInclude. We picked the second because giving OrderItem its own OrderItemId makes operations that point at an item, such as returns and partial cancellation, easier.
One Repository per Aggregate
IOrderRepository has only three methods: find by id, add, save. There is no generic repository like IRepository<T>, no IQueryable leaking out, and no repository for OrderItem. The unit of a repository is the aggregate, not the table; Vernon's "one aggregate per transaction" rule maps to SaveChangesAsync here. Since SaveChanges already opens a single transaction, the aggregate's root and its items are written together or not at all. We had the repository vs plain DbContext debate in the ASP.NET Core Web API (Application Programming Interface) series post "Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate" and in the EF Core series post that also covers the read side of CQRS (Command Query Responsibility Segregation), "EF Core and Architecture: Repositories, the Read Side of CQRS and Moving to Dapper". From a DDD (Domain-Driven Design) point of view the answer is clear: a narrow repository that loads and saves the aggregate as a whole and does nothing else.
Why Does the Concurrency Token Sit on the Root?
Back to the opening question. In the first attempt CancelItem had no Version++ line, and Grace's save ended like this:
UPDATE "OrderItems" SET "IsCancelled" = @p0
WHERE "Id" = @p1
RETURNING 1;
support: saved
Shipped v1, cancelled items: 1
Both saves succeeded. The order has shipped and the Dune item is cancelled; the invariant was violated and data that will trigger a refund was created. The reason is visible in the SQL: Grace's change went only to the OrderItems table, and the Orders row was never touched. Because the concurrency token sits on the root and the root didn't change, EF Core found nothing to check. A rowversion on SQL Server wouldn't have saved us either; a rowversion changes only when its own row is updated.
If the aggregate is a single consistency boundary, any change inside it must change the root's version. In the Order above, the Version++ at the end of CancelItem does exactly that, and the same scenario now ends like this:
UPDATE "OrderItems" SET "IsCancelled" = @p0
WHERE "Id" = @p1
RETURNING 1;
UPDATE "Orders" SET "Version" = @p0
WHERE "Id" = @p1 AND "Version" = @p2
RETURNING 1;
support: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
Shipped v1, cancelled items: 0
The OrderItems update was in the same transaction and was rolled back; the last line shows zero cancelled items. Grace's application catches the exception and reloads the order, and this time CancelItem is rejected with order.item-not-cancellable. We covered the token itself, rowversion and the ways to resolve the exception in detail in the EF Core series post "Concurrency in EF Core: Tokens, rowversion and DbUpdateConcurrencyException".
Writing Version++ in every mutating method can be forgotten. The alternative is a SaveChangesInterceptor that finds the roots of changed child entities and bumps their versions; the interceptor mechanics are in the EF Core series post "EF Core Interceptors and Events: Audit Fields and Slow Query Logging". In a small aggregate an explicit Version++ is more readable and easier to test.
An Alternative: Keeping Items in JSON
EF Core 10's complex collections open another option: make OrderItem a value object and keep all items in a single JSON column in the Orders table. It also fits Vernon's advice to make an aggregate's parts values where possible. I tried the same scenario with this model:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public sealed record OrderItem
{
public required BookId BookId { get; init; }
public required string Title { get; init; }
public required Money UnitPrice { get; init; }
public required int Quantity { get; init; }
public bool IsCancelled { get; init; }
}
public sealed class Order
{
private List<OrderItem> _items = [];
private Order() { }
public Order(IEnumerable<OrderItem> items)
{
Id = OrderId.New();
_items = [.. items];
}
public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public void MarkShipped()
{
Status = OrderStatus.Shipped;
Version++;
}
public void CancelItem(BookId bookId)
{
int index = _items.FindIndex(i => i.BookId == bookId && !i.IsCancelled);
_items[index] = _items[index] with { IsCancelled = true };
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Bookstore.Ordering.Infrastructure;
public sealed class JsonOrderingDbContext(bool log = false) : DbContext
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=ordering10json.db");
if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity<Order>(order =>
{
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.Status).HasConversion<string>();
order.Property(o => o.Version).IsConcurrencyToken();
order.Ignore(o => o.Items);
order.ComplexCollection<List<OrderItem>, OrderItem>("_items", items =>
{
items.ToJson("Items");
items.Property(i => i.BookId).HasConversion(id => id.Value, value => new BookId(value));
items.ComplexProperty(i => i.UnitPrice, money =>
{
money.Property(m => m.Amount);
money.Property(m => m.Currency);
});
});
});
}
Here CancelItem deliberately does not do Version++. The result:
CREATE TABLE "Orders" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
"Status" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"Items" TEXT NOT NULL
);
UPDATE "Orders" SET "Items" = @p0
WHERE "Id" = @p1 AND "Version" = @p2
RETURNING 1;
support: DbUpdateConcurrencyException
Because the items live in the root's row, every item change is an Orders update and the token check always happens. Grace's save was rejected because the warehouse bumped the version. Still, in a scenario where both sides change only items, the token doesn't change; either every method bumps the version or you use SQL Server's automatically changing rowversion. In this model rowversion really does the job, because every change now updates the same row.
There are two real points of friction. First, a complex collection's CLR (Common Language Runtime) type must implement IList<T>; when I mapped the IReadOnlyList<OrderItem> property directly, the model failed with:
The complex collection 'Order'.'Items' is of type 'IReadOnlyList<OrderItem>' which does not implement 'IList<OrderItem>'.
To keep encapsulation I Ignored the property and mapped the _items field by name. The cost is writing EF.Property<List<OrderItem>>(o, "_items") instead of o.Items in LINQ (Language Integrated Query) queries; EF Core translated that into an EXISTS over json_each on SQLite and returned the right result. Second, book-level reports (how many copies of which book sold this month?) are now JSON queries instead of a JOIN. Because Ordering's item table has heavy reporting needs in Bookstore, we choose the separate table; for an aggregate whose parts are rarely queried, JSON means far less ceremony.
Common Pitfalls
Domain-generated Guid keys and "0 rows affected". By default EF Core marks a plain Guid key without a converter as "generated by the database". If the domain assigned the Id in the constructor, the filled Id of a new child added to a loaded aggregate tells EF "this already exists":
namespace Bookstore.Shipping;
public sealed class Shipment
{
private readonly List<Parcel> _parcels = [];
public Guid Id { get; private set; } = Guid.CreateVersion7();
public IReadOnlyList<Parcel> Parcels => _parcels;
public void AddParcel(decimal weightKg) => _parcels.Add(new Parcel(weightKg));
}
public sealed class Parcel(decimal weightKg)
{
public Guid Id { get; private set; } = Guid.CreateVersion7();
public decimal WeightKg { get; private set; } = weightKg;
}
public sealed class ShippingDbContext(bool keysFromDomain) : DbContext
{
public DbSet<Shipment> Shipments => Set<Shipment>();
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlite("Data Source=shipping10.db");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Shipment>().HasMany(s => s.Parcels).WithOne().HasForeignKey("ShipmentId");
if (keysFromDomain)
modelBuilder.Entity<Parcel>().Property(p => p.Id).ValueGeneratedNever();
}
}
Parcel {Id: 01a1b80d-c319-7993-8136-c07d2924b918} Unchanged FK {ShipmentId: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6}
Parcel {Id: 01a1b80d-c3a7-728c-b0bf-b0f8c40989fc} Modified FK {ShipmentId: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6}
Shipment {Id: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6} Unchanged
DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may...
The new parcel was marked Modified instead of Added, and an UPDATE went to a row that doesn't exist. The same program with ValueGeneratedNever():
Parcel {Id: 01a1b80d-c6ff-7aef-965e-9b5d382d8821} Unchanged FK {ShipmentId: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4}
Parcel {Id: 01a1b80d-c791-72f0-b2c3-fc1cc80e35f2} Added FK {ShipmentId: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4}
Shipment {Id: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4} Unchanged
saved
We didn't hit this with Order, because EF Core already turns value generation off for strongly typed ids with converters.
Changing a child through its own DbSet or Set<T>(). Even if you don't expose a DbSet, Set<OrderItem>() is in everyone's hands:
await using (OrderingDbContext db = new(Db))
{
// Bypassing the aggregate and updating the item table directly
await db.Set<OrderItem>().Where(i => i.BookId == dune).ExecuteUpdateAsync(s => s.SetProperty(i => i.Quantity, 50));
Order order = (await new EfOrderRepository(db).FindAsync(orderId))!;
Console.WriteLine($"Dune x{order.Items.First(i => i.BookId == dune).Quantity}");
}
await using (OrderingDbContext db = new(Db))
{
// An old import script wrote an unsupported currency
await db.Database.ExecuteSqlAsync($"UPDATE OrderItems SET Currency = 'USD' WHERE Title = 'Emma'");
try { await new EfOrderRepository(db).FindAsync(orderId); }
catch (ArgumentException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
}
Dune x50
ArgumentException: Unsupported currency 'USD'. (Parameter 'currency')
The first block broke the 10-copies-per-book rule without ever going through the aggregate, and loading didn't notice; Quantity is a plain int. In the second block, because Money's constructor also runs on read, the corrupt data blocked the order from loading at all. That's a double-edged result: invalid state can't enter the domain, but a single bad row left by an old import makes the order impossible to open. Before tightening rules, clean up existing data with a migration or a script; use ExecuteUpdate and raw SQL on aggregate tables only for this kind of deliberate maintenance.
Lazy loading and disconnected graphs. Lazy-loading proxies silently load navigations to other aggregates, as we discussed in 09; keep them off for aggregates. Attaching a DTO (Data Transfer Object) graph from the API with db.Update(order) marks every item Modified and bypasses the domain methods. Always load the aggregate, make the change through a domain method, then save; the details of the disconnected scenario are in the EF Core series post "Saving Data with EF Core: SaveChanges, Related Graphs and Disconnected Entities".
A Small Experiment
★☆☆ Remove the Version++ from CancelItem and run the scenario yourself. Then repeat the test after removing the Version++ from MarkShipped; which side wins this time?
★★☆ Write a SaveChangesInterceptor that, for every OrderItem in the Modified or Added state in the ChangeTracker, finds the root through the shadow OrderId and bumps its Version. Could you remove the Version++ lines from the domain?
★★★ Try the same aggregate on SQL Server with rowversion, in both the separate-table and the JSON model. In which model is rowversion enough on its own, and in which isn't it? Compare the result with the throughput row of the Aggregate Design Canvas.
Our aggregates are now consistent both in code and in the database. But some behavior belongs neither to Order nor to StockItem: calculating the shipping fee, coordinating an order between payment and stock. In the next post we look at where that behavior goes: domain services, application services and Fowler's critique of the anemic domain model.