惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Microsoft Azure Blog
Microsoft Azure Blog
WordPress大学
WordPress大学
小众软件
小众软件
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
美团技术团队
博客园 - 三生石上(FineUI控件)
Last Week in AI
Last Week in AI
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - Franky
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
A
About on SuperTechFans
The GitHub Blog
The GitHub Blog
U
Unit 42
H
Hackread – Cybersecurity News, Data Breaches, AI and More
云风的 BLOG
云风的 BLOG
IT之家
IT之家
MyScale Blog
MyScale Blog
V
Visual Studio Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
I
InfoQ
博客园 - 司徒正美

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Specifications in Go: Composable Query Predicates Without...
Gabriel Anha · 2026-05-01 · via DEV Community

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