ADR-0006: Domain - Specification Pattern with Expression Tree-Based Query Translation
Context and Problem
Section titled “Context and Problem”Suppose there is a business rule: “customers who are active and Gold grade or above.” This condition is implemented in the domain model’s Customer.IsEligibleForPromotion() method, and also separately written as c => c.IsActive && c.Grade >= Grade.Gold in the Repository’s LINQ Where clause. When the grade threshold later changes to Silver, the domain method is updated but the LINQ query is missed, and a bug is discovered in production where the promotion target query and domain validation return different customer sets.
When the same business rule is scattered across domain code and query code, failing to update one side during rule changes leads to silent inconsistencies. Beyond simple conditions like “active AND Gold or above,” a structure is needed to declaratively compose complex conditions like “active AND (Gold or above OR VIP).”
Considered Options
Section titled “Considered Options”- ExpressionSpecification<T> + PropertyMap bridge
- Direct LINQ Where clause writing
- Dynamic LINQ library
- Query Object pattern
Decision
Section titled “Decision”Chosen option: “ExpressionSpecification<T> + PropertyMap bridge”, to establish a Single Source of Truth for business rules. A single Specification class defines the rule as an Expression Tree; for domain validation, IsSatisfiedBy() evaluates it in-memory, while for Repository queries, EF Core translates the same Expression into SQL. When a rule changes, modifying the Specification in one place instantly reflects in both directions.
The & (AND), | (OR), and ! (NOT) operator overloads enable declarative composition like ActiveSpec & (GoldOrHigherSpec | VipSpec), and the PropertyMap bridge absorbs differences between domain property names and DB column names, preserving domain model purity.
Consequences
Section titled “Consequences”- Good, because the same Specification is reused across domain validation, query filters, and API response filters, so changing a rule requires modification in only one place.
- Good, because compositions like
ActiveSpec & GoldOrHigherSpec | !SuspendedSpecexpress business intent as readable code. - Good, because the PropertyMap bridge declaratively resolves naming differences between the domain’s
Gradeproperty and the DB’scustomer_gradecolumn in a single location. - Bad, because the
ParameterReplacerandExpressionVisitorcombination logic inside Expression Trees is harder to debug than regular code, and incorrect Expression composition manifests as runtimeInvalidOperationException. - Bad, because a separate PropertyMap must be written for every Specification where the domain model and persistence model have different property names.
Confirmation
Section titled “Confirmation”- Verify through unit tests that Specification composition (
&,|,!) produces correct Expressions. - Verify through integration tests that DB query translation via PropertyMap correctly converts to actual SQL.
Pros and Cons of the Options
Section titled “Pros and Cons of the Options”ExpressionSpecification<T> + PropertyMap Bridge
Section titled “ExpressionSpecification<T> + PropertyMap Bridge”- Good, because modifying a single Specification class instantly reflects in both domain validation and DB queries when a rule changes.
- Good, because EF Core directly translates the Expression Tree into a SQL WHERE clause, filtering at the DB level without in-memory filtering.
- Good, because operator overloading like
spec1 & spec2 | !spec3expresses business rule composition in near-natural language. - Good, because PropertyMap declaratively resolves property name and type differences between domain models and persistence models outside the Specification.
- Bad, because the
ParameterExpressionreplacement logic duringAndSpecification,OrSpecification, and other Expression combinations is complex, resulting in high initial framework implementation cost. - Bad, because a PropertyMap must be additionally defined for every Specification where domain properties and DB columns differ, creating boilerplate.
Direct LINQ Where Clause Writing
Section titled “Direct LINQ Where Clause Writing”- Good, because
.Where(c => c.IsActive && c.Grade >= Grade.Gold)can be written directly without additional abstractions, with zero learning cost. - Bad, because the same
IsActive && Grade >= Goldcondition exists separately in both the domain method and Repository LINQ, risking silent inconsistency when one side is missed during updates. - Bad, because when rules are scattered across multiple locations, the impact scope of changes can only be determined through full-text code search.
- Bad, because composing complex conditions requires manual Where clause assembly each time, with no reusable structure.
Dynamic LINQ Library
Section titled “Dynamic LINQ Library”- Good, because string-based dynamic queries like
"Age > 18 AND IsActive"can be constructed at runtime, offering flexibility. - Bad, because renaming the
"Age"property to"UserAge"compiles successfully but throws a runtimeParseException, lacking type safety. - Bad, because typos like
"Actve"in strings only surface as runtime errors in specific branches, delaying discovery. - Bad, because string queries are separate from the domain layer’s business rules, leaving the rule duplication problem unresolved.
Query Object Pattern
Section titled “Query Object Pattern”- Good, because query logic is encapsulated in objects like
ActiveCustomerQuery, enabling reuse across Repositories. - Bad, because Query Objects do not directly generate Expression Trees, so an additional translation layer must be implemented to integrate with EF Core’s SQL translation.
- Bad, because Query Objects are DB-query-only, so they cannot be used for in-memory validation in the domain layer, leaving the rule duplication problem intact.
Related Information
Section titled “Related Information”- Related commit:
f1dec480 - Related tutorial:
Docs.Site/src/content/docs/tutorials/specification-pattern/ - Reference: Eric Evans, Domain-Driven Design — Chapter 9, Specification pattern