Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Sichere ProgrammierungI audited my own ML linter and had to withdraw its best evidence(21.09.2026 um 22:54 Uhr)
Sichere ProgrammierungQuantum Result Validation for Distributed Computing Systems(21.09.2026 um 22:54 Uhr)
Sichere ProgrammierungJWT Authentication and Role-Based Access Control in LocalHands(21.09.2026 um 22:56 Uhr)
Sichere ProgrammierungStochastic Parrot or Alien Mind?(21.09.2026 um 22:56 Uhr)
Sichere ProgrammierungBuilding AI for the Physical World Is a Different Engineering Problem(21.09.2026 um 22:58 Uhr)
Sichere ProgrammierungI audited my own ML linter and had to withdraw its best evidence(21.09.2026 um 22:54 Uhr)
Sichere ProgrammierungQuantum Result Validation for Distributed Computing Systems(21.09.2026 um 22:54 Uhr)
Sichere ProgrammierungJWT Authentication and Role-Based Access Control in LocalHands(21.09.2026 um 22:56 Uhr)
Sichere ProgrammierungStochastic Parrot or Alien Mind?(21.09.2026 um 22:56 Uhr)
Sichere ProgrammierungBuilding AI for the Physical World Is a Different Engineering Problem(21.09.2026 um 22:58 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

Specifications in Go: Composable Query Predicates Without an ORM Leak

Book: Hexagonal Architecture in Go Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other …

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!





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
}






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
}






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}
}






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)






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)
}






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
}






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,
}
}






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,
}
}






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,
)
}






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)
}






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")
}
}






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

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Specifications in Go: Composable Query Predicates Without an ORM Leak

Thematisch verwandte Begriffe: Specifications, Composable, Query, Predicates · 6 Treffer

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-79918 | MaxKB is an open-source AI assistant for enterprise. Prior to version 2.…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick