DEV Community

Cover image for Design Patterns #6: Filtering Made Elegant with the Specification Pattern
Serhii Korol
Serhii Korol

Posted on

Design Patterns #6: Filtering Made Elegant with the Specification Pattern

Hi! Today, I want to talk about something we do almost every day as developers: fetching data. Nearly every project involves a database, and rarely do we retrieve all the data - we usually need to apply filters.

For simple applications with straightforward data structures, filtering with LINQ works just fine. But as the application grows, things get more complex. Data models become richer, queries more intricate, and filtering logic harder to manage and reuse.

In most cases, we use the Repository pattern, join related entities, and apply various filtering conditions. In this article, I’ll start with a naive implementation and show you how we can make it more robust and maintainable.

Let’s dive in.

The Naive Implementation

Let’s start by implementing a simple filtering mechanism using a basic repository.

Imagine we’re working with a Customer entity. We’ll begin by defining a straightforward model and see how we might typically filter data from the repository.

public record Customer(int Id, string Name, bool IsActive, decimal TotalPurchases)
{
    public string Name { get; init; } = Name ?? throw new ArgumentNullException(nameof(Name));
    public bool IsActive { get; set; } = IsActive;

    public override string ToString() =>
        $"ID: {Id}, Name: {Name}, Active: {IsActive}, Purchases: {TotalPurchases:C}";
}
Enter fullscreen mode Exit fullscreen mode

The next step is to simulate a data repository and apply basic filtering to retrieve the relevant data.

public class LinqWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m),
        new(2, "Bob", false, 800m),
        new(3, "Charlie", true, 200m),
        new(4, "David", true, 1200m),
        new(5, "Eve", false, 2500m)
    ];

    const decimal PremiumThreshold = 1000m;

    public IEnumerable<Customer> GetActiveCustomers()
        => _customers.Where(x => x.IsActive);

    public IEnumerable<Customer> GetPremiumCustomers()
        => _customers.Where(x => x.TotalPurchases >= PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomers()
        => _customers.Where(x => x is { IsActive: true, TotalPurchases: >= PremiumThreshold });

    public IEnumerable<Customer> GetActiveOrPremiumCustomers()
        => _customers.Where(x => x.IsActive || x.TotalPurchases >= PremiumThreshold);

    public IEnumerable<Customer> GetInactiveCustomers()
        => _customers.Where(x => !x.IsActive);

    public IEnumerable<Customer> GetInactiveAndPremiumCustomers()
        => _customers.Where(x => x is { IsActive: false, TotalPurchases: >= PremiumThreshold });

    public IEnumerable<Customer> GetInactiveOrPremiumCustomers()
        => _customers.Where(x => !x.IsActive || x.TotalPurchases >= PremiumThreshold);

    public bool CheckIsActivePremiumCustomerByName(string name)
    {
        Customer? customer = _customers.FirstOrDefault(x => x.Name == name);

        return customer is { IsActive: true, TotalPurchases: >= PremiumThreshold };
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we retrieve data based on a single condition and then combine multiple conditions.
To run this, let’s create an additional method:

public class Program
{
    private const string CustomerName = "Bob";

    public static void Main()
    {
        RunLinqWay();
    }

    public static void RunLinqWay()
    {
        var linq = new LinqWayCustomerService();

        Print(linq.GetActiveCustomers());
        Print(linq.GetPremiumCustomers());
        Print(linq.GetActiveAndPremiumCustomers());
        Print(linq.GetActiveOrPremiumCustomers());
        Print(linq.GetInactiveCustomers());
        Print(linq.GetInactiveAndPremiumCustomers());
        Print(linq.GetInactiveOrPremiumCustomers());
        Print(CustomerName, linq.CheckIsActivePremiumCustomerByName(CustomerName));
    }

    private static void Print<T>(IEnumerable<T> customers) where T : class
    {
        foreach (var customer in customers)
        {
            Console.WriteLine(customer.ToString());
        }

        Console.WriteLine("\n");
    }

    private static void Print(string title, bool isActivePremiumCustomer)
    {
        Console.WriteLine($"{title} is active premium customer: {isActivePremiumCustomer}");
    }
}
Enter fullscreen mode Exit fullscreen mode

If you run this code, you’ll see the following result:

ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH
ID: 3, Name: Charlie, Active: True, Purchases: 200,00 UAH
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH
ID: 3, Name: Charlie, Active: True, Purchases: 200,00 UAH
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH


ID: 2, Name: Bob, Active: False, Purchases: 800,00 UAH
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH


ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH
ID: 2, Name: Bob, Active: False, Purchases: 800,00 UAH
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH


Bob is active premium customer: False

Process finished with exit code 0.
Enter fullscreen mode Exit fullscreen mode

This is a very basic implementation—one you’ve likely used many times when retrieving data. Whenever you need a new filter, you create a new method and write a new LINQ query. For simple scenarios, this approach works just fine. But as your queries grow more complex, it becomes harder to manage, and that’s where improving and reusing existing logic can really pay off.

Enhanced Implementation

The core idea of this approach is to create reusable static extension methods. These methods encapsulate filtering logic and can be easily composed with other LINQ queries, making your code cleaner and more maintainable.

public static class CustomerFilteringExtensions
{
    public static IEnumerable<Customer> FilterActive(this IEnumerable<Customer> customers)
        => customers.Where(c => c.IsActive);

    public static IEnumerable<Customer> FilterInactive(this IEnumerable<Customer> customers)
        => customers.Where(c => !c.IsActive);

    public static IEnumerable<Customer> FilterPremium(this IEnumerable<Customer> customers,
        decimal minimumPurchaseAmount)
        => customers.Where(c => c.TotalPurchases >= minimumPurchaseAmount);

    public static IEnumerable<Customer> FilterActiveOrPremium(this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(c => c.IsActive || c.TotalPurchases >= minAmount);

    public static IEnumerable<Customer> FilterInactiveOrPremium(this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(c => !c.IsActive || c.TotalPurchases >= minAmount);

    public static bool CheckActivePremiumCustomer(this IEnumerable<Customer> customers, string name, decimal minAmount)
    {
        Customer? customer = customers.FirstOrDefault(x => x.Name == name);

        return customer is { IsActive: true } && customer.TotalPurchases >= minAmount;
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps the service code cleaner by avoiding direct access to filtering logic or raw data queries.

public class ExpressionWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m),
        new(2, "Bob", false, 800m),
        new(3, "Charlie", true, 200m),
        new(4, "David", true, 1200m),
        new(5, "Eve", false, 2500m)
    ];

    const decimal PremiumThreshold = 1000m;

    public IEnumerable<Customer> GetActiveCustomers()
        => _customers.FilterActive();

    public IEnumerable<Customer> GetPremiumCustomers()
        => _customers.FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomers()
        => _customers.FilterActive().FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetActiveOrPremiumCustomers()
        => _customers.FilterActiveOrPremium(PremiumThreshold);

    public IEnumerable<Customer> GetInactiveCustomers()
        => _customers.FilterInactive();

    public IEnumerable<Customer> GetInactiveAndPremiumCustomers()
        => _customers.FilterInactive().FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetInactiveOrPremiumCustomers()
        => _customers.FilterInactiveOrPremium(PremiumThreshold);

    public bool CheckIsActivePremiumCustomerByName(string name)
        => _customers.CheckActivePremiumCustomer(name, PremiumThreshold);
}
Enter fullscreen mode Exit fullscreen mode

This approach is not only more efficient than the previous one but also requires less code to implement.

To test it, add the following method:

    public static void RunExpressionWay()
    {
        var expression = new ExpressionWayCustomerService();

        Print(expression.GetActiveCustomers());
        Print(expression.GetPremiumCustomers());
        Print(expression.GetActiveAndPremiumCustomers());
        Print(expression.GetActiveOrPremiumCustomers());
        // Print(expression.GetActiveAndPremiumCustomersWithActivePayments());
        Print(expression.GetInactiveCustomers());
        Print(expression.GetInactiveAndPremiumCustomers());
        Print(expression.GetInactiveOrPremiumCustomers());
        // Print(expression.GetInactiveAndPremiumCustomersWithInactivePayments());
        // Print(expression.GetCustomersWithoutPayments());
        Print(CustomerName, expression.CheckIsActivePremiumCustomerByName(CustomerName));
    }
Enter fullscreen mode Exit fullscreen mode

The result should match the output of the previous approach.

This method allows you to extract and organize query logic into separate methods, which improves readability. However, it may come with a performance cost.

If you're working with complex filtering logic, I recommend using the Specification pattern for a more scalable and maintainable solution.

Specification Implementation

This approach builds on the same principle as the previous ones, breaking down queries into smaller, reusable blocks. However, it leverages expressions and delegates to create a more flexible solution. While this approach results in a larger codebase compared to the previous two, it offers greater maintainability and scalability. In this case, each query is represented as a distinct class that inherits from a Specification base class. Let’s dive into the details.

public interface ISpecification<T>
{
    bool IsSatisfiedBy(T entity);
    Expression<Func<T, bool>> ToExpression();
}

public abstract class Specification<T> : ISpecification<T>
{
    private Func<T, bool>? _compiled;

    public abstract Expression<Func<T, bool>> ToExpression();

    public Func<T, bool> GetCompiledExpression()
        => _compiled ??= ToExpression().Compile();

    public virtual bool IsSatisfiedBy(T entity)
        => GetCompiledExpression()(entity);

    public static Specification<T> operator &(Specification<T> left, ISpecification<T> right)
        => new AndSpecification<T>(left, right);

    public static Specification<T> operator |(Specification<T> left, ISpecification<T> right)
        => new OrSpecification<T>(left, right);

    public static Specification<T> operator !(Specification<T> spec)
        => new NotSpecification<T>(spec);

    public static implicit operator Expression<Func<T, bool>>(Specification<T> spec)
        => spec.ToExpression();
}
Enter fullscreen mode Exit fullscreen mode

The base class compiles expressions and overrides operators. While it’s possible to use methods instead of operators, I recommend using this approach for better performance.

As you can see, the three primary conditional operators are implemented here. Each operator creates a new instance of the class, enabling more efficient and flexible query composition.

public class AndSpecification<T>(ISpecification<T> left, ISpecification<T> right) : Specification<T>
{
    private readonly ISpecification<T> _left = left ?? throw new ArgumentNullException(nameof(left));
    private readonly ISpecification<T> _right = right ?? throw new ArgumentNullException(nameof(right));

    public override Expression<Func<T, bool>> ToExpression() =>
        ExpressionCombiner.CombineAnd(_left.ToExpression(), _right.ToExpression());

    public override bool IsSatisfiedBy(T entity) => _left.IsSatisfiedBy(entity) && _right.IsSatisfiedBy(entity);
}

public class OrSpecification<T>(ISpecification<T> left, ISpecification<T> right) : Specification<T>
{
    private readonly ISpecification<T> _left = left ?? throw new ArgumentNullException(nameof(left));
    private readonly ISpecification<T> _right = right ?? throw new ArgumentNullException(nameof(right));

    public override Expression<Func<T, bool>> ToExpression() =>
        ExpressionCombiner.CombineOr(_left.ToExpression(), _right.ToExpression());

    public override bool IsSatisfiedBy(T entity) => _left.IsSatisfiedBy(entity) || _right.IsSatisfiedBy(entity);
}

public class NotSpecification<T>(ISpecification<T> inner) : Specification<T>
{
    private readonly ISpecification<T> _inner = inner ?? throw new ArgumentNullException(nameof(inner));
    public override Expression<Func<T, bool>> ToExpression() => ExpressionCombiner.CombineNot(_inner.ToExpression());
    public override bool IsSatisfiedBy(T entity) => !_inner.IsSatisfiedBy(entity);
}
Enter fullscreen mode Exit fullscreen mode

These classes are designed to be combined with other, more specific queries. They are generic, allowing them to work with different entities and making the approach highly reusable.

Additionally, you’ll need to include these helper methods for setting up, combining expressions, and handling parameters:

internal static class ExpressionCombiner
{
    public static Expression<Func<T, bool>> CombineAnd<T>(Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        var param = Expression.Parameter(typeof(T));
        var body = Expression.AndAlso(
            ParameterReplacer.Replace(left.Body, left.Parameters[0], param),
            ParameterReplacer.Replace(right.Body, right.Parameters[0], param));
        return Expression.Lambda<Func<T, bool>>(body, param);
    }

    public static Expression<Func<T, bool>> CombineOr<T>(Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        var param = Expression.Parameter(typeof(T));
        var body = Expression.OrElse(
            ParameterReplacer.Replace(left.Body, left.Parameters[0], param),
            ParameterReplacer.Replace(right.Body, right.Parameters[0], param));
        return Expression.Lambda<Func<T, bool>>(body, param);
    }

    public static Expression<Func<T, bool>> CombineNot<T>(Expression<Func<T, bool>> expression)
    {
        var param = expression.Parameters[0];
        var body = Expression.Not(expression.Body);
        return Expression.Lambda<Func<T, bool>>(body, param);
    }
}

internal class ParameterReplacer : ExpressionVisitor
{
    private readonly ParameterExpression _source;
    private readonly ParameterExpression _target;

    private ParameterReplacer(ParameterExpression source, ParameterExpression target)
    {
        _source = source;
        _target = target;
    }

    public static Expression Replace(Expression expression, ParameterExpression source, ParameterExpression target)
        => new ParameterReplacer(source, target).Visit(expression);

    protected override Expression VisitParameter(ParameterExpression node)
        => node == _source ? _target : base.VisitParameter(node);
}

Enter fullscreen mode Exit fullscreen mode

When filtering a specific entity, you should create a new class for the query and explicitly define the entity type within it.

public class ActiveCustomerSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression() => customer => customer.IsActive;
}

public class PremiumCustomerSpecification(decimal threshold = 1000m) : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression() => customer => customer.TotalPurchases >= threshold;
}

public class CustomerNameSpecification : Specification<Customer>
{
    public string Name { get; set; } = string.Empty;
    public override Expression<Func<Customer, bool>> ToExpression() => customer => customer.Name == Name;
}
Enter fullscreen mode Exit fullscreen mode

In the service, we use straightforward LINQ queries combined with specifications and delegates. There’s one important detail: you should always use static fields to ensure optimal performance. When declaring an expression, it needs to be compiled, and this compilation should only occur once to avoid unnecessary overhead.

public class SpecificationWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m),
        new(2, "Bob", false, 800m),
        new(3, "Charlie", true, 200m),
        new(4, "David", true, 1200m),
        new(5, "Eve", false, 2500m)
    ];

    private static readonly ActiveCustomerSpecification ActiveSpec = new();
    private static readonly PremiumCustomerSpecification PremiumSpec = new();

    private static readonly CustomerNameSpecification NameSpec = new();

    private static readonly Func<Customer, bool> ActiveFunc = ActiveSpec.GetCompiledExpression();
    private static readonly Func<Customer, bool> PremiumFunc = PremiumSpec.GetCompiledExpression();

    private static readonly Func<Customer, bool> ActiveAndPremiumFunc =
        (ActiveSpec & PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> ActiveOrPremiumFunc =
        (ActiveSpec | PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveFunc = (!ActiveSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveAndPremiumFunc =
        (!ActiveSpec & PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveOrPremiumFunc =
        (!ActiveSpec | PremiumSpec).GetCompiledExpression();

    public IEnumerable<Customer> GetActiveCustomers() => _customers.Where(ActiveFunc);
    public IEnumerable<Customer> GetPremiumCustomers() => _customers.Where(PremiumFunc);
    public IEnumerable<Customer> GetActiveAndPremiumCustomers() => _customers.Where(ActiveAndPremiumFunc);
    public IEnumerable<Customer> GetActiveOrPremiumCustomers() => _customers.Where(ActiveOrPremiumFunc);
    public IEnumerable<Customer> GetInactiveCustomers() => _customers.Where(InactiveFunc);
    public IEnumerable<Customer> GetInactiveAndPremiumCustomers() => _customers.Where(InactiveAndPremiumFunc);
    public IEnumerable<Customer> GetInactiveOrPremiumCustomers() => _customers.Where(InactiveOrPremiumFunc);

    public bool CheckIsActivePremiumCustomerByName(string name)
    {
        NameSpec.Name = name;
        var combinedSpec = NameSpec & ActiveSpec & PremiumSpec;
        return _customers.Any(combinedSpec.IsSatisfiedBy);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s take a look at this and add the necessary code to run the service:

    public static void RunSpecificationWay()
    {
        var specification = new SpecificationWayCustomerService();

        Print(specification.GetActiveCustomers());
        Print(specification.GetPremiumCustomers());
        Print(specification.GetActiveAndPremiumCustomers());
        Print(specification.GetActiveOrPremiumCustomers());
        Print(specification.GetInactiveCustomers());
        Print(specification.GetInactiveAndPremiumCustomers());
        Print(specification.GetInactiveOrPremiumCustomers());
        Print(CustomerName, specification.CheckIsActivePremiumCustomerByName(CustomerName));
    }
Enter fullscreen mode Exit fullscreen mode

You should get the same result.
As you can see, this approach has a larger codebase and is more complex. It also comes with a performance cost. So, why use it when you can implement the same logic with less code and complexity?

Before answering that, let’s first compare the performance of each approach.

Performance Comparison

I measured the performance using the DotNetBenchmark library to provide accurate and consistent results.

measure 1

As you can see, the Specification approach performs similarly to the expression-based approach, which has a smaller codebase. However, the straightforward LINQ approach is faster and simpler. Below, I’ll explain how the Specification pattern can still be beneficial in certain scenarios.

More Complex Scenario

In real-world applications, you often work with multiple entities that are part of a more complex structure. To demonstrate this, I’ll extend our data repository to include a related entity.

public record Customer(int Id, string Name, bool IsActive, decimal TotalPurchases, IEnumerable<Payment>? Payments)
{
    public string Name { get; init; } = Name ?? throw new ArgumentNullException(nameof(Name));
    public bool IsActive { get; set; } = IsActive;

    public override string ToString() =>
        $"ID: {Id}, Name: {Name}, Active: {IsActive}, Purchases: {TotalPurchases:C}, Payments: {(Payments != null ? string.Join(", ", Payments.Select(p => $"{p.Method}({(p.IsActive ? "Active" : "Inactive")})")) : "None")}";
}

public record Payment(string Method, bool IsActive);
Enter fullscreen mode Exit fullscreen mode

This entity contains a collection of related entities. Now, I need to filter the data based on this related information. Please update the service accordingly:

public class LinqWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m, [new("Card", true), new("PayPal", false), new("Crypto", true)]),
        new(2, "Bob", false, 800m, null),
        new(3, "Charlie", true, 200m, [new("Card", false), new("PayPal", false), new("Crypto", true)]),
        new(4, "David", true, 1200m, null),
        new(5, "Eve", false, 2500m, [new("Card", false), new("PayPal", false), new("Crypto", false)])
    ];

    const decimal PremiumThreshold = 1000m;

    public IEnumerable<Customer> GetActiveCustomers()
        => _customers.Where(x => x.IsActive);

    public IEnumerable<Customer> GetPremiumCustomers()
        => _customers.Where(x => x.TotalPurchases >= PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomers()
        => _customers.Where(x => x is { IsActive: true, TotalPurchases: >= PremiumThreshold });

    public IEnumerable<Customer> GetActiveOrPremiumCustomers()
        => _customers.Where(x => x.IsActive || x.TotalPurchases >= PremiumThreshold);

    public IEnumerable<Customer> GetInactiveCustomers()
        => _customers.Where(x => !x.IsActive);

    public IEnumerable<Customer> GetInactiveAndPremiumCustomers()
        => _customers.Where(x => x is { IsActive: false, TotalPurchases: >= PremiumThreshold });

    public IEnumerable<Customer> GetInactiveOrPremiumCustomers()
        => _customers.Where(x => !x.IsActive || x.TotalPurchases >= PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomersWithActivePayments()
        => _customers.Where(x =>
            x is { IsActive: true, TotalPurchases: >= PremiumThreshold, Payments: not null } &&
            x.Payments.Any(y => y is { IsActive: true, Method: "Card" } || y.Method == "Crypto"));

    public IEnumerable<Customer> GetInactiveAndPremiumCustomersWithInactivePayments()
        => _customers.Where(x =>
            x is { IsActive: false, TotalPurchases: >= PremiumThreshold, Payments: not null } &&
            x.Payments.Any(y => y is { IsActive: false }));

    public IEnumerable<Customer> GetCustomersWithoutPayments()
        => _customers.Where(x => x is { Payments: null });

    public bool CheckIsActivePremiumCustomerByName(string name)
    {
        Customer? customer = _customers.FirstOrDefault(x => x.Name == name);

        return customer is { IsActive: true, TotalPurchases: >= PremiumThreshold };
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the queries have become more complex with just one additional related entity. In real-world scenarios, you often deal with multiple related entities, which can result in highly intricate filtering logic.

For testing, I’ve updated the method as follows:

public static void RunLinqWay()
    {
        var linq = new LinqWayCustomerService();

        Print(linq.GetActiveCustomers());
        Print(linq.GetPremiumCustomers());
        Print(linq.GetActiveAndPremiumCustomers());
        Print(linq.GetActiveOrPremiumCustomers());
        Print(linq.GetActiveAndPremiumCustomersWithActivePayments());
        Print(linq.GetInactiveCustomers());
        Print(linq.GetInactiveAndPremiumCustomers());
        Print(linq.GetInactiveOrPremiumCustomers());
        Print(linq.GetInactiveAndPremiumCustomersWithInactivePayments());
        Print(linq.GetCustomersWithoutPayments());
        Print(CustomerName, linq.CheckIsActivePremiumCustomerByName(CustomerName));
    }
Enter fullscreen mode Exit fullscreen mode

The expected result should look like this:

ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)
ID: 3, Name: Charlie, Active: True, Purchases: 200,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Active)
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)
ID: 3, Name: Charlie, Active: True, Purchases: 200,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Active)
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)


ID: 2, Name: Bob, Active: False, Purchases: 800,00 UAH, Payments: None
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 1, Name: Alice, Active: True, Purchases: 1 500,00 UAH, Payments: Card(Active), PayPal(Inactive), Crypto(Active)
ID: 2, Name: Bob, Active: False, Purchases: 800,00 UAH, Payments: None
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None
ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 5, Name: Eve, Active: False, Purchases: 2 500,00 UAH, Payments: Card(Inactive), PayPal(Inactive), Crypto(Inactive)


ID: 2, Name: Bob, Active: False, Purchases: 800,00 UAH, Payments: None
ID: 4, Name: David, Active: True, Purchases: 1 200,00 UAH, Payments: None


Bob is active premium customer: False

Process finished with exit code 0.
Enter fullscreen mode Exit fullscreen mode

With the Expression approach, you'll need to update the filters as follows:

public static class CustomerFilteringExtensions
{
    public static IEnumerable<Customer> FilterActive(this IEnumerable<Customer> customers)
        => customers.Where(c => c.IsActive);

    public static IEnumerable<Customer> FilterInactive(this IEnumerable<Customer> customers)
        => customers.Where(c => !c.IsActive);

    public static IEnumerable<Customer> FilterPremium(this IEnumerable<Customer> customers,
        decimal minimumPurchaseAmount)
        => customers.Where(c => c.TotalPurchases >= minimumPurchaseAmount);

    public static IEnumerable<Customer> FilterActiveOrPremium(this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(c => c.IsActive || c.TotalPurchases >= minAmount);

    public static IEnumerable<Customer> FilterInactiveOrPremium(this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(c => !c.IsActive || c.TotalPurchases >= minAmount);

    public static bool CheckActivePremiumCustomer(this IEnumerable<Customer> customers, string name, decimal minAmount)
    {
        Customer? customer = customers.FirstOrDefault(x => x.Name == name);

        return customer is { IsActive: true } && customer.TotalPurchases >= minAmount;
    }

    public static IEnumerable<Customer> CheckActiveAndPremiumCustomersWithActivePayments(
        this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(x =>
            x is { IsActive: true, Payments: not null } && x.TotalPurchases >= minAmount &&
            x.Payments.Any(y => y is { IsActive: true, Method: "Card" } || y.Method == "Crypto"));

    public static IEnumerable<Customer> CheckInactiveAndPremiumCustomersWithInactivePayments(
        this IEnumerable<Customer> customers, decimal minAmount)
        => customers.Where(x =>
            x is { IsActive: false, Payments: not null } && x.TotalPurchases >= minAmount &&
            x.Payments.Any(y => y is { IsActive: false }));

    public static IEnumerable<Customer> CheckCustomersWithoutPayments(this IEnumerable<Customer> customers)
        => customers.Where(x => x is { Payments: null });
}
Enter fullscreen mode Exit fullscreen mode

Additionally, you'll need to add new requests to the service as follows:

public class ExpressionWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m, [new("Card", true), new("PayPal", false), new("Crypto", true)]),
        new(2, "Bob", false, 800m, null),
        new(3, "Charlie", true, 200m, [new("Card", false), new("PayPal", false), new("Crypto", true)]),
        new(4, "David", true, 1200m, null),
        new(5, "Eve", false, 2500m, [new("Card", false), new("PayPal", false), new("Crypto", false)])
    ];

    const decimal PremiumThreshold = 1000m;

    public IEnumerable<Customer> GetActiveCustomers()
        => _customers.FilterActive();

    public IEnumerable<Customer> GetPremiumCustomers()
        => _customers.FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomers()
        => _customers.FilterActive().FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetActiveOrPremiumCustomers()
        => _customers.FilterActiveOrPremium(PremiumThreshold);

    public IEnumerable<Customer> GetInactiveCustomers()
        => _customers.FilterInactive();

    public IEnumerable<Customer> GetInactiveAndPremiumCustomers()
        => _customers.FilterInactive().FilterPremium(PremiumThreshold);

    public IEnumerable<Customer> GetInactiveOrPremiumCustomers()
        => _customers.FilterInactiveOrPremium(PremiumThreshold);

    public IEnumerable<Customer> GetActiveAndPremiumCustomersWithActivePayments()
        => _customers.CheckActiveAndPremiumCustomersWithActivePayments(PremiumThreshold);

    public IEnumerable<Customer> GetInactiveAndPremiumCustomersWithInactivePayments()
        => _customers.CheckInactiveAndPremiumCustomersWithInactivePayments(PremiumThreshold);

    public IEnumerable<Customer> GetCustomersWithoutPayments()
        => _customers.CheckCustomersWithoutPayments();

    public bool CheckIsActivePremiumCustomerByName(string name)
        => _customers.CheckActivePremiumCustomer(name, PremiumThreshold);
}
Enter fullscreen mode Exit fullscreen mode

In general, to extend this functionality, you’ll need to make updates in two key places.

For testing, update this method:

public static void RunExpressionWay()
    {
        var expression = new ExpressionWayCustomerService();

        Print(expression.GetActiveCustomers());
        Print(expression.GetPremiumCustomers());
        Print(expression.GetActiveAndPremiumCustomers());
        Print(expression.GetActiveOrPremiumCustomers());
        Print(expression.GetActiveAndPremiumCustomersWithActivePayments());
        Print(expression.GetInactiveCustomers());
        Print(expression.GetInactiveAndPremiumCustomers());
        Print(expression.GetInactiveOrPremiumCustomers());
        Print(expression.GetInactiveAndPremiumCustomersWithInactivePayments());
        Print(expression.GetCustomersWithoutPayments());
        Print(CustomerName, expression.CheckIsActivePremiumCustomerByName(CustomerName));
    }
Enter fullscreen mode Exit fullscreen mode

To extend the Specification pattern, additional updates are required. First, you'll need to create new specifications that define the new filtering logic.

public class ActiveCardOrCryptoPaymentSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
    {
        return customer => customer.Payments != null &&
                           customer.Payments.Any(p => p.IsActive && (p.Method == "Card" || p.Method == "Crypto"));
    }
}

public class HasActivePaymentSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
    {
        return customer => customer.Payments != null &&
                           customer.Payments.Any(p => p.IsActive);
    }
}

public class HasInactivePaymentSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
    {
        return customer => customer.Payments != null &&
                           customer.Payments.Any(p => !p.IsActive);
    }
}

public class HasPaymentsSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
    {
        return customer => customer.Payments != null;
    }
}

public class NoPaymentsSpecification : Specification<Customer>
{
    public override Expression<Func<Customer, bool>> ToExpression()
    {
        return customer => customer.Payments == null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Additionally, you’ll need to make substantial changes to the service:

public class SpecificationWayCustomerService
{
    private readonly IEnumerable<Customer> _customers =
    [
        new(1, "Alice", true, 1500m, [new("Card", true), new("PayPal", false), new("Crypto", true)]),
        new(2, "Bob", false, 800m, null),
        new(3, "Charlie", true, 200m, [new("Card", false), new("PayPal", false), new("Crypto", true)]),
        new(4, "David", true, 1200m, null),
        new(5, "Eve", false, 2500m, [new("Card", false), new("PayPal", false), new("Crypto", false)])
    ];

    private static readonly ActiveCustomerSpecification ActiveSpec = new();
    private static readonly PremiumCustomerSpecification PremiumSpec = new();
    private static readonly CustomerNameSpecification NameSpec = new();
    private static readonly HasPaymentsSpecification HasPaymentsSpec = new();
    private static readonly NoPaymentsSpecification NoPaymentsSpec = new();
    private static readonly ActiveCardOrCryptoPaymentSpecification ActiveCardOrCryptoSpec = new();
    private static readonly HasActivePaymentSpecification HasActivePaymentSpec = new();
    private static readonly HasInactivePaymentSpecification HasInactivePaymentSpec = new();

    private static readonly Func<Customer, bool> ActiveFunc = ActiveSpec.GetCompiledExpression();
    private static readonly Func<Customer, bool> PremiumFunc = PremiumSpec.GetCompiledExpression();

    private static readonly Func<Customer, bool> ActiveAndPremiumFunc =
        (ActiveSpec & PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> ActiveOrPremiumFunc =
        (ActiveSpec | PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveFunc = (!ActiveSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveAndPremiumFunc =
        (!ActiveSpec & PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactiveOrPremiumFunc =
        (!ActiveSpec | PremiumSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> NoPaymentsFunc = NoPaymentsSpec.GetCompiledExpression();

    private static readonly Func<Customer, bool> ActivePremiumWithActivePaymentsFunc =
        (ActiveSpec & PremiumSpec & HasPaymentsSpec & ActiveCardOrCryptoSpec).GetCompiledExpression();

    private static readonly Func<Customer, bool> InactivePremiumWithInactivePaymentsFunc =
        (!ActiveSpec & PremiumSpec & HasPaymentsSpec & HasInactivePaymentSpec).GetCompiledExpression();

    public IEnumerable<Customer> GetActiveCustomers() => _customers.Where(ActiveFunc);
    public IEnumerable<Customer> GetPremiumCustomers() => _customers.Where(PremiumFunc);
    public IEnumerable<Customer> GetActiveAndPremiumCustomers() => _customers.Where(ActiveAndPremiumFunc);
    public IEnumerable<Customer> GetActiveOrPremiumCustomers() => _customers.Where(ActiveOrPremiumFunc);
    public IEnumerable<Customer> GetInactiveCustomers() => _customers.Where(InactiveFunc);
    public IEnumerable<Customer> GetInactiveAndPremiumCustomers() => _customers.Where(InactiveAndPremiumFunc);
    public IEnumerable<Customer> GetInactiveOrPremiumCustomers() => _customers.Where(InactiveOrPremiumFunc);

    public IEnumerable<Customer> GetActiveAndPremiumCustomersWithActivePayments()
        => _customers.Where(ActivePremiumWithActivePaymentsFunc);

    public IEnumerable<Customer> GetInactiveAndPremiumCustomersWithInactivePayments()
        => _customers.Where(InactivePremiumWithInactivePaymentsFunc);

    public IEnumerable<Customer> GetCustomersWithoutPayments()
        => _customers.Where(NoPaymentsFunc);

    public bool CheckIsActivePremiumCustomerByName(string name)
    {
        NameSpec.Name = name;
        var combinedSpec = NameSpec & ActiveSpec & PremiumSpec;
        return _customers.Any(combinedSpec.IsSatisfiedBy);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this case, I’ve added new specifications and combined the queries.
For testing, update the method as follows:

public static void RunSpecificationWay()
    {
        var specification = new SpecificationWayCustomerService();

        Print(specification.GetActiveCustomers());
        Print(specification.GetPremiumCustomers());
        Print(specification.GetActiveAndPremiumCustomers());
        Print(specification.GetActiveOrPremiumCustomers());
        Print(specification.GetActiveAndPremiumCustomersWithActivePayments());
        Print(specification.GetInactiveCustomers());
        Print(specification.GetInactiveAndPremiumCustomers());
        Print(specification.GetInactiveOrPremiumCustomers());
        Print(specification.GetInactiveAndPremiumCustomersWithInactivePayments());
        Print(specification.GetCustomersWithoutPayments());
        Print(CustomerName, specification.CheckIsActivePremiumCustomerByName(CustomerName));
    }
Enter fullscreen mode Exit fullscreen mode

Final Comparison

I conducted a new benchmark using more complex queries:

measure 2

As you can see, the performance gap between this approach and the first one has narrowed, despite adding just one entity and three new filters. In more complex scenarios, however, the performance will likely degrade further.

Conclusion

Let’s review the pros and cons of the Specification pattern.

Pros

  • Enables reuse of filters
  • Facilitates building simple queries and combining them into more complex ones
  • Treats each query as a separate class, improving readability
  • Provides good performance in more complex scenarios

Cons

  • More complex to implement and maintain
  • Requires familiarity with Expressions
  • Involves writing more code
  • Incorrect implementation can negatively impact performance

When to Use:

Use the Specification pattern in large projects with complex data structures.

When to Avoid:

Avoid it in small projects, with straightforward data models, or simple filtering needs.

Thanks for reading! I hope this article was helpful. See you next time, and happy coding!

You can find the source code for this example here.

Buy Me A Beer

Top comments (0)