DEV Community

Cover image for Your LINQ Filters Are Scattered Everywhere — Here's How to Fix It
Ahmad Al-Freihat
Ahmad Al-Freihat

Posted on

Your LINQ Filters Are Scattered Everywhere — Here's How to Fix It

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.");
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)