A useful C# specification pattern example does not need a framework, a database, or a hierarchy of generic base classes. It needs a clear domain rule, an honest contract, and tests that prove the important behavior.
This article builds that version from scratch. The implementation is a pure domain model based on IsSatisfiedBy. It uses immutable named rules, defines what happens when the candidate is null, and includes positive, negative, boundary, and null-policy tests. It does not cover Entity Framework Core, Repository, expression translation, query shaping, Ardalis.Specification, or a validation framework.
Choose the Pure Domain Specification Model
The word "specification" can describe several abstractions. Here, it means one thing:
A specification is an immutable, side-effect-free object that answers whether a candidate satisfies one named domain condition.
That definition keeps the implementation small. The object receives any rule parameters in its constructor, stores them as immutable state, and exposes one operation:
bool IsSatisfiedBy(T candidate)
The Evans and Fowler Specification paper describes Specification as an encapsulated predicate used for purposes including selection and validation. The implementation in this article uses that predicate-centered model without expanding it into a persistence abstraction.
The .NET platform also defines a predicate concept. Predicate<T> is a delegate that returns a Boolean value for an input. Our interface represents similar Boolean behavior as a named object. That name is what lets a call site speak in domain terms instead of repeating the condition.
.NET 10 is an LTS release. C# 14 is the released language version associated with .NET 10. The examples target that stable pairing. No preview language or framework feature is required.
Start With a Concrete Domain Problem
Imagine a customer-support application. Customers with active accounts and enough loyalty points can receive priority support.
That sentence contains two independent rules:
- The customer's account is active.
- The customer has at least the required number of loyalty points.
We could write both conditions inline wherever they are needed. That may be the correct choice if the rule appears once. For this example, assume the conditions are reused by an eligibility service and need focused boundary tests.
The specification names should remain positive:
CustomerAccountIsActiveSpecificationCustomerHasMinimumLoyaltyPointsSpecification
Positive names make true easy to interpret. If CustomerAccountIsActiveSpecification.IsSatisfiedBy(customer) returns true, the statement reads naturally. A negative name such as CustomerIsNotInactiveSpecification forces the reader to mentally invert the result.
Define the Contract and Null Policy
The generic contract is intentionally narrow. It exposes bool IsSatisfiedBy(T candidate). The complete interface appears in the implementation below.
The in modifier makes the type parameter contravariant, but the implementation does not depend on variance to work. The central choice is the Boolean method.
We also need a null policy. There are three common options:
- Return
falsefor a null candidate. - Throw an argument exception.
- Prevent null at an earlier boundary and treat it as impossible.
This example throws ArgumentNullException. A missing customer is not a customer that simply failed an eligibility rule. It is an invalid call to the rule. Returning false would collapse those two meanings into the same result.
The important lesson is not that throwing is universally correct. It is that every specification API should choose a policy deliberately and test it. Silent inconsistency is the expensive option.
A Complete C# Specification Pattern Example
The following code is one conceptual C# file. It uses only the base class library. The tests are executable checks rather than a third-party test framework, and the Main entry point calls SpecificationTests.RunAll(). That keeps the example dependency free while still executing the rule checks.
using System;
using System.Collections.Generic;
using System.Linq;
namespace FromScratchSpecifications;
public interface ISpecification<in T>
{
bool IsSatisfiedBy(T candidate);
}
public sealed class Customer
{
public Customer(
string name,
bool isAccountActive,
int loyaltyPoints)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (loyaltyPoints < 0)
{
throw new ArgumentOutOfRangeException(nameof(loyaltyPoints));
}
Name = name;
IsAccountActive = isAccountActive;
LoyaltyPoints = loyaltyPoints;
}
public string Name { get; }
public bool IsAccountActive { get; }
public int LoyaltyPoints { get; }
}
public sealed class CustomerAccountIsActiveSpecification
: ISpecification<Customer>
{
public bool IsSatisfiedBy(Customer candidate)
{
ArgumentNullException.ThrowIfNull(candidate);
return candidate.IsAccountActive;
}
}
public sealed class CustomerHasMinimumLoyaltyPointsSpecification
: ISpecification<Customer>
{
private readonly int _minimumLoyaltyPoints;
public CustomerHasMinimumLoyaltyPointsSpecification(
int minimumLoyaltyPoints)
{
if (minimumLoyaltyPoints < 0)
{
throw new ArgumentOutOfRangeException(
nameof(minimumLoyaltyPoints));
}
_minimumLoyaltyPoints = minimumLoyaltyPoints;
}
public bool IsSatisfiedBy(Customer candidate)
{
ArgumentNullException.ThrowIfNull(candidate);
return candidate.LoyaltyPoints >= _minimumLoyaltyPoints;
}
}
public sealed class PrioritySupportEligibilityService
{
private readonly ISpecification<Customer> _activeAccount;
private readonly ISpecification<Customer> _minimumLoyaltyPoints;
public PrioritySupportEligibilityService(
ISpecification<Customer> activeAccount,
ISpecification<Customer> minimumLoyaltyPoints)
{
ArgumentNullException.ThrowIfNull(activeAccount);
ArgumentNullException.ThrowIfNull(minimumLoyaltyPoints);
_activeAccount = activeAccount;
_minimumLoyaltyPoints = minimumLoyaltyPoints;
}
public bool IsEligible(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
return _activeAccount.IsSatisfiedBy(customer) &&
_minimumLoyaltyPoints.IsSatisfiedBy(customer);
}
}
public static class SpecificationTests
{
public static void RunAll()
{
ActiveAccount_ActiveCustomer_ReturnsTrue();
ActiveAccount_InactiveCustomer_ReturnsFalse();
MinimumPoints_ExactBoundary_ReturnsTrue();
MinimumPoints_BelowBoundary_ReturnsFalse();
MinimumPoints_NullCandidate_Throws();
Eligibility_AllRulesSatisfied_ReturnsTrue();
Eligibility_OneRuleFails_ReturnsFalse();
}
private static void ActiveAccount_ActiveCustomer_ReturnsTrue()
{
var customer = new Customer("Avery", true, 500);
var specification =
new CustomerAccountIsActiveSpecification();
AssertTrue(
specification.IsSatisfiedBy(customer),
nameof(ActiveAccount_ActiveCustomer_ReturnsTrue));
}
private static void ActiveAccount_InactiveCustomer_ReturnsFalse()
{
var customer = new Customer("Blake", false, 500);
var specification =
new CustomerAccountIsActiveSpecification();
AssertFalse(
specification.IsSatisfiedBy(customer),
nameof(ActiveAccount_InactiveCustomer_ReturnsFalse));
}
private static void MinimumPoints_ExactBoundary_ReturnsTrue()
{
var customer = new Customer("Casey", true, 1_000);
var specification =
new CustomerHasMinimumLoyaltyPointsSpecification(1_000);
AssertTrue(
specification.IsSatisfiedBy(customer),
nameof(MinimumPoints_ExactBoundary_ReturnsTrue));
}
private static void MinimumPoints_BelowBoundary_ReturnsFalse()
{
var customer = new Customer("Devon", true, 999);
var specification =
new CustomerHasMinimumLoyaltyPointsSpecification(1_000);
AssertFalse(
specification.IsSatisfiedBy(customer),
nameof(MinimumPoints_BelowBoundary_ReturnsFalse));
}
private static void MinimumPoints_NullCandidate_Throws()
{
var specification =
new CustomerHasMinimumLoyaltyPointsSpecification(1_000);
AssertThrows<ArgumentNullException>(
() => specification.IsSatisfiedBy(null!),
nameof(MinimumPoints_NullCandidate_Throws));
}
private static void Eligibility_AllRulesSatisfied_ReturnsTrue()
{
var customer = new Customer("Emery", true, 1_500);
var service = CreateEligibilityService();
AssertTrue(
service.IsEligible(customer),
nameof(Eligibility_AllRulesSatisfied_ReturnsTrue));
}
private static void Eligibility_OneRuleFails_ReturnsFalse()
{
var customer = new Customer("Finley", false, 1_500);
var service = CreateEligibilityService();
AssertFalse(
service.IsEligible(customer),
nameof(Eligibility_OneRuleFails_ReturnsFalse));
}
private static PrioritySupportEligibilityService
CreateEligibilityService()
{
return new PrioritySupportEligibilityService(
new CustomerAccountIsActiveSpecification(),
new CustomerHasMinimumLoyaltyPointsSpecification(1_000));
}
private static void AssertTrue(bool value, string testName)
{
if (!value)
{
throw new InvalidOperationException(
$"{testName} expected true.");
}
}
private static void AssertFalse(bool value, string testName)
{
if (value)
{
throw new InvalidOperationException(
$"{testName} expected false.");
}
}
private static void AssertThrows<TException>(
Action action,
string testName)
where TException : Exception
{
try
{
action();
}
catch (TException)
{
return;
}
throw new InvalidOperationException(
$"{testName} expected {typeof(TException).Name}.");
}
}
public static class Program
{
public static void Main()
{
SpecificationTests.RunAll();
}
}
Every public object in the sample exposes immutable state. Customer validates its constructor arguments and provides get-only properties. The parameterized specification validates its threshold once and stores it in a readonly field. Neither rule changes after construction.
The service depends on the specification contract rather than the concrete rules. That makes its decision readable without turning the specifications into an all-purpose framework. The service owns the workflow-level requirement that both rules must pass. The individual specifications continue to own one condition each.
Walk Through the Two Named Rules
CustomerAccountIsActiveSpecification has no constructor parameters because the meaning of "active" is already represented by the domain object. Its implementation is almost trivial.
That is not automatically a problem. Small specifications can be valuable when the name is reused and meaningful. But the class should still justify itself. If the condition is local to one method and never discussed independently, customer.IsAccountActive may be clearer.
CustomerHasMinimumLoyaltyPointsSpecification captures a threshold. Constructor validation prevents an impossible negative minimum from entering the object. The rule uses >=, making the threshold inclusive.
That tiny operator choice is exactly why boundary tests matter. A positive example using 1,500 points would pass whether the implementation used > or >=. The test using exactly 1,000 points distinguishes the intended rule from the off-by-one alternative.
The names also separate the rule from the consuming workflow. A reporting feature could use the points specification without inheriting the priority-support decision. Reuse happens at the criterion level, not by forcing multiple workflows into one service.
Treat Constructor Parameters as Rule Identity
A parameterized specification is more than a method with an argument moved into a field. Its constructor defines the identity of the rule instance.
CustomerHasMinimumLoyaltyPointsSpecification(1_000) means "the rule requiring at least 1,000 loyalty points." Once created, that meaning should not drift. A public setter for the threshold would let the same object represent different rules at different times. That would make logs, tests, and consumers harder to reason about.
Validate rule parameters at construction. The example rejects a negative threshold because the domain object also rejects negative loyalty points. A specification that could never be satisfied may be intentional in some domains, but an impossible parameter should not slip through accidentally.
Constructor validation also keeps IsSatisfiedBy focused. The method evaluates the candidate against a valid rule. It does not repeatedly check whether the rule itself was configured correctly.
Use parameter names that preserve domain meaning. minimumLoyaltyPoints explains the threshold. A generic name such as value would compile, but it would make construction and review less clear.
For rules involving dates, inject the relevant DateTimeOffset into the constructor instead of reading the system clock inside IsSatisfiedBy. The specification then represents a decision at a known instant. Tests can cover the exact boundary without depending on when they happen to run.
Let the Consumer Own the Workflow Decision
The eligibility service requires both atomic rules. It expresses that requirement with &&, but it does not create a reusable AndSpecification<T> abstraction.
That choice is deliberate. The article is implementing named domain rules, not a general composition framework. The service owns the use-case statement "priority support requires an active account and enough points." Each specification remains reusable because it does not know why the service needs it.
This boundary also protects naming. A generic composed object can tell you that two predicates were joined, but it cannot automatically invent a useful domain name for the combined meaning. PrioritySupportEligibilityService makes the purpose explicit.
If several consumers need the exact combined rule as a standalone concept, introduce a named specification for that concept. Its implementation can depend on the two atomic rules while retaining a domain-specific name. Do that because the combined idea is reusable, not because every Boolean expression needs to become an object graph.
The service-level tests prove the workflow decision separately from the atomic rule tests. When one fails, you can see whether the problem belongs to account status, the points boundary, or the coordination between them. That separation is small, but it is practical.
Apply a Specification Without Hiding the Code
For an in-memory collection, applying a specification is ordinary LINQ. Enumerable Where calls the predicate for each source element and yields elements for which it returns true. A caller can pass customer => specification.IsSatisfiedBy(customer) as that predicate.
This helper belongs beside the earlier types in the same conceptual file:
public static class CustomerSpecificationFilters
{
public static IReadOnlyList<Customer> WhereSatisfied(
IEnumerable<Customer> customers,
ISpecification<Customer> specification)
{
ArgumentNullException.ThrowIfNull(customers);
ArgumentNullException.ThrowIfNull(specification);
return customers
.Where(specification.IsSatisfiedBy)
.ToArray();
}
}
If you need a refresher on the broader operator model, LINQ in C#: Complete Guide to Language Integrated Query .NET 6-9 covers the foundation. For predicate-focused operators, LINQ Filtering in C#: Where, Any, All, Contains, and OfType provides the relevant filtering context.
Keep one boundary in mind: this article's specification is executable C# behavior. It does not promise that a database provider can inspect or translate the method. That is outside this page's pure domain model.
What the Tests Prove
The test methods cover four distinct concerns.
Positive Behavior
The active-account test proves that an active customer satisfies the active-account rule. The eligibility test proves that a customer satisfying both atomic rules is eligible for priority support.
Negative Behavior
The inactive-account test proves that the active rule rejects an inactive customer. The service-level negative test proves that one failed rule is enough to reject the combined workflow decision.
Boundary Behavior
The exact-threshold test proves that the loyalty-points minimum is inclusive. The below-threshold test proves the nearest failing value is rejected.
Null Policy
The null test proves that a missing candidate throws ArgumentNullException. This is not incidental defensive code. It is part of the public contract.
The tests do not prove anything about database translation, SQL, persistence, request validation, or authorization. They prove the in-memory semantics of these rules and the consumer that coordinates them. That scope is enough because the implementation makes no broader promise.
Keep Specifications Immutable and Focused
An immutable rule is easier to reason about than one whose threshold or mode changes between calls. Constructor parameters describe the rule at creation time. Readonly fields preserve that identity.
Focused rules also improve naming. A class called EligibleCustomerSpecification may begin with one condition and slowly accumulate account state, region, points, payment history, marketing consent, and authorization checks. The name stays broad while the behavior becomes difficult to reuse.
Prefer atomic domain statements. Let a consumer coordinate multiple statements when the workflow needs them together. That does not mean every property check deserves a class. It means each extracted specification should represent one coherent idea.
Avoid placing these behaviors inside IsSatisfiedBy:
- Network or database calls.
- Logging that changes the outcome.
- Mutating the candidate.
- Reading the current time directly.
- Sending messages or triggering actions.
- Catching unrelated exceptions and returning false.
If a rule depends on a date, pass a DateTimeOffset or an immutable reference time into the specification constructor. The resulting object remains explicit and testable.
Pros and Cons of a From-Scratch Specification
Pros
The implementation is small. There is no package API to learn, no inheritance hierarchy, and no persistence dependency.
The names can improve domain vocabulary. Callers see CustomerHasMinimumLoyaltyPointsSpecification rather than re-deriving the meaning of a threshold.
The rules are easy to test in isolation. Boundary and null behavior can be proven without starting the application or mocking unrelated services.
The contract is yours. You can choose the null policy, naming conventions, immutability rules, and composition boundary that fit the codebase.
Cons
Every rule adds another type. If most conditions are short and local, navigation overhead can exceed the value of extraction.
A homegrown abstraction can grow unexpectedly. Teams often add generic base classes, operator overloads, error messages, asynchronous methods, expression trees, and query metadata until the "small pattern" becomes an internal framework.
A Boolean result is intentionally limited. It does not explain why a rule failed, identify a field, localize a message, or coordinate a validation lifecycle. Adding all of those responsibilities changes the abstraction.
The pure domain form also does not provide provider-visible query criteria. Trying to use IsSatisfiedBy as if it were a database expression crosses into a different specification model.
When to Use This Implementation
Use this small design when a rule has a meaningful name, is evaluated in memory, and benefits from reuse or focused tests. It works well for decisions inside domain services, application workflows, and entity behavior where all required state is already available.
Skip it when the condition is obvious, local, and used once. An inline if statement is not a design failure. It can be the clearest representation.
Also skip this particular model when the primary problem is describing a database query. Provider-visible criteria require a different contract. Do not expand IsSatisfiedBy until it carries unrelated query concerns. Choose the model that matches the execution environment.
Frequently Asked Questions
Why use an interface for one method?
The interface gives consumers a stable contract and allows multiple named rules to be supplied without knowing their concrete types. If you do not need substitution, a concrete sealed class can be enough.
Should a null candidate return false?
It can, but the choice changes the meaning of false. This example throws because "missing input" and "candidate fails the rule" are different states. Pick one policy and test it consistently.
Should specifications return error messages?
Not in this minimal Boolean model. A structured failure result can be useful, but it is a different contract. Do not add messages casually and still assume the object is only a predicate.
Do I need a generic base class?
No. The interface is sufficient for the example. Add shared implementation only when real duplicated behavior appears. Starting with a base class can encourage framework building before the domain needs it.
Can these specifications be combined?
The consumer can coordinate multiple rules with ordinary Boolean operators, as the eligibility service does. A reusable composition API is possible, but it introduces naming, null, and algebra decisions that deserve separate treatment.
The Practical Result
This C# specification pattern example stays useful because it stays narrow.
The interface asks one question. Each sealed rule represents one positive domain condition. Constructor arguments define immutable rule state. The null policy is explicit. Tests cover positive, negative, boundary, and null behavior. The consumer coordinates rules without pretending that the specifications own the whole workflow.
That is enough for a dependency-free domain implementation. If the abstraction starts collecting persistence, query shaping, structured validation, or provider behavior, stop and name the new responsibility. A small specification should make the business condition easier to see -- not give every conditional a new place to hide.

