# The Always-Valid Domain Model

> An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/always-valid-domain-model

An **always-valid domain model** enforces its business invariants when objects are created and whenever their state changes.
In C#, private constructors, validated value objects, and guarded methods prevent callers from leaving objects inconsistent.
This reduces repeated rule checks, while null handling, database constraints, and concurrency protection remain necessary at system boundaries.

## The Model That Can't Defend Itself

This is the starting point in most codebases:

```csharp
public class Order
{
    public Guid Id { get; set; }
    public string CustomerEmail { get; set; }
    public List<OrderLine> Lines { get; set; } = [];
    public OrderStatus Status { get; set; }
}
```

Every property is settable by anyone, at any time, to anything.
The consequences compound:

- An `Order` with a null email can exist, so everything downstream checks for it.
- Any service can add lines to a shipped order, so each caller must remember the same rule.
- Tests can accidentally construct states that the intended business workflow would never produce.

The validation for this model usually lives somewhere else: in a service, in a validator, in the controller.
That's the core problem.
**The rules live apart from the data they protect**, and nothing stops a new code path from skipping them.

## Step 1: Make Invalid Construction Impossible

Flip the defaults: private setters, a private constructor, and one static factory that enforces the rules.
The examples use the application-defined `Result` and `Error` types from the linked Result-pattern article, plus domain-specific IDs, lines, and error definitions.
`Result<T>` supports converting a successful value to a result:

```csharp
public class Order
{
    private readonly List<OrderLine> _lines = [];

    private Order(Guid id, Email customerEmail)
    {
        Id = id;
        CustomerEmail = customerEmail;
        Status = OrderStatus.Pending;
    }

    public Guid Id { get; private set; }
    public Email CustomerEmail { get; private set; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public static Result<Order> Create(Email customerEmail)
    {
        if (customerEmail is null)
        {
            return Result.Failure<Order>(OrderErrors.MissingEmail);
        }

        return new Order(Guid.NewGuid(), customerEmail);
    }
}
```

Three deliberate choices here:

- **The constructor is private.** The factory is the only door, so the rules in it are unskippable.
- **The collection is encapsulated.** Callers see `IReadOnlyCollection`, so nobody can `order.Lines.Add(...)` around the rules. I covered why this matters in [**encapsulating collections in domain entities**](https://milanjovanovic.tech/blog/encapsulating-collections-domain-entities).
- **Creation returns a `Result`** instead of throwing, because "the input broke a business rule" is an expected outcome, not an exceptional one. That's the [**Result pattern**](https://milanjovanovic.tech/blog/functional-error-handling-in-dotnet-with-the-result-pattern) doing what it does best.

## Step 2: Push Validation Into Value Objects

Notice the email parameter isn't a `string`.
That's not decoration; it's where the biggest wins come from.

```csharp
public sealed record Email
{
    private Email(string value) => Value = value;

    public string Value { get; }

    public static Result<Email> Create(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return Result.Failure<Email>(EmailErrors.Empty);
        }

        var trimmed = value.Trim();

        if (trimmed.Length > 254 ||
            !System.Net.Mail.MailAddress.TryCreate(trimmed, out var address) ||
            address.Address != trimmed)
        {
            return Result.Failure<Email>(EmailErrors.Invalid);
        }

        return new Email(trimmed);
    }
}
```

The factory checks a bounded email-address policy using [`MailAddress.TryCreate`](https://learn.microsoft.com/en-us/dotnet/api/system.net.mail.mailaddress.trycreate).
It preserves the address's casing and rejects display-name forms; parsing does not prove deliverability or ownership.
Use a verification flow when ownership matters.

A non-null `Email` created through this factory has passed those checks.
C# nullable annotations help callers but do not prevent nulls at runtime, and persistence can bypass the factory.
That is the useful guarantee of [**value objects**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals): local constraints are centralized behind a controlled API.

## Step 3: Guard Every State Transition

Creation is half the story.
An always-valid model must also refuse to **become** invalid, which means every mutation goes through a method that checks the rules first:

Add these methods to `Order.cs`; `OrderLine` must also reject an invalid product or price and expose no public mutation methods:

![An order lifecycle state machine where every transition is guarded: creation lands in Pending, lines can be added only while Pending, paying moves to Paid, and shipping is allowed only from Paid](https://milanjovanovic.tech/blogs/articles/always-valid-domain-model/order-lifecycle.png)


```csharp
public Result AddLine(ProductId productId, Money price, int quantity)
{
    if (Status != OrderStatus.Pending)
    {
        return Result.Failure(OrderErrors.NotModifiable(Status));
    }

    if (quantity <= 0)
    {
        return Result.Failure(OrderErrors.InvalidQuantity);
    }

    _lines.Add(new OrderLine(Id, productId, price, quantity));

    return Result.Success();
}

public Result Ship()
{
    if (Status != OrderStatus.Paid)
    {
        return Result.Failure(OrderErrors.CannotShip(Status));
    }

    if (_lines.Count == 0)
    {
        return Result.Failure(OrderErrors.EmptyOrder);
    }

    Status = OrderStatus.Shipped;

    return Result.Success();
}

public Result MarkPaid()
{
    if (Status != OrderStatus.Pending)
    {
        return Result.Failure(new Error(
            "Order.CannotPay", "Only pending orders can be paid."));
    }

    if (_lines.Count == 0)
    {
        return Result.Failure(OrderErrors.EmptyOrder);
    }

    Status = OrderStatus.Paid;
    return Result.Success();
}
```

Call `MarkPaid` only after the application has verified a successful payment.
It records that domain transition; it does not contact a payment provider.

The rule "you can't ship an unpaid order" now exists in exactly one place, and it cannot be bypassed, because `Status` has no public setter.
Contrast that with the anemic version, where the rule lived wherever each developer remembered to put it.
This transition, from property-bag to behavior, is the same journey I walked through in [**refactoring from an anemic to a rich domain model**](https://milanjovanovic.tech/blog/refactoring-from-an-anemic-domain-model-to-a-rich-domain-model).

## How Do Validation and Invariants Differ?

A common objection: "so I delete my FluentValidation validators?"
No.
You're conflating two layers that both earn their keep:

- **Boundary validation** ([**FluentValidation**](https://docs.fluentvalidation.net/) on your request DTOs) rejects malformed input early and produces field-level errors. The application maps these errors to HTTP or another transport.
- **Invariants** are what the domain guarantees at all times: an order has at least one line before shipping, a balance never goes negative without an overdraft. A violated invariant isn't a 400 response; it's a state that must not exist.

The boundary can duplicate a few domain rules (email format, required fields), and that duplication is fine.
What matters is direction: the domain **never depends on** the boundary having run.
If a message consumer, a background job, or a future teammate calls `Order.Create` directly, the rules still hold.
For the deeper distinction, see [**what invariants are and why the domain model enforces them**](https://milanjovanovic.tech/blog/what-invariants-are-and-why-a-domain-model-is-the-best-place-to-enforce-them).

## The Practical Frictions (and Their Answers)

**EF Core needs to materialize entities.**
[**EF Core supports private constructors**](https://learn.microsoft.com/en-us/ef/core/modeling/constructors), including parameterless construction followed by writing mapped properties or fields.
Add a private parameterless constructor and configure the `Email` conversion and collection backing field in infrastructure.
Database rows can come from imports, migrations, or older rules, so bypassing a factory does not prove that persisted data satisfies today's invariants.
Use database constraints and explicit migration or rehydration policies for that boundary.

**Exceptions or Results?**
My rule: `Result` for expected rule violations (the caller can do something about them), exceptions for programmer errors (a guard clause that should be unreachable).
A `Ship()` on an unpaid order coming from a user action is a `Result.Failure`.
Treat a negative quantity as a result when it is an expected caller error; use an exception when the API contract treats it as a programming error.
Boundary validation may not run for every consumer of the domain.

**"This is a lot of ceremony for a CRUD app."**
True, and that's the honest boundary of the pattern.
A settings screen that maps form fields to columns gains nothing from factories and value objects.
Apply the discipline where rules are dense (ordering, billing, inventory), and let genuinely rule-free areas stay simple.
Knowing where each applies is most of the judgment in [**transaction script vs domain model**](https://milanjovanovic.tech/blog/transaction-script-vs-domain-model).

**Tests get better, not worse.**
Factories make illegal states unrepresentable in tests too, which kills the classic "test passes against a state production can't produce" failure mode.
A small builder that composes the public factories keeps test setup short.

## Which Checks Can You Remove?

The payoff is measured in removed code.
Once the model is always valid:

- Repeated format checks can disappear after a value object has been created successfully.
- Repeating the same local invariants before every save becomes unnecessary when every mutation enforces them.
- Defensive collection handling can be centralized in the domain's read-only wrappers or immutable snapshots.
- Normalization can happen once in a value-object factory, using rules that match the domain.

Keep repository not-found checks, runtime null guards where needed, and [**concurrency handling**](https://learn.microsoft.com/en-us/ef/core/saving/concurrency).
Two valid in-memory objects can still represent conflicting updates to the same persisted state.

The domain layer becomes the one place where rules live, which is exactly the promise of the pattern.
If you want to go deeper on structuring that layer, this is the core of what I teach in [Pragmatic Domain-Driven Design](https://milanjovanovic.tech/pragmatic-domain-driven-design).

## Summary

The always-valid domain model is three habits applied consistently:

- **Validate construction** with private constructors, factories, and value objects.
- **Guard mutations** with private setters, encapsulated collections, and methods that check state transitions.
- **Keep both validation layers**: request validation produces useful errors and domain validation preserves local invariants.

The model should guarantee its local rules through its public API.
Make those guarantees explicit, and keep the checks that protect external data and concurrent updates.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### What is an always-valid domain model?

A design discipline where domain objects enforce local invariants at creation and on every public state change. This reduces repeated validation, while external data, null references, and concurrent persistence still require checks.

### What is the difference between validation and invariants?

Boundary validation produces useful input errors. Invariants are constraints the domain preserves, such as requiring a line before an order can be paid or shipped. A rejected operation can return a result without leaving an invalid object.

### Should domain entities throw exceptions or return results?

Both approaches work. Returning a Result from static factory methods treats expected rule violations as flow control, while exceptions guard against programmer errors. Many teams use Result for creation and business operations, and exceptions for guard clauses that should never fire.

### How does an always-valid model work with EF Core?

EF Core can use private constructors and mapped backing fields to materialize entities without calling a factory. Configure mappings explicitly and account for imports, migrations, and older data through database constraints and a rehydration policy.

### Does an always-valid domain model remove the need for FluentValidation?

No. You still validate requests at the application boundary to return good error messages and reject garbage early. The domain enforces the rules that must hold; boundary validation improves the user experience and fails fast.
