Ever found yourself copying the same LINQ Where clause across multiple services? Or debugging a filter condition that's duplicated in 15 different places? Let me introduce you to a pattern that's been saving my sanity: the Specification Pattern.
The Problem We All Face
Consider this common scenario in a typical .NET application:
// In CustomerService.cs
public async Task<List<Customer>> GetPremiumCustomers()
{
return await _dbContext.Customers
.Where(c => c.Balance >= 100000 && c.Age >= 18)
.ToListAsync();
}
// In ReportService.cs
public async Task<int> GetPremiumCustomerCount()
{
return await _dbContext.Customers
.CountAsync(c => c.Balance >= 100000 && c.Age >= 18);
}
// In NotificationService.cs
public async Task SendPremiumOffers()
{
List<Customer> customers = await _dbContext.Customers
.Where(c => c.Balance >= 100000 && c.Age >= 18)
.ToListAsync();
foreach (Customer customer in customers)
{
await _emailService.SendOffer(customer);
}
}
// In CustomerController.cs
[HttpGet("premium")]
public async Task<IActionResult> GetPremium()
{
List<Customer> customers = await _dbContext.Customers
.Where(c => c.Balance >= 100000 && c.Age >= 18)
.ToListAsync();
return Ok(customers);
}
The same filter appears 4 times. Now imagine:
- Your business changes the premium threshold from $100,000 to $250,000
- You need to add a new condition: customer must also be verified
- One developer updates 3 places but misses the 4th
This is a maintenance nightmare waiting to happen.
The Solution: Specification Pattern
The Specification Pattern encapsulates business rules as reusable, testable objects. Instead of scattering conditions everywhere, you define them once:
public class PremiumCustomerSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return customer => customer.Balance >= 100000 && customer.Age >= 18;
}
}
Now your code becomes:
// In CustomerService.cs
public async Task<List<Customer>> GetPremiumCustomers()
{
PremiumCustomerSpecification spec = new PremiumCustomerSpecification();
return await _dbContext.Customers
.Where(spec.ToExpression())
.ToListAsync();
}
// In ReportService.cs
public async Task<int> GetPremiumCustomerCount()
{
PremiumCustomerSpecification spec = new PremiumCustomerSpecification();
return await _dbContext.Customers
.CountAsync(spec.ToExpression());
}
// In NotificationService.cs
public async Task SendPremiumOffers()
{
PremiumCustomerSpecification spec = new PremiumCustomerSpecification();
List<Customer> customers = await _dbContext.Customers
.Where(spec.ToExpression())
.ToListAsync();
foreach (Customer customer in customers)
{
await _emailService.SendOffer(customer);
}
}
Change the rule once, it updates everywhere.
Enter Masterly.Specification
I've been working on Masterly.Specification, a .NET library that implements this pattern with powerful additions. Let me walk you through its features.
Installation
dotnet add package Masterly.Specification
Basic Usage: Two Ways to Use Specifications
1. Database Queries (Expression Trees)
Specifications work seamlessly with Entity Framework and other ORMs:
PremiumCustomerSpecification spec = new PremiumCustomerSpecification();
// The expression is translated to SQL by EF Core
List<Customer> premiumCustomers = await _dbContext.Customers
.Where(spec.ToExpression())
.ToListAsync();
// Also works with implicit conversion - no ToExpression() needed!
List<Customer> premiumCustomers = await _dbContext.Customers
.Where(spec)
.ToListAsync();
2. In-Memory Validation
Use the same specification to validate individual objects:
PremiumCustomerSpecification spec = new PremiumCustomerSpecification();
Customer customer = new Customer
{
Name = "John",
Balance = 150000,
Age = 25
};
if (spec.IsSatisfiedBy(customer))
{
Console.WriteLine("Customer is premium!");
// Grant premium access, show special UI, etc.
}
else
{
Console.WriteLine("Customer is not premium.");
}
Composing Specifications: Building Complex Rules
The real power comes from combining specifications. Let's say you have these business rules:
public class AdultSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return customer => customer.Age >= 18;
}
}
public class HighBalanceSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return customer => customer.Balance >= 100000;
}
}
public class VerifiedSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return customer => customer.IsVerified;
}
}
public class ActiveSpecification : Specification<Customer>
{
public override Expression<Func<Customer, bool>> ToExpression()
{
return customer => customer.IsActive;
}
}
Now combine them:
// AND: Customer must be adult AND have high balance
ISpecification<Customer> premiumSpec = new AdultSpecification()
.And(new HighBalanceSpecification());
// OR: Customer is premium OR verified
ISpecification<Customer> qualifiedSpec = premiumSpec
.Or(new VerifiedSpecification());
// NOT: Customer is NOT active
ISpecification<Customer> inactiveSpec = new ActiveSpecification().Not();
// AND NOT: Premium customers who are NOT inactive
ISpecification<Customer> activePremiumSpec = premiumSpec
.AndNot(inactiveSpec);
// Use in query
List<Customer> results = await _dbContext.Customers
.Where(activePremiumSpec.ToExpression())
.ToListAsync();
Creating Reusable Composite Specifications
You can create a class for frequently used combinations:
public class EligibleForLoanSpecification : AndSpecification<Customer>
{
public EligibleForLoanSpecification()
: base(
new AdultSpecification(),
new VerifiedSpecification(),
new ActiveSpecification())
{
}
}
// Usage
EligibleForLoanSpecification spec = new EligibleForLoanSpecification();
bool canApplyForLoan = spec.IsSatisfiedBy(customer);
Advanced Logic Operators
Version 2.0 introduces operators you won't find in basic implementations:
XOR (Exclusive Or)
Returns true when exactly one condition is true:
ISpecification<Customer> goldSpec = new GoldMemberSpecification();
ISpecification<Customer> silverSpec = new SilverMemberSpecification();
// Customer can be Gold OR Silver, but not both (exclusive tiers)
ISpecification<Customer> exclusiveTierSpec = goldSpec.Xor(silverSpec);
// Truth table:
// Gold=true, Silver=true -> false (can't be both)
// Gold=true, Silver=false -> true
// Gold=false, Silver=true -> true
// Gold=false, Silver=false -> false (must be one)
Implies (Material Implication)
Enforces business rules: "If A, then B must be true":
ISpecification<Order> highValueSpec = new ExpressionSpecification<Order>(o => o.Total > 10000);
ISpecification<Order> managerApprovedSpec = new ExpressionSpecification<Order>(o => o.ApprovedByManager);
// Business rule: High-value orders MUST have manager approval
ISpecification<Order> validOrderSpec = highValueSpec.Implies(managerApprovedSpec);
// Truth table:
// HighValue=true, ManagerApproved=true -> true (rule satisfied)
// HighValue=true, ManagerApproved=false -> false (VIOLATION!)
// HighValue=false, ManagerApproved=true -> true (rule doesn't apply)
// HighValue=false, ManagerApproved=false -> true (rule doesn't apply)
Iff (If and Only If)
Both conditions must have the same truth value:
ISpecification<User> activeSpec = new ExpressionSpecification<User>(u => u.IsActive);
ISpecification<User> hasSubscriptionSpec = new ExpressionSpecification<User>(u => u.SubscriptionId != null);
// Users are active if and only if they have a subscription
// (ensures data consistency)
ISpecification<User> consistentSpec = activeSpec.Iff(hasSubscriptionSpec);
NAND and NOR
// NAND: Not both conditions true
ISpecification<Product> nandSpec = expensiveSpec.Nand(outOfStockSpec);
// Returns false only when product is BOTH expensive AND out of stock
// NOR: Neither condition true
ISpecification<Document> norSpec = expiredSpec.Nor(archivedSpec);
// Returns true only when document is NEITHER expired NOR archived
N-ary Composition: Multiple Specifications at Once
When you need flexible rules across many conditions:
ISpecification<Customer> hasEmail = new ExpressionSpecification<Customer>(c => !string.IsNullOrEmpty(c.Email));
ISpecification<Customer> hasPhone = new ExpressionSpecification<Customer>(c => !string.IsNullOrEmpty(c.Phone));
ISpecification<Customer> hasAddress = new ExpressionSpecification<Customer>(c => c.Address != null);
ISpecification<Customer> hasId = new ExpressionSpecification<Customer>(c => !string.IsNullOrEmpty(c.GovernmentId));
// ALL must be satisfied
ISpecification<Customer> completeProfile = Specifications.All(hasEmail, hasPhone, hasAddress, hasId);
// ANY ONE is enough
ISpecification<Customer> contactable = Specifications.AnyOf(hasEmail, hasPhone);
// EXACTLY 2 must be true
ISpecification<Customer> twoFactorReady = Specifications.Exactly(2, hasEmail, hasPhone, hasAddress);
// AT LEAST 2 must be true
ISpecification<Customer> minimalProfile = Specifications.AtLeast(2, hasEmail, hasPhone, hasAddress);
// AT MOST 1 can be true (useful for mutual exclusivity)
ISpecification<Customer> singleTier = Specifications.AtMost(1, isGold, isSilver, isBronze);
// NONE should be true
ISpecification<Customer> cleanRecord = Specifications.NoneOf(isBanned, isSuspended, hasWarnings);
Fluent Builder: Quick Specs Without Classes
For simple, one-off specifications, skip the class creation:
// Simple specification
ISpecification<User> adultUsers = Spec.Where<User>(u => u.Age >= 18);
// Complex specification with fluent API
ISpecification<User> eligibleUsers = Spec.For<User>()
.Where(u => u.Age >= 18)
.And(u => u.IsActive)
.And(u => u.EmailConfirmed)
.AndNot(u => u.IsBanned)
.Build();
// With grouping for complex logic
// (Age >= 18 AND IsActive) OR (IsAdmin)
ISpecification<User> accessSpec = Spec.For<User>()
.Group(g => g
.Where(u => u.Age >= 18)
.And(u => u.IsActive))
.Or(u => u.IsAdmin)
.Build();
// Factory methods
ISpecification<User> anyUser = Spec.Any<User>(); // Always returns true
ISpecification<User> noUser = Spec.None<User>(); // Always returns false
Property-Based Specifications: Type-Safe Filters
Create readable, strongly-typed property conditions:
// Numeric comparisons
ISpecification<Person> adults = Property<Person>.For(p => p.Age).GreaterThanOrEqual(18);
ISpecification<Person> seniors = Property<Person>.For(p => p.Age).GreaterThan(65);
ISpecification<Person> workingAge = Property<Person>.For(p => p.Age).InRange(18, 65);
// String operations
ISpecification<Person> gmailUsers = Property<Person>.For(p => p.Email).EndsWith("@gmail.com");
ISpecification<Person> hasEmail = Property<Person>.For(p => p.Email).IsNotNullOrEmpty();
ISpecification<Person> validEmailLength = Property<Person>.For(p => p.Email).HasLengthBetween(5, 100);
// Null checks
ISpecification<Person> hasManager = Property<Person>.For(p => p.Manager).IsNotNull();
// In collection
ISpecification<Person> inAllowedCountries = Property<Person>.For(p => p.Country).In("USA", "Canada", "UK");
// Custom predicates
ISpecification<Person> evenAge = Property<Person>.For(p => p.Age).Matches(age => age % 2 == 0);
// Combine them naturally
ISpecification<Person> targetAudience = Property<Person>.For(p => p.Age).InRange(25, 45)
.And(Property<Person>.For(p => p.Email).EndsWith("@company.com"))
.And(Property<Person>.For(p => p.Country).In("USA", "Canada"));
Temporal Specifications: DateTime Logic Made Easy
Date handling is notoriously error-prone. Temporal specifications make it clean:
// Basic comparisons
ISpecification<Event> futureEvents = Temporal<Event>.For(e => e.StartDate).After(DateTime.Now);
ISpecification<Event> pastEvents = Temporal<Event>.For(e => e.StartDate).Before(DateTime.Now);
ISpecification<Event> thisYear = Temporal<Event>.For(e => e.StartDate).InYear(2024);
ISpecification<Event> thisMonth = Temporal<Event>.For(e => e.StartDate).InMonth(2024, 6);
// Date ranges
ISpecification<Event> q1Events = Temporal<Event>.For(e => e.StartDate)
.Between(new DateTime(2024, 1, 1), new DateTime(2024, 3, 31));
// Day of week
ISpecification<Event> weekendEvents = Temporal<Event>.For(e => e.StartDate).OnWeekend();
ISpecification<Event> weekdayEvents = Temporal<Event>.For(e => e.StartDate).OnWeekday();
ISpecification<Event> mondayEvents = Temporal<Event>.For(e => e.StartDate).OnDayOfWeek(DayOfWeek.Monday);
// Time of day (business hours: 9 AM to 5 PM)
ISpecification<Event> businessHours = Temporal<Event>.For(e => e.StartDate)
.TimeBetween(TimeSpan.FromHours(9), TimeSpan.FromHours(17));
// Nullable DateTime support
ISpecification<Event> hasEndDate = Temporal<Event>.For(e => e.EndDate).HasValue();
ISpecification<Event> openEnded = Temporal<Event>.For(e => e.EndDate).IsNull();
// Combine for complex queries
ISpecification<Event> upcomingBusinessMeetings = Temporal<Event>.For(e => e.StartDate)
.After(DateTime.Now)
.And(Temporal<Event>.For(e => e.StartDate).OnWeekday())
.And(Temporal<Event>.For(e => e.StartDate).TimeBetween(
TimeSpan.FromHours(9),
TimeSpan.FromHours(17)));
Conditional Pipelines: Dynamic Rule Application
Apply different rules based on runtime conditions:
// Apply specification only when a condition is true
bool applyPremiumFilter = user.HasPremiumAccess;
ISpecification<Product> conditionalSpec = premiumProductsSpec.OnlyWhen(applyPremiumFilter);
// If applyPremiumFilter is false, all products pass through
// Skip specification when condition is true
bool isAdmin = user.Role == "Admin";
ISpecification<Document> securitySpec = confidentialSpec.SkipWhen(isAdmin);
// Admins bypass the confidential filter
// Choose different specs based on entity properties
ISpecification<Order> orderValidation = PipelineExtensions
.When<Order>(
order => order.Type == "Premium",
strictValidationSpec)
.Otherwise(standardValidationSpec);
// Or with pass-through/fail-all options
ISpecification<Order> premiumOnlyFilter = PipelineExtensions
.When<Order>(
order => order.Total > 1000,
requiresApprovalSpec)
.OtherwisePass(); // Orders under $1000 pass without approval check
Diagnostics: Understanding Why Rules Fail
Debugging complex specifications is easy with built-in diagnostics:
ISpecification<Order> orderSpec = new ExpressionSpecification<Order>(o => o.Total > 100)
.And(new ExpressionSpecification<Order>(o => o.Status == "Approved"))
.And(new ExpressionSpecification<Order>(o => o.Items.Count > 0));
Order order = new Order
{
Total = 50, // Fails: not > 100
Status = "Pending", // Fails: not "Approved"
Items = new List<OrderItem>() // Fails: count is 0
};
// Get human-readable explanation
string explanation = orderSpec.Explain();
Console.WriteLine(explanation);
// Output: ((o.Total > 100) AND (o.Status == "Approved")) AND (o.Items.Count > 0)
// Get detailed evaluation
EvaluationResult result = orderSpec.Evaluate(order);
Console.WriteLine($"Satisfied: {result.IsSatisfied}"); // false
Console.WriteLine($"Summary: {result.Summary}"); // FAILED
Console.WriteLine("\nPassed conditions:");
foreach (string passed in result.GetPassedConditions())
{
Console.WriteLine($" ✓ {passed}");
}
Console.WriteLine("\nFailed conditions:");
foreach (string failed in result.GetFailureReasons())
{
Console.WriteLine($" ✗ {failed}");
}
// Output:
// Satisfied: False
// Summary: FAILED
// Passed conditions:
// (none)
// Failed conditions:
// ✗ o.Total > 100
// ✗ o.Status == "Approved"
// ✗ o.Items.Count > 0
Performance Optimization: Caching for Hot Paths
For frequently evaluated specifications, cache the compiled expression:
// Without caching - compiles expression every time
ISpecification<Item> spec = new ExpressionSpecification<Item>(i => i.Price > 50);
foreach (Item item in millionItems)
{
spec.IsSatisfiedBy(item); // Compiles expression each call
}
// With caching - compiles once, reuses
CachedSpecification<Item> cachedSpec = spec.Cached();
foreach (Item item in millionItems)
{
cachedSpec.IsSatisfiedBy(item); // Uses cached compiled delegate
}
// Access the compiled predicate directly for maximum performance
Func<Item, bool> predicate = cachedSpec.CompiledPredicate;
List<Item> filtered = millionItems.Where(predicate).ToList();
// Memoization - cache results per entity instance
ISpecification<Item> memoizedSpec = expensiveSpec.Memoized();
memoizedSpec.IsSatisfiedBy(item1); // Evaluates and caches result
memoizedSpec.IsSatisfiedBy(item1); // Returns cached result (no evaluation)
memoizedSpec.IsSatisfiedBy(item2); // Different item, evaluates fresh
When Should You Use This?
Use specifications when:
- Business rules are reused across multiple services/controllers
- Filter logic is complex enough to deserve a name
- You want testable, isolated business rules
- Requirements change frequently (centralized updates)
- You need to explain why something passed/failed
Skip specifications for:
- One-off, simple queries (
c => c.Id == id) - Reporting where raw SQL is more appropriate
- Trivial conditions that don't benefit from naming
Complete Example: Putting It All Together
public class OrderService
{
private readonly AppDbContext _dbContext;
public OrderService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<List<Order>> GetOrdersRequiringAttention()
{
// Define individual specifications
ISpecification<Order> highValue = new ExpressionSpecification<Order>(o => o.Total > 5000);
ISpecification<Order> pending = new ExpressionSpecification<Order>(o => o.Status == "Pending");
ISpecification<Order> overdue = Temporal<Order>.For(o => o.DueDate).Before(DateTime.Now);
ISpecification<Order> flagged = new ExpressionSpecification<Order>(o => o.IsFlagged);
// Combine with business logic:
// Orders need attention if:
// - High value AND pending, OR
// - Overdue (regardless of value), OR
// - Manually flagged
ISpecification<Order> needsAttention = highValue.And(pending)
.Or(overdue)
.Or(flagged);
// Use cached version for performance
CachedSpecification<Order> cachedSpec = needsAttention.Cached();
return await _dbContext.Orders
.Where(cachedSpec.ToExpression())
.OrderByDescending(o => o.Total)
.ToListAsync();
}
public async Task<bool> CanProcessOrder(Order order)
{
ISpecification<Order> canProcess = Spec.For<Order>()
.Where(o => o.Status == "Approved")
.And(o => o.PaymentVerified)
.And(o => o.Items.Any())
.Build();
if (!canProcess.IsSatisfiedBy(order))
{
EvaluationResult result = canProcess.Evaluate(order);
_logger.LogWarning(
"Order {OrderId} cannot be processed. Failures: {Failures}",
order.Id,
string.Join(", ", result.GetFailureReasons()));
return false;
}
return true;
}
}
Wrapping Up
The Specification Pattern transforms scattered, duplicated business rules into clean, testable, reusable components. Masterly.Specification brings this pattern to .NET with:
- Basic composition (And, Or, Not, AndNot)
- Advanced logic (Xor, Implies, Iff, Nand, Nor)
- N-ary composition (All, AnyOf, Exactly, AtLeast, AtMost, NoneOf)
- Fluent builder for inline specs
- Property-based specifications
- Temporal (DateTime) specifications
- Conditional pipelines
- Diagnostics and explanation
- Performance caching
Check out the GitHub repo for full documentation!
What patterns do you use to manage complex business logic in your .NET projects? Drop a comment below!
Top comments (0)