BrandGhost
Using Specifications for Domain Rules and Validation

Using Specifications for Domain Rules and Validation

Specification Pattern DDD validation works best when the Specification has a narrow job: express a named, reusable, side-effect-free domain rule. The confusion starts when that Boolean rule is expected to parse requests, produce field-level errors, protect aggregate state, authorize users, and orchestrate workflows. Those are separate boundaries.

This article focuses on pure domain specifications. There is no EF Core, no query provider, and no persistence-shaped metadata. The goal is to model useful rule predicates, adapt failures into structured results, and keep aggregate invariant enforcement where it belongs.

Specification Pattern DDD Validation Has a Narrow Job

In the Evans/Fowler tradition, a Specification encapsulates a predicate. Eric Evans's DDD Reference places Specification in a domain-model vocabulary, while the Evans/Fowler Specifications paper discusses selection, validation, building to order, reuse, and Boolean composition.

For this article, a pure domain Specification has four properties:

  • It has a name that makes sense to a domain expert.
  • It evaluates one candidate and returns bool.
  • It has no observable side effects.
  • It does not depend on persistence, HTTP, clocks, or other I/O during evaluation.

That shape is deliberately smaller than a validation framework. It is also smaller than a rules engine.

using System;

namespace DomainRules;

public interface IDomainSpecification<in T>
{
    bool IsSatisfiedBy(T candidate);
}

The interface resembles a named form of .NET's Predicate<T>, which is a Boolean-returning delegate. The value added by a Specification is not the bool itself. It is the rule's identity, reuse, and domain language.

Put the Rule in Domain Language

Consider a subscription aggregate that can add seats only while active, paid through the current time, and within its plan limit. A rule named SubscriptionCanAddSeatsSpecification communicates more than a scattered group of comparisons.

The first design decision is important: pass time and requested seat count into the Specification's constructor. Do not let IsSatisfiedBy read the system clock or call another service. The result then depends only on its inputs.

using System;
using System.Collections.Generic;
using System.Linq;

namespace DomainRules;

public enum SubscriptionStatus
{
    Trial,
    Active,
    Suspended,
    Cancelled
}

public sealed class Subscription
{
    public Subscription(
        Guid id,
        SubscriptionStatus status,
        int seatCount,
        int seatLimit,
        DateTimeOffset paidThrough)
    {
        if (seatCount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(seatCount));
        }

        if (seatLimit < 1 || seatCount > seatLimit)
        {
            throw new ArgumentOutOfRangeException(nameof(seatLimit));
        }

        Id = id;
        Status = status;
        SeatCount = seatCount;
        SeatLimit = seatLimit;
        PaidThrough = paidThrough;
    }

    public Guid Id { get; }

    public SubscriptionStatus Status { get; }

    public int SeatCount { get; private set; }

    public int SeatLimit { get; }

    public DateTimeOffset PaidThrough { get; }

    public bool CanIncreaseSeats(
        int additionalSeats,
        DateTimeOffset evaluatedAt)
    {
        return additionalSeats > 0
            && Status == SubscriptionStatus.Active
            && PaidThrough >= evaluatedAt
            && additionalSeats <= SeatLimit - SeatCount;
    }

    public void IncreaseSeats(
        int additionalSeats,
        DateTimeOffset evaluatedAt)
    {
        if (!CanIncreaseSeats(additionalSeats, evaluatedAt))
        {
            throw new InvalidOperationException(
                "The subscription cannot add the requested seats.");
        }

        SeatCount += additionalSeats;
    }
}

public sealed class SubscriptionCanAddSeatsSpecification(
    int additionalSeats,
    DateTimeOffset evaluatedAt)
    : IDomainSpecification<Subscription>
{
    public bool IsSatisfiedBy(Subscription candidate)
    {
        ArgumentNullException.ThrowIfNull(candidate);

        return candidate.CanIncreaseSeats(
            additionalSeats,
            evaluatedAt);
    }
}

This code makes the boundary visible. The Specification offers a reusable preflight rule. The aggregate's IncreaseSeats method independently protects the state transition. A caller cannot create an invalid seat count merely by forgetting to run the Specification first.

That duplication is intentional only at the boundary, not in the rule logic. Both paths call CanIncreaseSeats, so the condition has one implementation. The Specification gives that condition a reusable object identity. The aggregate method remains the invariant-enforcement gate.

Side-Effect-Free Evaluation Is a Real Constraint

A domain rule should not send email, reserve inventory, mutate the candidate, increment a counter, or fetch remote data. A method that performs those actions is a workflow step, not a predicate.

Purity matters because the same rule may be evaluated:

  • Before displaying an available action.
  • While collecting several domain failures.
  • Again immediately before a state transition.
  • In focused unit tests.

If evaluating the rule changes the world, those repeated calls become dangerous. If the rule depends on hidden time or I/O, tests become less deterministic and failures become harder to interpret.

Pass changing values in explicitly. A pricing rule can receive the effective date. A capacity rule can receive a previously loaded capacity snapshot. A credit rule can receive an immutable assessment. The service that obtains those values can perform I/O before constructing the Specification.

A Boolean Is Not a Structured Validation Result

IsSatisfiedBy answers one question. It does not explain why the answer was false.

Real application boundaries often need a stable code and a useful message. They may need several failures at once. Keep those concerns in a separate adapter rather than stuffing error strings, localization, logging, or transport details into every Specification.

namespace DomainRules;

public sealed record DomainRule<T>(
    IDomainSpecification<T> Specification,
    string Code,
    string Message);

public sealed record DomainRuleFailure(
    string Code,
    string Message);

public sealed record DomainRuleResult(
    IReadOnlyList<DomainRuleFailure> Failures)
{
    public bool IsSuccess => Failures.Count == 0;
}

public sealed class DomainRuleEvaluator<T>
{
    public DomainRuleResult Evaluate(
        T candidate,
        IEnumerable<DomainRule<T>> rules)
    {
        ArgumentNullException.ThrowIfNull(candidate);
        ArgumentNullException.ThrowIfNull(rules);

        var failures = rules
            .Where(rule => !rule.Specification.IsSatisfiedBy(candidate))
            .Select(rule => new DomainRuleFailure(
                rule.Code,
                rule.Message))
            .ToArray();

        return new DomainRuleResult(failures);
    }
}

The adapter does not change what a Specification means. Each Specification remains a Boolean rule. DomainRule<T> associates that rule with presentation-neutral failure metadata, while DomainRuleEvaluator<T> collects failures into a result.

This separation has practical benefits:

  • The same Specification can be reused with different messages at different application boundaries.
  • Error codes remain stable even if text changes.
  • Localization can occur outside the domain rule.
  • The domain rule stays easy to test.

There is a cost. The application now has additional types and must decide which rules apply to a use case. That ceremony is worthwhile only when rule reuse and structured failures are genuine requirements.

Keep Request Validation Separate

Request validation answers whether incoming data can be processed. It may check that a string is present, a number is within a syntactic range, or a field has a supported format. Those checks belong near the transport or application boundary.

A domain Specification answers whether a meaningful domain candidate satisfies a rule. It should not need to understand JSON property names, model binding, HTTP status codes, or form-field keys.

For example, a request to add seats may need all of these checks:

  1. The requested number is present and can be parsed.
  2. The number is greater than zero.
  3. The subscription exists.
  4. The subscription is allowed to add that many seats.
  5. The aggregate successfully applies the state change.

The first two are request-boundary concerns. The fourth is a reusable domain predicate. The fifth is invariant enforcement. They can share concepts, but combining them into one Boolean object hides important failure behavior.

The LINQ filtering guide is useful background for understanding predicates and selection. Domain validation adds another concern: a failed rule often needs a reason, not merely exclusion from a sequence.

Keep Aggregate Invariants Enforced by the Aggregate

Microsoft's domain-model validation guidance states that aggregate invariants should remain valid for the aggregate's lifetime. It also discusses Specification plus Notification as an advanced validation approach.

That leads to a strong boundary:

  • A Specification can tell a caller whether a proposed operation appears acceptable.
  • A result adapter can explain failed rules.
  • The aggregate method must still reject a state transition that violates its invariants.

The Specification is useful for user experience and orchestration. It can collect several failures before attempting a command. It is not a substitute for protecting the aggregate itself.

Imagine that two callers exist. One uses the structured adapter; another calls the aggregate from a background process. If only the adapter enforces the limit, the second caller can create invalid state. Keeping the guard in IncreaseSeats prevents that dependency.

Define Null and Boundary Policies Explicitly

Null behavior is part of the rule contract. Do not let it emerge accidentally.

In the example, SubscriptionCanAddSeatsSpecification throws ArgumentNullException for a null candidate. That policy says a missing subscription is a caller error, not a subscription that merely fails the business rule.

Another domain may reasonably return false for a missing optional value. The choice depends on meaning. What matters is consistency and tests.

Boundary cases need the same attention:

  • Adding seats up to the exact plan limit should succeed.
  • Adding one seat beyond the limit should fail.
  • A payment date equal to the evaluation time should follow an explicit inclusive or exclusive rule.
  • Zero and negative requested seats should fail.
  • Suspended and cancelled states should fail even when capacity remains.

These cases reveal the actual language of the rule. They also prevent a friendly class name from hiding vague semantics.

Test What the Specification Proves

A pure Specification test proves in-memory Boolean behavior. It does not prove request validation, aggregate persistence, authorization policy behavior, or rules-engine workflow execution.

The following xUnit tests cover positive, negative, boundary, and null-policy behavior:

using System;
using DomainRules;
using Xunit;

namespace DomainRules.Tests;

public sealed class SubscriptionCanAddSeatsSpecificationTests
{
    private static readonly DateTimeOffset EvaluatedAt =
        new(2026, 9, 2, 13, 0, 0, TimeSpan.Zero);

    [Fact]
    public void IsSatisfiedBy_ActivePaidSubscriptionWithinLimit_ReturnsTrue()
    {
        var subscription = CreateSubscription(
            SubscriptionStatus.Active,
            seatCount: 8,
            seatLimit: 10,
            paidThrough: EvaluatedAt.AddDays(1));
        var specification =
            new SubscriptionCanAddSeatsSpecification(2, EvaluatedAt);

        var result = specification.IsSatisfiedBy(subscription);

        Assert.True(result);
    }

    [Fact]
    public void IsSatisfiedBy_RequestExceedsLimit_ReturnsFalse()
    {
        var subscription = CreateSubscription(
            SubscriptionStatus.Active,
            seatCount: 9,
            seatLimit: 10,
            paidThrough: EvaluatedAt.AddDays(1));
        var specification =
            new SubscriptionCanAddSeatsSpecification(2, EvaluatedAt);

        var result = specification.IsSatisfiedBy(subscription);

        Assert.False(result);
    }

    [Fact]
    public void IsSatisfiedBy_PaidThroughEqualsEvaluationTime_ReturnsTrue()
    {
        var subscription = CreateSubscription(
            SubscriptionStatus.Active,
            seatCount: 9,
            seatLimit: 10,
            paidThrough: EvaluatedAt);
        var specification =
            new SubscriptionCanAddSeatsSpecification(1, EvaluatedAt);

        var result = specification.IsSatisfiedBy(subscription);

        Assert.True(result);
    }

    [Fact]
    public void IsSatisfiedBy_NullCandidate_ThrowsArgumentNullException()
    {
        var specification =
            new SubscriptionCanAddSeatsSpecification(1, EvaluatedAt);

        Assert.Throws<ArgumentNullException>(
            () => specification.IsSatisfiedBy(null!));
    }

    private static Subscription CreateSubscription(
        SubscriptionStatus status,
        int seatCount,
        int seatLimit,
        DateTimeOffset paidThrough)
    {
        return new Subscription(
            Guid.NewGuid(),
            status,
            seatCount,
            seatLimit,
            paidThrough);
    }
}

The exact-limit and equal-time tests are especially valuable because they document inclusive boundaries. The null test documents a policy instead of accepting whichever exception happens to occur.

You should separately test Subscription.IncreaseSeats because it owns the invariant-enforcing state transition. Testing only the Specification would leave the aggregate guard unproven.

When Reuse Is Worth the Additional Types

The Specification Pattern DDD validation approach earns its cost when a domain rule has an independent name and appears in several contexts. It may drive an available-action check, a command preflight, a batch selection, and a domain decision.

It is less helpful when the condition is:

  • Used once.
  • Obvious at the call site.
  • Tied entirely to one request model.
  • Mostly concerned with formatting or parsing.

In those cases, a local condition or request validator is often clearer. Not every if statement needs a class.

A useful rule of thumb is to listen to domain conversations. If people repeatedly name the same condition, the code may benefit from a named Specification. If the proposed name sounds like an implementation detail, such as ValueNotNullAndLessThanTenSpecification, the abstraction may be too low level.

Also consider how often the rule changes independently from its callers. A named Specification can be valuable when one policy evolves while several workflows continue to depend on it. If the condition changes only with one use case, keeping it beside that use case may be easier to follow. Reuse should be observed or strongly expected, not invented to justify another layer.

Boundaries the Boolean Rule Must Not Cross

A pure domain Specification should not be presented as a replacement for:

Concern Why the Specification is insufficient
Request validation Needs binding-aware, field-aware, and format-aware failures
Aggregate invariant enforcement Must protect every state transition, even when callers skip preflight checks
Structured validation results Needs codes, messages, and potentially multiple failures
Authorization Needs user, resource, requirements, handlers, and security failure semantics
Rules engine Needs workflow, external configuration, result trees, actions, or rule lifecycle

These boundaries do not make Specifications less useful. They make their usefulness precise.

Frequently Asked Questions

Can a Specification validate a domain object?

It can evaluate whether the object satisfies a named rule. If the caller needs error codes, messages, or multiple failures, use a separate result adapter. Keep the aggregate's invariant enforcement in the aggregate.

Should IsSatisfiedBy return an error message?

Usually no. Returning bool keeps the Specification reusable and side-effect free. Associate failure metadata through a separate rule descriptor or adapter when a boundary needs structured output.

Where should the current time come from?

Resolve time outside the Specification and pass the relevant DateTimeOffset into its constructor. This avoids hidden clock access and makes boundary tests deterministic.

Should a null candidate return false?

That is a domain-policy choice. In this example, null is a caller error and throws ArgumentNullException. Another domain may define false as appropriate. Pick one policy and test it.

Can the same rule be used before and during a state change?

Yes, provided the rule is pure. A caller can use it for preflight feedback, while the aggregate still enforces the invariant when applying the state change.

Do Specifications replace a rules engine?

No. Specifications are useful code-level predicates. A rules engine handles broader concerns such as external configuration, workflow, result trees, and actions.

Keep the Rule Pure and the Boundaries Honest

The strongest use of Specification Pattern DDD validation is modest. Give an important domain condition a name. Make its evaluation deterministic. Reuse it where a Boolean answer is valuable.

Then stop at the boundary. Adapt failures into structured results separately. Validate incoming requests at the application edge. Enforce aggregate invariants inside the aggregate. Keep authorization and workflow orchestration in their own systems.

That separation creates a little more structure, but it also makes each piece easier to reason about. The Specification says whether a rule is satisfied. Nothing more, and nothing hidden.

How to Implement a Specification in C# From Scratch

Build a dependency-free C# specification pattern example with immutable named rules, an explicit null policy, focused tests, and balanced design tradeoffs.

The Specification Design Pattern in C#: What You Need To Know

Learn about the Specification Design Pattern in C# and its benefits for your code. See how this pattern can improve code quality and how to implement it!

Specification Pattern in C#: A Practical Guide for Modern .NET

Understand the Specification Pattern in C#, distinguish domain rules from query criteria and query envelopes, and decide when it fits a modern .NET design.

An error has occurred. This application may no longer respond until reloaded. Reload