8.8 KiB
Specification Pattern in Go - Detailed Explanation
What is the Specification Pattern?
The Specification Pattern is a design pattern that allows you to encapsulate business rules and domain logic in reusable, composable objects. Instead of scattering business rules throughout your codebase, you create specification objects that represent individual rules that can be combined using logical operations (AND, OR, NOT).
Why Use It?
- Single Source of Truth: Business rules are defined in one place
- Reusability: Rules can be reused across different parts of the application
- Composability: Simple rules can be combined to create complex rules
- Testability: Each specification can be tested in isolation
- Flexibility: Easy to modify or extend business rules
How It Works in Our Go Implementation
Core Components
1. Specification Interface
type Specification[T any] interface {
IsSatisfiedBy(candidate T) bool
And(other Specification[T]) Specification[T]
Or(other Specification[T]) Specification[T]
Not() Specification[T]
GetConditions() []Condition
GetSpec() *Spec
}
This interface defines the contract that all specifications must implement. The generic type T allows specifications to work with any type of object.
2. SimpleSpecification - The Workhorse
type SimpleSpecification[T any] struct {
spec *Spec
}
This is the main implementation that evaluates conditions against objects using reflection.
3. Condition Structure
type Condition struct {
Field string // Field name to check (e.g., "expirationDate")
Operator string // Comparison operator (e.g., "<=", "==", ">")
Value interface{} // Value to compare against
}
4. Spec Structure - The Building Block
type Spec struct {
Condition *Condition // Single condition
LogicalGroup *LogicalGroup // Group of conditions (AND/OR/NOT)
}
How It Evaluates Conditions
The magic happens in the getFieldValue method. When you specify a condition like Lte("expirationDate", currentTime), the system needs to:
- Find the field: Look for a getter method like
ExpirationDate()orGetExpirationDate() - Extract the value: Call the method to get the actual value
- Compare values: Use the appropriate comparison based on the operator
Field Resolution Strategy
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) {
// 1. Try "GetFieldName" method first
getterName := "Get" + strings.Title(fieldName)
method := v.MethodByName(getterName)
// 2. Try field name as method (e.g., "ExpirationDate")
method = v.MethodByName(fieldName)
// 3. Try direct field access (for exported fields only)
field := v.FieldByName(fieldName)
if field.IsValid() && field.CanInterface() {
return field.Interface(), nil
}
}
This approach handles different naming conventions and respects Go's visibility rules.
Logical Operations
AND Operation
func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] {
if otherSpec, ok := other.(*SimpleSpecification[T]); ok {
// Both are SimpleSpecifications - combine their specs
return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec))
}
// Different types - create a composite specification
return &CompositeSpecification[T]{
left: s,
right: other,
op: "AND",
}
}
CompositeSpecification - Handling Mixed Types
When you try to combine different specification types, instead of panicking, we create a CompositeSpecification that:
- Stores both specifications: Keeps references to both left and right specs
- Evaluates independently: Calls
IsSatisfiedByon each specification - Combines results: Applies the logical operation (AND/OR) to the results
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool {
switch c.op {
case "AND":
return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate)
case "OR":
return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate)
}
}
Building Specifications
We provide convenient helper functions that mirror the PHP/NestJS implementations:
// Simple conditions
spec := Eq("name", "Apple") // name == "Apple"
spec := Gt("price", 10.0) // price > 10.0
spec := Lte("expirationDate", time) // expirationDate <= time
// Logical combinations
spec := And(
Eq("status", "active"),
Or(
Gt("score", 80),
In("role", []interface{}{"admin", "moderator"})
),
Not(Eq("deleted", true))
)
Real-World Usage: ItemExpirationSpec
Here's how we use it to check if items are expired:
func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool {
return s.GetSpec(currentTime).IsSatisfiedBy(item)
}
func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] {
// Create condition: expirationDate <= currentTime
spec := Lte("expirationDate", currentTime)
return NewSimpleSpecification[*entities.ItemEntity](spec)
}
Repository Integration
The specification integrates seamlessly with repositories:
func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
var filteredItems []*entities.ItemEntity
for _, item := range items {
if spec.IsSatisfiedBy(item) {
filteredItems = append(filteredItems, item)
}
}
return filteredItems, nil
}
Key Benefits of This Implementation
1. Type Safety
Using generics (Specification[T any]) ensures type safety at compile time.
2. Flexibility
- Supports different field access patterns (getters, direct fields)
- Handles various data types (strings, numbers, dates, etc.)
- Allows complex nested conditions
3. No Panic Zone
This implementation gracefully handles mixed types using CompositeSpecification.
4. Reflection Safety
The field resolution respects Go's visibility rules and provides clear error messages when fields can't be accessed.
5. Performance
- Caches reflection information when possible
- Short-circuits evaluation (stops at first false in AND, first true in OR)
- Minimal overhead for simple conditions
Common Patterns and Examples
Checking Expired Items
expirationSpec := NewItemExpirationSpec()
isExpired := expirationSpec.IsExpired(item, time.Now())
Finding Active Users
activeUserSpec := And(
Eq("status", "active"),
Gte("lastLogin", thirtyDaysAgo),
Not(Eq("role", "banned"))
)
Complex Business Rules
premiumCustomerSpec := Or(
And(
Eq("subscriptionType", "premium"),
Gte("subscriptionEndDate", time.Now()),
),
And(
Eq("totalPurchases", 1000),
Gte("customerSince", oneYearAgo),
),
)
Troubleshooting Common Issues
1. Field Not Found
Error: field expirationDate not found or not accessible
Solution: Ensure your entity has a getter method like ExpirationDate() or GetExpirationDate()
2. Type Mismatch
Error: Comparison fails between different types Solution: The system tries to convert types automatically, but ensure your condition values match the expected field types
3. Performance Concerns
Issue: Reflection is slower than direct field access Solution: For performance-critical paths, consider implementing custom specifications or caching results
Best Practices
- Use getter methods: Prefer
GetFieldName()orFieldName()methods over direct field access - Keep conditions simple: Complex business logic should be in domain services, not specifications
- Test specifications: Write unit tests for your specifications to ensure they work correctly
- Document business rules: Add comments explaining what each specification represents
- Reuse specifications: Create specification factories for commonly used rules
Comparison with PHP/NestJS
Our Go implementation maintains the same API surface as the PHP and NestJS versions:
- Same helper functions:
Eq(),Gt(),Lte(),And(),Or(),Not() - Same condition structure:
[field, operator, value] - Same logical operations: AND, OR, NOT combinations
- Same usage patterns: Build specs, then call
IsSatisfiedBy()
The main difference is that Go's static typing and lack of runtime metadata requires more sophisticated reflection handling, which we've encapsulated in the getFieldValue method.
This implementation provides a robust, production-ready specification pattern that follows Go best practices while maintaining compatibility with the PHP and NestJS implementations.