Skip to content

Latest commit

 

History

History
206 lines (146 loc) · 10.5 KB

README.md

File metadata and controls

206 lines (146 loc) · 10.5 KB

FluentSpecification

ci NuGet NuGet downloads License: MIT .NET Standard 2.0 Platforms

A lightweight C# implementation of the Specification pattern. Express business rules as small, reusable, composable objects and combine them fluently with And, Or and Not.

Install

dotnet add package IcodeNet.FluentSpecification

The package ID is IcodeNet.FluentSpecification. In code, the namespace stays FluentSpecification:

using FluentSpecification;

Supported platforms: the package targets .NET Standard 2.0. It runs on .NET Framework 4.6.1+, .NET Core 2.0+ and .NET 5+, on Windows, Linux and macOS. It has no dependencies.

Why?

Instead of scattering if conditions and LINQ predicates across your codebase, you express each rule as a named class (or a one-line lambda). Rules stay decoupled from your domain objects and can be combined without touching either.

How it fits together

Candidates flow through a specification and come out as matches

Core Classes

Class Purpose
SpecificationBase<T> Abstract base – implement SatisfyingElementsFrom to define your rule
CompositeSpecification<T> Extends SpecificationBase<T> with And, Or, and Not combinators
FunctionalSpecification<T> Ready-made specification built from a lambda – no subclass needed. In-memory data only
ExpressionSpecification<T> Same, but built from an expression, so databases such as Entity Framework can run it as SQL

Quick Start

1 – Define a named specification

Subclass CompositeSpecification<T> and express the rule in LINQ:

public class OverDueLoanSpecification : CompositeSpecification<Loan>
{
    public override IQueryable<Loan> SatisfyingElementsFrom(IQueryable<Loan> candidates)
    {
        return from l in candidates
               where l.DateTaken < DateTime.Today
               where l.DateTaken.Add(l.LoanPeriod) < DateTime.Now
               select l;
    }
}

2 – Check a single object

var customer = new Customer { FirstName = "Alice", Balance = 500.00 };
var valuedCustomer = new ValuedCustomerSpecification(); // Balance > 1300

bool isValued = valuedCustomer.IsSatisfiedBy(customer); // false

3 – Filter a collection

The OverDueLoanSpecification above works on in-memory data. See the database section below for rules that run as SQL.

var overdueSpec = new OverDueLoanSpecification();
IQueryable<Loan> overdueLoans = overdueSpec.SatisfyingElementsFrom(loans.AsQueryable());

4 – Use a lambda instead of a class

When the rule is simple enough that a dedicated class would be overkill, use FunctionalSpecification<T> (in-memory data) or ExpressionSpecification<T> (databases):

var bigLoanSpec = new FunctionalSpecification<Loan>(l => l.Amount > 10_000.00);
IQueryable<Loan> bigLoans = bigLoanSpec.SatisfyingElementsFrom(loans.AsQueryable());

5 – Use a database

For Entity Framework or any other query provider, use ExpressionSpecification<T>. The provider translates it to SQL:

var bigLoans = new ExpressionSpecification<Loan>(l => l.Amount > 10_000);
var rows = bigLoans.SatisfyingElementsFrom(db.Loans).ToList(); // WHERE Amount > 10000

6 – Combine specifications

And, Or and Not shown as Venn diagrams

Specifications that extend CompositeSpecification<T> (including FunctionalSpecification<T>) expose And, Or, and Not:

var overdueSpec  = new OverDueLoanSpecification();
var bigLoanSpec  = new FunctionalSpecification<Loan>(l => l.Amount > 10_000.00);
var sixDaysSpec  = new FunctionalSpecification<Loan>(l => l.LoanPeriod == TimeSpan.FromDays(6));
var fortDaysSpec = new FunctionalSpecification<Loan>(l => l.LoanPeriod == TimeSpan.FromDays(14));

// AND – overdue AND large
var overdueAndBig = overdueSpec.And(bigLoanSpec);

// OR – 6-day or 14-day loans
var sixOrFourteen = sixDaysSpec.Or(fortDaysSpec);

// NOT – anything that is NOT a 6-day loan
var notSixDays = sixDaysSpec.Not();

// Use just like any other specification
IQueryable<Loan> results = repo.FindElementsBy(overdueAndBig);

Combinators can be chained arbitrarily:

var complex = overdueSpec.And(bigLoanSpec).And(sixDaysSpec.Not());

Repository Integration

Pass a specification into your repository to keep query logic out of your data layer:

public class LoanRepository
{
    public IQueryable<Loan> FindElementsBy(SpecificationBase<Loan> spec)
    {
        // fetch your data source, then delegate filtering to the spec
        return spec.SatisfyingElementsFrom(loans.AsQueryable());
    }
}

// Caller decides the rule – repository stays unchanged
var results = repo.FindElementsBy(overdueSpec.And(bigLoanSpec));

How it works, and what to watch for

Read this before you use the library with a database.

  • Each rule filters a set. SatisfyingElementsFrom takes an IQueryable<T> and returns the items that pass. IsSatisfiedBy checks one item by running the same rule over a one-item set.
  • And, Or and Not use LINQ set operations (Intersect, Union and Except). In memory, items are compared with Equals and GetHashCode. A class without them is compared by reference, so two different objects with the same data count as different items. On a database, the provider compares whole rows (every selected column) and ignores Equals. The same specification can therefore return different results in memory and on a database.
  • The combinators remove duplicates and do not keep order. A single rule keeps duplicates. A combined rule does not. On a database the order of a combined result is not guaranteed, so add OrderBy when order matters.
  • IsSatisfiedBy is fast for the built-in rules. It checks one item directly. A custom rule that only overrides SatisfyingElementsFrom uses the slower default, which runs a query over a one-item set.
  • Databases: use ExpressionSpecification<T>. FunctionalSpecification<T> takes a Func<T, bool> (plain code), which Entity Framework and other query providers cannot translate to SQL. They throw InvalidOperationException when you read the query. ExpressionSpecification<T> takes an expression, so the provider turns it into SQL. A named specification also works, as long as every construct in its LINQ expression is one your provider can translate. For example, DateTime.Add(TimeSpan) in the OverDueLoanSpecification example above works in memory but Entity Framework cannot translate it. If you must use a Func, load the rows first with ToList().AsQueryable(). Note that AsEnumerable().AsQueryable() does not help.
  • Combinators and databases. And, Or and Not use Intersect, Union and Except. With Entity Framework on SQLite they run when every rule is an expression, and the tests cover this. Other providers may behave differently, so test yours.
  • Null arguments fail fast. Passing null to a constructor, a combinator or SatisfyingElementsFrom throws ArgumentNullException straight away.
  • Thread safety. Specifications hold no changing state. They are safe to share across threads if your own predicates are.

API reference

Full XML documentation ships with the package, so your editor shows it as you type.

Member Description
SpecificationBase<T>.IsSatisfiedBy(T) Returns true when one item passes the rule
SpecificationBase<T>.SatisfyingElementsFrom(IQueryable<T>) Abstract. Returns the items that pass the rule
CompositeSpecification<T>.And(other) Both rules must pass
CompositeSpecification<T>.Or(other) Either rule must pass
CompositeSpecification<T>.Not() The rule must not pass
FunctionalSpecification<T>(Func<T, bool>) A rule built from a lambda. In-memory data only
ExpressionSpecification<T>(Expression<Func<T, bool>>) A rule built from an expression. Works with Entity Framework
AndSpecification<T>, OrSpecification<T>, NotSpecification<T> What the combinators return. You rarely create them directly

Troubleshooting

  • SpecificationBase<T> has no And method. Only CompositeSpecification<T> has the combinators. Derive your rule from CompositeSpecification<T>, not from SpecificationBase<T>.
  • Two equal-looking objects are treated as different (in memory). Override Equals and GetHashCode, or use a record. This has no effect on a database, which compares whole rows.
  • InvalidOperationException: "The LINQ expression ... could not be translated". You used FunctionalSpecification<T> on a database query. Switch to ExpressionSpecification<T>.

Samples and tests

FluentSpecification.Samples is a runnable console example (loans and customers):

dotnet run --project FluentSpecification.Samples

FluentSpecification.Tests holds the unit tests:

dotnet test

Contributing, security and licence

See CONTRIBUTING.md, SECURITY.md and CHANGELOG.md. Released under the MIT licence.

Project structure

FluentSpecification/
├── FluentSpecification/          # The NuGet package (netstandard2.0)
├── FluentSpecification.Tests/    # xUnit tests
└── FluentSpecification.Samples/  # Runnable examples (Loan / Customer domain)