DEV Community

Cover image for Specifications in Go: Composable Query Predicates Without an ORM Leak
Gabriel Anhaia
Gabriel Anhaia

Posted on

Specifications in Go: Composable Query Predicates Without an ORM Leak


You've seen this repository file. It started as FindAll, FindByID, FindByEmail. Six months later it has FindActiveCustomersInRegionWithOverdueInvoicesOlderThan. The method name is a sentence. Nine arguments, half of them optional pointers, two of them booleans whose meaning you have to grep for. The implementation is a 60-line SQL builder.

That repository did not start broken. It got broken one query at a time, because each new use case demanded a slightly different WHERE clause and the team kept the pattern of "one method per query." Eventually the repository became a thin wrapper around a search API, except the search API was hand-rolled, badly, in dozens of methods.

The Specification pattern is the fix. Originally written up by Eric Evans and Martin Fowler (Specifications, 2003) and popularized through Evans' Domain-Driven Design book, it moves the predicate out of the repository's method name and into a first-class object the domain layer can build, name, and compose. The repository goes back to one method: FindAll(spec). The adapter knows how to translate any spec into SQL.

The Predicate as a Domain Object

A specification is just a named predicate. Active customer. Overdue invoice. Customer in EU region. Each one is something the domain expert says out loud during requirements gathering. Make each one a type.

Generics make this clean in Go 1.18+. The interface has one method.

package spec

type Specification[T any] interface {
    IsSatisfiedBy(t T) bool
}
Enter fullscreen mode Exit fullscreen mode

IsSatisfiedBy is the in-memory check. It's what makes specs testable without a database, and what lets them double as filters over slices when you already have the data in hand.

Three concrete specs in the customer domain:

package customer

import "time"

type Customer struct {
    ID         string
    Email      string
    Status     Status
    SignedUpAt time.Time
    IsEmployee bool
}

type ActiveCustomer struct{}

func (ActiveCustomer) IsSatisfiedBy(c Customer) bool {
    return c.Status == StatusActive
}

type SignedUpAfter struct {
    Cutoff time.Time
}

func (s SignedUpAfter) IsSatisfiedBy(c Customer) bool {
    return c.SignedUpAt.After(s.Cutoff)
}

type Employee struct{}

func (Employee) IsSatisfiedBy(c Customer) bool {
    return c.IsEmployee
}
Enter fullscreen mode Exit fullscreen mode

These read like the requirements doc. Active customer. Signed up after a date. Employee. No SQL, no column names, no WHERE clauses.

Composition: AND, OR, NOT

The pattern earns its keep when you compose. A new use case asks for active customers who signed up in the last 30 days, excluding employees. With method-per-query you'd add a new repository method. With specs, you build it from the pieces that already exist.

package spec

type AndSpec[T any] struct {
    Left, Right Specification[T]
}

func (a AndSpec[T]) IsSatisfiedBy(t T) bool {
    return a.Left.IsSatisfiedBy(t) &&
        a.Right.IsSatisfiedBy(t)
}

func And[T any](
    l, r Specification[T],
) AndSpec[T] {
    return AndSpec[T]{Left: l, Right: r}
}

type OrSpec[T any] struct {
    Left, Right Specification[T]
}

func (o OrSpec[T]) IsSatisfiedBy(t T) bool {
    return o.Left.IsSatisfiedBy(t) ||
        o.Right.IsSatisfiedBy(t)
}

func Or[T any](
    l, r Specification[T],
) OrSpec[T] {
    return OrSpec[T]{Left: l, Right: r}
}

type NotSpec[T any] struct {
    Inner Specification[T]
}

func (n NotSpec[T]) IsSatisfiedBy(t T) bool {
    return !n.Inner.IsSatisfiedBy(t)
}

func Not[T any](s Specification[T]) NotSpec[T] {
    return NotSpec[T]{Inner: s}
}
Enter fullscreen mode Exit fullscreen mode

The fields are exported on purpose. The adapter will need to walk the tree to translate it.

The use case now reads like the requirement.

thirtyDaysAgo := time.Now().AddDate(0, 0, -30)

newActiveNonEmployees := spec.And(
    spec.And(
        customer.ActiveCustomer{},
        customer.SignedUpAfter{Cutoff: thirtyDaysAgo},
    ),
    spec.Not(customer.Employee{}),
)

results, err := repo.FindAll(ctx, newActiveNonEmployees)
Enter fullscreen mode Exit fullscreen mode

That last line is the entire repository call. One method. Any query the domain can name, you can ask for.

The Repository: One Method, Any Query

package customer

import (
    "context"

    "myapp/spec"
)

type Repository interface {
    FindAll(
        ctx context.Context,
        s spec.Specification[Customer],
    ) ([]Customer, error)
}
Enter fullscreen mode Exit fullscreen mode

The interface lives next to the aggregate, in the same package as the domain types. Same dependency-direction rule as any other port: the consumer (the application service) imports customer, the Postgres adapter implements customer.Repository implicitly.

The Adapter: Translating Spec → SQL

IsSatisfiedBy works for in-memory tests. It does not work for a billion-row Postgres table. You can't pull every customer through Go and filter in process. The adapter has to translate the spec tree into a WHERE clause and let the database do the work.

The cleanest way is a second interface — a visitor — that adapters implement. Each leaf spec knows how to render itself into SQL fragments. The translator walks the composite tree and combines fragments.

package spec

type SQLClause struct {
    SQL  string
    Args []any
}

type SQLConvertible interface {
    ToSQL(argOffset int) SQLClause
}
Enter fullscreen mode Exit fullscreen mode

argOffset matters because Postgres uses positional parameters ($1, $2, ...). Each leaf spec needs to know how many parameters came before it so it can number its own.

The leaves are where the domain meets the schema. They live in the adapter package, not the domain. That seam is what keeps the ORM leak from happening.

package postgres

import (
    "fmt"

    "myapp/customer"
    "myapp/spec"
)

type activeCustomerSQL struct{}

func (activeCustomerSQL) ToSQL(off int) spec.SQLClause {
    return spec.SQLClause{
        SQL:  "status = 'active'",
        Args: nil,
    }
}

type signedUpAfterSQL struct {
    inner customer.SignedUpAfter
}

func (s signedUpAfterSQL) ToSQL(
    off int,
) spec.SQLClause {
    return spec.SQLClause{
        SQL:  fmt.Sprintf("signed_up_at > $%d", off+1),
        Args: []any{s.inner.Cutoff},
    }
}

type employeeSQL struct{}

func (employeeSQL) ToSQL(off int) spec.SQLClause {
    return spec.SQLClause{
        SQL:  "is_employee = true",
        Args: nil,
    }
}
Enter fullscreen mode Exit fullscreen mode

The composite glue is also adapter-side. AND, OR, and NOT operate on already-translated children, which is what makes the translator total instead of partial:

package postgres

type andSQL struct{ left, right spec.SQLConvertible }

func (a andSQL) ToSQL(off int) spec.SQLClause {
    l := a.left.ToSQL(off)
    r := a.right.ToSQL(off + len(l.Args))
    return spec.SQLClause{
        SQL:  "(" + l.SQL + " AND " + r.SQL + ")",
        Args: append(l.Args, r.Args...),
    }
}

type orSQL struct{ left, right spec.SQLConvertible }

func (o orSQL) ToSQL(off int) spec.SQLClause {
    l := o.left.ToSQL(off)
    r := o.right.ToSQL(off + len(l.Args))
    return spec.SQLClause{
        SQL:  "(" + l.SQL + " OR " + r.SQL + ")",
        Args: append(l.Args, r.Args...),
    }
}

type notSQL struct{ inner spec.SQLConvertible }

func (n notSQL) ToSQL(off int) spec.SQLClause {
    in := n.inner.ToSQL(off)
    return spec.SQLClause{
        SQL:  "NOT (" + in.SQL + ")",
        Args: in.Args,
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the translator is a single recursive function. It pattern-matches on leaves first, then descends into composites and rewraps them with already-translated children.

func translate(
    s spec.Specification[customer.Customer],
) (spec.SQLConvertible, error) {
    switch v := s.(type) {
    case customer.ActiveCustomer:
        return activeCustomerSQL{}, nil
    case customer.SignedUpAfter:
        return signedUpAfterSQL{inner: v}, nil
    case customer.Employee:
        return employeeSQL{}, nil
    case spec.AndSpec[customer.Customer]:
        l, err := translate(v.Left)
        if err != nil {
            return nil, err
        }
        r, err := translate(v.Right)
        if err != nil {
            return nil, err
        }
        return andSQL{left: l, right: r}, nil
    case spec.OrSpec[customer.Customer]:
        l, err := translate(v.Left)
        if err != nil {
            return nil, err
        }
        r, err := translate(v.Right)
        if err != nil {
            return nil, err
        }
        return orSQL{left: l, right: r}, nil
    case spec.NotSpec[customer.Customer]:
        in, err := translate(v.Inner)
        if err != nil {
            return nil, err
        }
        return notSQL{inner: in}, nil
    }
    return nil, fmt.Errorf(
        "no SQL translation for %T", s,
    )
}
Enter fullscreen mode Exit fullscreen mode

That recursion is the difference between a pattern that ships and a pattern that panics on the second-level composite. The repository becomes a thin shell.

type CustomerRepo struct {
    db *sql.DB
}

func (r *CustomerRepo) FindAll(
    ctx context.Context,
    s spec.Specification[customer.Customer],
) ([]customer.Customer, error) {
    sqlConv, err := translate(s)
    if err != nil {
        return nil, err
    }
    clause := sqlConv.ToSQL(0)
    query := "SELECT id, email, status, signed_up_at, is_employee " +
        "FROM customers WHERE " + clause.SQL
    rows, err := r.db.QueryContext(
        ctx, query, clause.Args...,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    return scanCustomers(rows)
}
Enter fullscreen mode Exit fullscreen mode

The domain layer never sees WHERE, never sees $1, never sees the table name. Switch the adapter to MongoDB and you write a ToBSON method on each leaf and a sibling translate for the BSON tree. The domain code that built the spec doesn't change a line.

Testing the Pattern

Specs are unit-testable without a database. That's the second payoff.

func TestNewActiveNonEmployees(t *testing.T) {
    cutoff := time.Date(
        2026, 1, 1, 0, 0, 0, 0, time.UTC,
    )
    s := spec.And(
        spec.And(
            customer.ActiveCustomer{},
            customer.SignedUpAfter{Cutoff: cutoff},
        ),
        spec.Not(customer.Employee{}),
    )

    fresh := customer.Customer{
        Status:     customer.StatusActive,
        SignedUpAt: cutoff.AddDate(0, 1, 0),
        IsEmployee: false,
    }
    if !s.IsSatisfiedBy(fresh) {
        t.Fatal("expected fresh non-employee to match")
    }

    employee := fresh
    employee.IsEmployee = true
    if s.IsSatisfiedBy(employee) {
        t.Fatal("expected employee to be excluded")
    }
}
Enter fullscreen mode Exit fullscreen mode

No SQL, no fixtures, no test container. The spec is just a function of the customer's fields.

The SQL translation gets its own test, against sqlmock or a real Postgres in a test container, but it tests the adapter layer in isolation. The domain test and the adapter test are decoupled, which is the whole point of hexagonal architecture applied to query logic.

When to Reach for It (and When Not To)

The Specification pattern is not free. It adds a layer. Every leaf spec needs a domain type plus an adapter translation. Composite specs need their visitor methods. For a repository with three queries that will never grow, that's overhead with no payoff.

Use it when:

  • Multiple call sites build different combinations of the same predicates. Active customers shows up in three reports, two cron jobs, and an export. Each report wants it composed differently.
  • The domain expert names predicates as nouns. Overdue invoice. High-value account. Suspended subscription. Those names want to be types.
  • You expect the predicate set to grow. Every new spec is one new struct plus one new adapter method. No new repository signature.
  • You need to filter in memory and in SQL. Specs do both.

Skip it when:

  • The repository has a known, finite set of queries (three, four, five) that will never combine arbitrarily. Add the methods directly. FindActiveByID, ListPending. No abstraction needed.
  • Your queries are heavily aggregated — joins across five tables, GROUP BY, window functions. Specs handle row-level predicates. Aggregations want a different abstraction (a query object, a read model, a view).
  • The team is small and the domain is simple. A 200-line repository with hand-written SQL beats a spec layer plus a translator plus a test pyramid.

Pay the indirection once, get a spec language for free.


If this was useful

Predicate translation, repository design at aggregate granularity, and the seams that keep persistence from leaking into the domain are three chapters of Hexagonal Architecture in Go. The other half of the Thinking in Go series, The Complete Guide to Go Programming, covers the generics and interface mechanics that make this pattern feel native instead of bolted on.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)