# 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 has a public setter, so any code can change it.
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.
Those rules are separate 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 way to create an `Order`, so its rules always run.
- **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).

## Step 2: Push Validation Into Value Objects

Notice the email parameter isn't a `string`.
The email rules live in an `Email` value object:

```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.
[**Value objects**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals) give you that guarantee by centralizing local constraints behind a controlled API.

## Step 3: Guard Every State Transition

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.
I walked through the same transition, from property bag to behavior, 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?

An always-valid model doesn't mean you delete your FluentValidation validators.
Validation and invariants are two separate layers, and you need both:

- **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. The domain rejects any operation that would violate one, so the invalid state never exists.

The boundary can duplicate a few domain rules (email format, required fields), and that duplication is fine.
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.
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 rule-free areas stay simple.
To decide which approach fits an area, see [**transaction script vs domain model**](https://milanjovanovic.tech/blog/transaction-script-vs-domain-model).

**What about tests?**
Factories make illegal states unrepresentable in tests too, so a test can't pass against a state that production code can't produce.
A small builder that composes the public factories keeps test setup short.

## Which Checks Can You Remove?

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 the rules live.
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.

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. 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.
