Business rules have a nasty habit of multiplying. What starts as a simple if check grows into nested conditionals scattered across handlers, middleware, and service layers. The Specification Pattern is a lightweight approach that turns these rules into first-class, composable objects. Here’s how it works in Go and why it might be the cleanest refactoring you do this month.
The Problem: Conditionals That Breed
Consider a typical eligibility check. A user can access a premium feature if they have an active subscription, their account isn’t suspended, and they’ve completed onboarding. Straightforward enough — until product adds more rules: the user must be in a supported region, their account must be older than 30 days, and they can’t be on a trial that expired within the last week.
func CanAccessPremiumFeature(user *User) bool {
if user.Subscription.Status != "active" {
return false
}
if user.Account.Suspended {
return false
}
if !user.Onboarding.Complete {
return false
}
if !user.Region.IsSupported {
return false
}
if user.Account.AgeDays() < 30 {
return false
}
if user.Subscription.IsTrial && user.Subscription.TrialExpiredRecently() {
return false
}
return true
}
Readable, but fragile. This exact sequence of checks might be needed in three different handlers. When a new rule arrives, you get to find and update every copy. And good luck testing individual rules in isolation.
The Specification Interface
The Specification Pattern starts with a dead-simple interface:
type Specification[T any] interface {
IsSatisfiedBy(candidate T) bool
}
Each business rule becomes a struct implementing this interface. Using a generic type parameter means the same pattern works for users, orders, documents — anything.
type HasActiveSubscription struct{}
func (HasActiveSubscription) IsSatisfiedBy(user *User) bool {
return user.Subscription.Status == "active"
}
type NotSuspended struct{}
func (NotSuspended) IsSatisfiedBy(user *User) bool {
return !user.Account.Suspended
}
type CompletedOnboarding struct{}
func (CompletedOnboarding) IsSatisfiedBy(user *User) bool {
return user.Onboarding.Complete
}
type InSupportedRegion struct {
Supported map[string]bool
}
func (s InSupportedRegion) IsSatisfiedBy(user *User) bool {
return s.Supported[user.Region.Code]
}
Each specification is independently testable. You can unit test InSupportedRegion without standing up a database or constructing a full user object with every field populated.
Composing Specifications
The real power comes from composition. Instead of hard-coding which rules apply, you combine specifications at runtime using logical operators:
type AndSpecification[T any] struct {
left, right Specification[T]
}
func (a AndSpecification[T]) IsSatisfiedBy(candidate T) bool {
return a.left.IsSatisfiedBy(candidate) &&
a.right.IsSatisfiedBy(candidate)
}
type OrSpecification[T any] struct {
left, right Specification[T]
}
func (o OrSpecification[T]) IsSatisfiedBy(candidate T) bool {
return o.left.IsSatisfiedBy(candidate) ||
o.right.IsSatisfiedBy(candidate)
}
type NotSpecification[T any] struct {
inner Specification[T]
}
func (n NotSpecification[T]) IsSatisfiedBy(candidate T) bool {
return !n.inner.IsSatisfiedBy(candidate)
}
Convenience constructors make the API fluent:
func And[T any](left, right Specification[T]) Specification[T] {
return AndSpecification[T]{left: left, right: right}
}
func Or[T any](left, right Specification[T]) Specification[T] {
return OrSpecification[T]{left: left, right: right}
}
func Not[T any](inner Specification[T]) Specification[T] {
return NotSpecification[T]{inner: inner}
}
Putting It Together
Now the premium access check becomes a composition of named, reusable specifications. Since And takes exactly two arguments, chain them for multiple rules:
func PremiumAccessSpec() Specification[*User] {
isTrial := func(u *User) bool {
return u.Subscription.IsTrial
}
expired := func(u *User) bool {
return u.Subscription.TrialExpiredRecently()
}
accountOldEnough := func(u *User) bool {
return u.Account.AgeDays() >= 30
}
return And[*User](
HasActiveSubscription{},
And[*User](
NotSuspended{},
And[*User](
CompletedOnboarding{},
And[*User](
InSupportedRegion{Supported: map[string]bool{
"US": true, "EU": true, "UK": true,
}},
And[*User](
accountOldEnough,
Not(And[*User](isTrial, expired)),
),
),
),
),
)
}
Wait — isTrial and expired are plain functions, not structs. That works because functions are valid implementations of single-method interfaces in Go. For quick, one-off rules, a function is often cleaner than defining a new struct. For rules that carry configuration (like InSupportedRegion), a struct with fields is the better fit.
Using Specifications in Handlers
With the access policy defined as a single value, injecting it into a handler is straightforward:
type PremiumHandler struct {
accessSpec Specification[*User]
}
func NewPremiumHandler(spec Specification[*User]) *PremiumHandler {
return &PremiumHandler{accessSpec: spec}
}
func (h *PremiumHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r.Context())
if !h.accessSpec.IsSatisfiedBy(user) {
http.Error(w, "access denied", http.StatusForbidden)
return
}
// serve premium content
}
Testing this handler means creating a mock specification — just a struct that returns true or false — rather than constructing elaborate user objects that satisfy a chain of internal checks. The spec itself gets its own test suite, completely decoupled from HTTP concerns.
Dynamic Rule Configuration
Specifications really shine when rules need to vary by context. Imagine different access policies for different tiers:
var tierSpecs = map[string]Specification[*User]{
"basic": And[*User](HasActiveSubscription{}, NotSuspended{}),
"pro": And[*User](
HasActiveSubscription{},
And[*User](
NotSuspended{},
And[*User](
CompletedOnboarding{},
accountAgeRequirement(30),
),
),
),
"enterprise": And[*User](
HasActiveSubscription{},
CompletedOnboarding{},
),
}
func accountAgeRequirement(days int) Specification[*User] {
return func(u *User) bool {
return u.Account.AgeDays() >= days
}
}
func GetAccessSpec(tier string) Specification[*User] {
spec, ok := tierSpecs[tier]
if !ok {
return tierSpecs["basic"]
}
return spec
}
Rules that once lived as if-else chains in a handler now live in a data structure. Adding a new tier or modifying an existing one doesn't require touching handler code at all.
Beyond Booleans: Querying with Specifications
Specifications aren't limited to simple true/false checks. They translate naturally into database queries. An OrderSpecification that filters orders can compose the same way, then produce a WHERE clause:
type OrderSpec struct {
spec Specification[*Order]
}
func (os OrderSpec) ToWhereClause(db *sql.DB) (string, []any, error) {
// Walk the specification tree and build a SQL WHERE clause
// based on the composed rules.
// This can also produce query builder calls for ORMs.
}
The same And/Or/Not composition that works for in-memory checks drives your database queries. Rules stay consistent whether you're filtering a loaded slice or pushing predicates into SQL. This is where the pattern earns its keep in production systems — one rule definition, multiple execution strategies.
When the Pattern Fits (and When It Doesn't)
The Specification Pattern works best when business rules are:
- Combinable — rules mix and match in different contexts (different tiers, regions, or feature flags)
- Frequently changing — product keeps adding or modifying eligibility criteria
- Reusable — the same rule or combination appears in multiple places
It's overkill for a single static check that never changes and only exists in one place. Not every if needs to become a specification. But the moment you're copying the same conditional block into a second handler, that's the signal.
The pattern's strength isn't abstraction for its own sake — it's giving business rules a name, a type, and a test boundary. Three things that nested conditionals can never provide.