← All articles

Who Decides What a Search Can Ask

Specifications versus search objects in an EF Core API

Two ways to keep a search screen from turning into a pile of if-statements, which one fits where, and how to use both. One puts each query in a class a developer owns; the other lets the caller pick filters from a fixed menu.
Who Decides What a Search Can Ask

A search screen starts as one optional parameter and grows into a pile of if-statements. Two well-known ways out look alike from a distance, since both put the query in one place, give it a name and test it once, and they differ in one thing: who holds the pen. In the first, a developer writes each query as a class and callers choose which one to run. In the second, the developer writes the menu of filters once and the caller composes any combination from it.

Ardalis.Specification is the first; the SearchObject that the filtering series is built on is the second.

Two lanes. With a specification, the developer writes the query class and the caller only picks it and passes arguments; with a search object, the developer writes the menu once and the caller ticks any combination of its filters. Both end in one IQueryable the database runs, and a named rule such as DealsSpec can be applied inside the search object's builder when the caller asks for it.

Universal or Regira? The Specification half is the Ardalis library (MIT, used in the eShopOnWeb reference app); the SearchObject half is Regira Entities. The comparison is library-independent: any typed-filter-object design and any specification implementation sit on the same two sides.

The sample this post runs, 06-searchobject-vs-specification, is one SQLite table of products and the same questions asked twice, so the two shapes can be compared on identical output. With the .NET 10 SDK installed, dotnet run from its folder recreates and seeds the database and prints the output below.

What a Specification solves

A specification is a developer's description of one query, written once and reused wherever that query is needed: the predicate, the ordering, the includes, sometimes a projection, in one object.

public class ProductsByBrandSpec : Specification<Product>
{
    public ProductsByBrandSpec(int brandId, decimal? minPrice = null)
    {
        Query.Where(p => p.BrandId == brandId);
        if (minPrice != null)
            Query.Where(p => p.Price >= minPrice);
        Query.Include(p => p.Brand).OrderBy(p => p.Price);
    }
}

Applying it is one call: db.Products.WithSpecification(new ProductsByBrandSpec(volvo.Id, 50)). The caller chooses which specification and passes its arguments; it cannot reshape it. The query has a name, one owner, and one place to be wrong.

What a SearchObject solves

A search object is the caller's side of the same query: every filter the API lets a client ask for, named once, with null meaning "not filtered". One small builder translates it, and the pipeline around it adds sorting, paging and counts.

public record ProductSearchObject : SearchObject
{
    public ICollection<int>? BrandId { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public bool? InStock { get; set; }
    public bool? Deals { get; set; }   // a named rule the client may ask for - see "Using both"
}

The same questions, asked twice

Run the sample and the two shapes answer identically:

Volvo, 50 or more
  Specification : Dash cam (89.90), Roof rack (129.00)
  SearchObject  : Dash cam (89.90), Roof rack (129.00)
In stock, under 100
  Specification : Cargo net (24.90), Mud flaps (35.00), Seat cover (59.50), Dash cam (89.90)
  SearchObject  : Cargo net (24.90), Mud flaps (35.00), Seat cover (59.50), Dash cam (89.90)
  count         : Specification 4, SearchObject 4
Everything
  SearchObject  : Cargo net (24.90), Mud flaps (35.00), Floor mats (45.00), Seat cover (59.50), Dash cam (89.90), Roof rack (129.00)
Deals (a named rule), Scania only
  Specification : Mud flaps (35.00), Seat cover (59.50)
  SearchObject  : Mud flaps (35.00), Seat cover (59.50)

The third question is asked only once. An empty search object is the whole table, because null means "not filtered"; an "everything" specification is a class nobody writes. That asymmetry is the comparison in miniature. The fourth question is the two shapes composed, shown under Using both.

Where they overlap

Both compose an IQueryable, let the database do the work, and are pure functions of their inputs: unit-testable against an in-memory list, no database in sight. On a query that a developer writes and a developer calls, such as a report, a job or a domain rule, they are interchangeable, and a team that already has one has no reason to add the other for that.

Where they differ

Who composes. A specification is composed by the developer who wrote it; the caller supplies arguments. A search object is composed by the caller, filter by filter, from the vocabulary the developer wrote. Every other difference follows from this one.

Cardinality. One specification per query: ProductsByBrandSpec, InStockUnderSpec, and a new class for every combination a screen needs. One search object per entity: the same record serves every combination of its filters, including the empty one.

Surface. A specification is internal; nothing outside the process sees it. A search object is the HTTP contract: what the query string binds to, what OpenAPI documents, what the front-end client mirrors so that server and SPA cannot drift apart.

Ordering and includes. A specification carries them, which is a strength for a fixed query and a weakness for a list view whose user sorts by three columns and asks for related rows on demand. A search object carries neither; the pipeline takes sorting and includes as typed enums the client sends (?sortBy=Price&includes=Brand), and paging and counts too.

What the client can ask. Gridify, Sieve and OData $filter give you filtering without a builder per entity: annotate or configure, and clients get a query language for free, which for a thin API over your own tables may be all you need. The trade is what the string buys the client: it can filter on anything you exposed, which is an index-performance question (arbitrary predicates over unindexed columns) and a security surface at once. A typed search object is an allowlist by construction, since a client can only ask what you named, and it shows up in OpenAPI by itself because it is a parameter object. A specification never had to think about this: no client ever reaches it.

When a Specification is the better tool

  • A rule with a name that more than one caller needs: "overdue invoices", "active products", "deals". The nightly reminders job and the report must mean the same thing by it.
  • A query with its includes and ordering baked in: the order-detail endpoint that always loads lines, customer and payments.
  • No HTTP in sight: background work, integration tests, a domain service.
  • An existing repository abstraction: ISpecification<T> replaces the ListByX methods that accumulate there.

When a SearchObject is the better tool

  • A filter panel, a list view, a search box: the user decides which filters combine. The vehicles grid with a brand picker, a status list, a price range and a search box is one record; as specifications it is a class hierarchy.
  • A page that needs its count: /search returns items and total in one response; a specification leaves you to build it twice.
  • A contract that must be visible: OpenAPI, a typed client, a front end that renders the panel from the same shape.
  • A surface a stranger can call: the allowlist above.

Using both

The fourth question is the honest answer to "which one": both, each where it belongs. The client asks for deals=true by name; the search object carries the flag, the builder applies the named rule, and the same rule serves a job on its own.

public class DealsSpec : Specification<Product>
{
    public DealsSpec() { Query.Where(p => p.InStock && p.Price < 60); } // a pure predicate: no ordering, so it composes anywhere
}

// inside the SearchObject's filter
if (so?.Deals == true) query = query.WithSpecification(new DealsSpec());

The seam is clean because the two answer different questions: what does the client want and what does this rule mean. Keep a specification that is meant to compose inside a pipeline to its predicate; an OrderBy inside one would fight the pipeline's sort. "Show me the deals" on a grid, a flag that applies a specification, is the most common shape once an API grows.

Why did Regira Entities choose SearchObjects? Because in a CRUD-heavy line-of-business API the caller is usually a client, not a developer, and the contract has to be visible: bound from the query string, documented by OpenAPI, mirrored by the SPA, safe to expose because it is an allowlist. A specification answers a different question well, and nothing stops you from applying one inside a builder, which is what the sample does.

Docs: Regira Entities — services, query builders, the read pipeline; Ardalis.Specification — the library, its evaluator and repository base. The sample runs on the free tier, without a license key.

Versions: the sample pins Ardalis.Specification.EntityFrameworkCore 9.3.1, Regira.Entities.DependencyInjection 6.4.0 and Microsoft.EntityFrameworkCore.Sqlite 10.0.12.

Built with

Regira.Entities

The filtering, sorting and paging in this post are what Regira Entities gives you for a search object and one registration line.

What Regira Entities covers Entities documentation Free tier & licensing