Multiple implementations of the same back-end application. The aim is to provide quick, side-by-side comparisons of different technologies (languages, frameworks, libraries) while preserving consistent business logic across all implementations.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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?

  1. Single Source of Truth: Business rules are defined in one place
  2. Reusability: Rules can be reused across different parts of the application
  3. Composability: Simple rules can be combined to create complex rules
  4. Testability: Each specification can be tested in isolation
  5. 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:

  1. Find the field: Look for a getter method like ExpirationDate() or GetExpirationDate()
  2. Extract the value: Call the method to get the actual value
  3. 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:

  1. Stores both specifications: Keeps references to both left and right specs
  2. Evaluates independently: Calls IsSatisfiedBy on each specification
  3. 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

  1. Use getter methods: Prefer GetFieldName() or FieldName() methods over direct field access
  2. Keep conditions simple: Complex business logic should be in domain services, not specifications
  3. Test specifications: Write unit tests for your specifications to ensure they work correctly
  4. Document business rules: Add comments explaining what each specification represents
  5. 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.