The Always-Valid Domain Model: Protecting Invariants with Constructors, Factories, and Guards
An always-valid domain model doesn't check invariants before saving, it makes violating them impossible: a private constructor, a static factory and guard clauses close every path to an invalid state. With Bookstore's Order we work through the at-least-one-item, 10-copies-per-book, single-currency and one-coupon rules, the limits of required/init and C# 14's field, the domain exception vs Result split, and the difference between input validation and invariants, with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
This is what an order looks like in the Bookstore.Business project of Bookstore's layered monolith. The rules exist, all inside Validate():
namespace Bookstore.Business;
public class LegacyOrder
{
public int Id { get; set; }
public string CustomerEmail { get; set; } = "";
public string Status { get; set; } = "New";
public List<LegacyOrderItem> Items { get; set; } = [];
public bool Validate() =>
Items.Count > 0
&& Items.All(i => i.Quantity is > 0 and <= 10)
&& Items.Select(i => i.Currency).Distinct().Count() == 1;
}
public class LegacyOrderItem
{
public string Title { get; set; } = "";
public decimal UnitPrice { get; set; }
public string Currency { get; set; } = "";
public int Quantity { get; set; }
}
An admin screen, a CSV (Comma-Separated Values) import and a nightly job all touch this class. Does the code below compile, and does it run? If it runs, which total is printed and what does Validate() return? The most important question: if SaveChanges were called after these lines, what would stop this order from reaching the database?
using System.Globalization;
using Bookstore.Business;
LegacyOrder order = new() { CustomerEmail = "ada@example.com" };
order.Items.Add(new LegacyOrderItem { Title = "Dune", UnitPrice = 12.50m, Currency = "EUR", Quantity = 2 });
order.Items.Add(new LegacyOrderItem { Title = "Emma", UnitPrice = 310m, Currency = "TRY", Quantity = 1 });
order.Status = "Shiped";
order.Items[0].Quantity = -3;
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$"{order.Items.Count} items, status '{order.Status}', total {order.Items.Sum(i => i.UnitPrice * i.Quantity)}"));
Console.WriteLine($"Validate(): {order.Validate()}");
What Is an Invariant?
An invariant is a rule that must hold throughout an object's life, before and after every public operation. The concept comes from the class invariant in Bertrand Meyer's Design by Contract approach (Object-Oriented Software Construction, 1988): the constructor establishes the invariant, every public method preserves it. Eric Evans carries the same idea to the aggregate level: when a change completes, the aggregate's rules must be consistent.
Bookstore's order rules, from the series' glossary:
- An order has at least one item (
OrderItem). - An order holds at most 10 copies of the same book (
MaxQuantityPerBook = 10). - All items in an order are in the same currency; EUR and TRY (Turkish lira) don't mix.
- A cancelled order doesn't change; a shipped order can't be cancelled.
- A coupon is applied to an order only once.
An always-valid domain model means these rules aren't "checked before saving", they are "impossible to violate". Vladimir Khorikov sums the approach up in his "Always-Valid Domain Model" article as domain classes guarding themselves against ever becoming invalid. .NET itself uses this model every day:
try { DateOnly date = new(2026, 2, 30); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }
CustomerContact contact = new() { Email = " grace@example.com " };
Console.WriteLine($"[{contact.Email}]");
try { _ = new CustomerContact { Email = "grace" }; }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }
public sealed class CustomerContact
{
public required string Email
{
get;
init => field = value.Contains('@')
? value.Trim()
: throw new ArgumentException($"'{value}' is not an email address.", nameof(Email));
}
}
Year, Month, and Day parameters describe an un-representable DateTime.
[grace@example.com]
'grace' is not an email address. (Parameter 'Email')
The first line comes from DateOnly: you can't create a DateOnly for 30 February, so you never need to ask IsValid() afterwards. We'll come back to the rest of the code below.
Private Constructor and Static Factory
Back to the opening code. The output:
2 items, status 'Shiped', total 272.50
Validate(): False
It compiled and ran. The status name is misspelled ("Shiped"), the quantity is minus three, EUR and TRY were added together, and a meaningless total of 272.50 came out. Validate() returned False, but nobody has to call it. If whoever wrote the nightly job forgets to call it, nothing stops the save. The rules live in a method; the state is in everyone's hands.
In the always-valid Order, every door leading to an invalid state is shut. The constructor is private, an order is born only through Order.Place, items are added only from inside Order, and every method that changes state checks its own rule:
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 enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }
public sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed class OrderItem
{
internal OrderItem(BookId bookId, string title, Money unitPrice, int quantity)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
BookId = bookId;
Title = title;
UnitPrice = unitPrice;
Quantity = quantity;
}
public BookId BookId { get; }
public string Title { get; }
public Money UnitPrice { get; }
public int Quantity { get; private set; }
public Money LineTotal => UnitPrice * Quantity;
internal void Increase(int quantity) => Quantity += quantity;
}
public sealed record CartLine(BookId BookId, string Title, Money UnitPrice, int Quantity);
public sealed class Order
{
public const int MaxQuantityPerBook = 10;
private readonly List<OrderItem> _items = [];
private Order(CustomerId customerId, Address shippingAddress, DateTimeOffset placedAt)
{
Id = OrderId.New();
CustomerId = customerId;
ShippingAddress = shippingAddress;
PlacedAt = placedAt;
Status = OrderStatus.Placed;
}
public OrderId Id { get; }
public CustomerId CustomerId { get; }
public Address ShippingAddress { get; private set; }
public DateTimeOffset PlacedAt { get; }
public OrderStatus Status { get; private set; }
public string? CouponCode { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public Money Total => _items.Select(i => i.LineTotal).Aggregate((sum, next) => sum + next);
public static Order Place(CustomerId customerId, Address shippingAddress,
IReadOnlyCollection<CartLine> lines, TimeProvider clock)
{
ArgumentNullException.ThrowIfNull(shippingAddress);
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)
order.AddItem(line);
return order;
}
private void AddItem(CartLine line)
{
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);
}
public void ApplyCoupon(string couponCode)
{
ArgumentException.ThrowIfNullOrWhiteSpace(couponCode);
EnsureNotCancelled();
if (CouponCode is not null)
throw new DomainException("order.coupon-already-applied", $"Coupon {CouponCode} is already applied.");
CouponCode = couponCode;
}
public void Cancel(CancellationReason reason)
{
EnsureNotCancelled();
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
throw new DomainException("order.already-shipped", $"Order {Id.Value} is {Status} and cannot be cancelled.");
Status = OrderStatus.Cancelled;
}
private void EnsureNotCancelled()
{
if (Status == OrderStatus.Cancelled)
throw new DomainException("order.cancelled", $"Order {Id.Value} is cancelled.");
}
}
A few design decisions. OrderItem's constructor is internal; nobody outside can create an item and add it to the list, and the list is exposed as an IReadOnlyList anyway. Place takes the lines coming from the cart (CartLine); if the same book arrives on two lines it merges them and checks the 10-copy rule against the merged quantity. Time comes from TimeProvider, in the BCL (Base Class Library) since .NET 8, instead of DateTime.UtcNow, so a test can supply a fixed clock. The Aggregate in Total would throw on an empty list; thanks to the "at least one item" invariant, that line never blows up. Invariants simplify other code too.
Guard Clauses and Fail Fast
using Bookstore.Ordering;
using Bookstore.SharedKernel;
TimeProvider clock = TimeProvider.System;
Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
BookId dune = BookId.New();
BookId emma = BookId.New();
Order order = Order.Place(CustomerId.New(), home,
[new(dune, "Dune", new Money(12.50m, "EUR"), 2), new(dune, "Dune", new Money(12.50m, "EUR"), 1)], clock);
Console.WriteLine($"{order.Status} {order.Items.Count} item(s), Dune x{order.Items[0].Quantity}, total {order.Total}");
Try(() => Order.Place(CustomerId.New(), home, [], clock));
Try(() => Order.Place(CustomerId.New(), home,
[new(dune, "Dune", new Money(12.50m, "EUR"), 6), new(dune, "Dune", new Money(12.50m, "EUR"), 5)], clock));
Try(() => Order.Place(CustomerId.New(), home,
[new(dune, "Dune", new Money(12.50m, "EUR"), 1), new(emma, "Emma", new Money(310m, "TRY"), 1)], clock));
Try(() => Order.Place(CustomerId.New(), home, [new(dune, "Dune", new Money(12.50m, "EUR"), 0)], clock));
order.ApplyCoupon("BACK2SCHOOL");
Try(() => order.ApplyCoupon("NEWYEAR"));
order.Cancel(CancellationReason.CustomerRequest);
Try(() => order.Cancel(CancellationReason.CustomerRequest));
static void Try(Action action)
{
try { action(); Console.WriteLine("OK"); }
catch (DomainException ex) { Console.WriteLine($"{ex.Code}: {ex.Message}"); }
catch (ArgumentException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
}
Placed 1 item(s), Dune x3, total 37.50 EUR
order.empty: An order needs at least one item.
order.max-quantity: At most 10 copies of 'Dune' per order, requested 11.
order.currency-mismatch: Order is in EUR, 'Emma' is priced in TRY.
ArgumentOutOfRangeException: quantity ('0') must be a non-negative and non-zero value. (Parameter 'quantity')
Actual value was 0.
order.coupon-already-applied: Coupon BACK2SCHOOL is already applied.
order.cancelled: Order 01a1b807-ebac-7523-bf58-faf1f7ab3a09 is cancelled.
The first order merged the Dune copies arriving on two separate lines into a single item with three copies. The next four attempts hit four different rules, and none of them left a half-built Order behind; when the factory throws, there is no order whose reference anyone holds. The coupon couldn't be applied twice, and the cancelled order couldn't be cancelled again.
Notice that there are two kinds of checks. ArgumentException.ThrowIfNullOrWhiteSpace and ArgumentOutOfRangeException.ThrowIfNegativeOrZero are BCL throw helpers introduced in .NET 8; they catch a programming error where a method is called with a bad argument. DomainException is a business rule violation and carries a Code that speaks the domain's language (order.max-quantity). We discussed guard clauses in the Clean Code series post "Functions in Clean Code: Small Methods, One Level of Abstraction and Guard Clauses" and the fail fast principle in the SOLID (the initials of Robert C. Martin's five design principles) series post "Lesser-Known Principles"; here the two together guard the invariant.
required, init and C# 14's field Keyword
At first glance C# 11's required looks made for always-valid models. If you forget to set a property, the compiler stops you:
CustomerContact contact = new();
Console.WriteLine(contact.Email);
public sealed class CustomerContact
{
public required string Email { get; init; }
}
Program.cs(1,27): error CS9035: Required member 'CustomerContact.Email' must be set in the object initializer or attribute constructor.
But required only guarantees that "a value was given", not that "a correct value was given". Email = "grace" keeps the compiler happy. The shortest way to put validation inside the init accessor is C# 14's field keyword: the CustomerContact above validates the email both in the object initializer and in a with expression, and rejects "grace", as you saw in the output. Still, rules that span several fields (e.g. "if no billing address is given, a shipping address is mandatory") don't fit into a single property's accessor. Don't forget that an object initializer sets properties one by one, and in between the object is half-built. For entities whose rules span more than one field, the constructor or the factory is still the right place; required and init are more for DTOs (Data Transfer Object) and small value objects.
Error Model: Domain Exception or Result?
When a rule is violated, how should the domain say so? There are two options.
Exceptions. The domain fails fast, the invariant is never violated, and the caller doesn't have to check a result on every line. The cost is that the possibility of failure doesn't show up in the method signature; the caller learns which rules can blow up by reading the code.
Result or the Try pattern. Failure shows up in the return type. We took this route in the Clean, Onion and Hexagonal Architecture series post "The Application Layer and Use Cases: Handlers, Validation, and Transaction Boundaries Without MediatR": the domain's TryReserve returned a bool, the handler turned it into an Error, and Result lived only in Application. The cost is having to check the result on every call, and code that silently carries on when the check is forgotten.
In this series we split the two by the nature of the rule, without contradicting the Clean series' decision. Rules the caller can know in advance (no coupon on a cancelled order, no cancelling an order that has shipped) throw a DomainException; the UI (user interface) shouldn't show that button in the first place, so getting here is a bug or a race condition. Outcomes that can't be known in advance and are routine (no copies left on the shelf) return a bool through the Try pattern; we'll see this in StockItem.TryReserve in post 09. The domain never knows about a Result type. The Application layer catches DomainException in one place and turns it, based on its Code, into an Error, and from there into a 409 or 422 in HTTP (Hypertext Transfer Protocol).
Input Validation vs Invariants
If we already check "quantity must be between 1 and 10" in the API (Application Programming Interface) request, isn't the domain check a duplicate? No, because the two answer different questions:
| Question | Input validation | Invariant |
|---|---|---|
| Where? | At the system's edge: endpoint, request DTO | Inside the domain model: constructor, factory, method |
| What does it check? | The shape of the request: is the field filled, is the format right | The business rule: can this change be made in this state |
| How does it report errors? | All errors at once, 400 Bad Request | Stops at the first violation, 409 or 422 |
| Example | Email format, empty quantity field | 11 copies of the same book, a coupon on a cancelled order |
Input validation gives the user a good form experience: it shows five errors at once. The invariant is the last line of defence; it works even when the order arrives from a CSV import or a message queue instead of HTTP. It's normal for the same rule to appear in two places; the domain is the source of the rule, the check at the edge is for user experience.
Common Pitfalls
Public setters and a forgotten Validate(). The problem with the opening code isn't missing rules, it's that the rules are optional. A rule becomes an invariant only when every path to the state is closed.
Making the parameterless constructor public for a serializer or an ORM (Object-Relational Mapper). The constructor Entity Framework Core (EF Core) needs can be private; we'll use that in post 10. A public Order() constructor is the shortest way around every check in the factory.
Mistaking rules that need a database query for invariants. "There can't be a second customer with the same email" isn't a rule of a single Customer, it's a rule of the set of all customers; one aggregate can't protect it on its own. Its place is a unique index and a check in the Application layer. Which rule is protected at which boundary is the topic of the next post.
Loosening an invariant for a draft state. It's tempting to drop Order's "at least one item" rule because "the order may be empty for a while as the customer fills the cart". The right answer is usually a separate concept: the glossary's Cart can be empty, an Order can't. If you need a half-valid state, there is probably a concept you haven't named yet.
Using exceptions for flow control. Throwing and catching exceptions for outcomes users run into every day (out of stock, an expired coupon) is costly for both performance and readability. Use the Try split above for those outcomes.
When Not to Use It
An always-valid model is valuable where there are rules. Writing a factory, a private constructor and a DomainException for an admin panel's "category name and description" form is ceremony around data without rules; a simple DTO and input validation are enough there. Using the split from post 03 of this series, CRUD (Create, Read, Update, Delete) code in generic and supporting subdomains doesn't need this investment. In Bookstore, the always-valid model belongs in the Ordering context, and especially in Order.
A Small Experiment
★☆☆ Add ChangeShippingAddress(Address address) to Order: the address can't change once the order has shipped. Which exception code did you pick, and why?
★★☆ Rewrite Order.Place as a version that collects all violations in a list instead of throwing DomainException. When does that give a better user experience, and when does it weaken the invariant?
★★★ Write an OrderFactory that refuses to create a new order if the same customer has placed more than 50 orders in the last 24 hours (suspected fraud). Is this rule an invariant or a policy? Where does it get its information from, and should Order itself need that information?
So far we have protected rules inside a single Order. What about stock? Is "don't sell a book that isn't on the shelf" a rule of Order, of StockItem, or of both together? In the next post we move on to aggregate design: consistency boundaries, small aggregates, and referencing other aggregates only by id.