DEV Community

Bala Paranj
Bala Paranj

Posted on

5 Factory Patterns in Go That Aren't the Gang of Four

Function-type factories, strategy factories, registration factories, functional options, and null object factories — how a Go CLI uses factory patterns.

Go doesn't have abstract classes. It doesn't have constructor overloading. But Go has factories. They just look different — function types instead of classes, closures instead of inheritance, switch statements instead of visitor patterns. Here are five factory patterns from a Go security CLI, each solving a real architectural problem without the ceremony.

1. Function-Type Factories — Deferred Construction

The Problem

The CLI layer knows how to create a YAML control loader (it has the filesystem paths and config). The app layer needs a control loader but must not import CLI packages (hexagonal architecture). How do you pass "the ability to create a loader" without passing the loader itself?

The Pattern

Define the factory as a function type:

// compose/infra.go — shared type definitions

// CtlRepoFactory creates a control repository for loading control definitions.
type CtlRepoFactory = func() (appcontracts.ControlRepository, error)

// ObsRepoFactory creates an observation repository for loading snapshots.
type ObsRepoFactory = func() (appcontracts.ObservationRepository, error)

// CELEvaluatorFactory creates a CEL predicate evaluator.
type CELEvaluatorFactory = func() (policy.PredicateEval, error)
Enter fullscreen mode Exit fullscreen mode

The CLI layer creates the concrete factory by closing over its dependencies:

// cmd/commands.go — wiring at the composition root

provider := &compose.Provider{
    ObsRepoFunc: func() (appcontracts.ObservationRepository, error) {
        return observations.NewObservationLoader(), nil
    },
    ControlRepoFunc: func() (appcontracts.ControlRepository, error) {
        return ctlyaml.NewControlLoader(validator), nil
    },
    CELEvalFunc: func() (policy.PredicateEval, error) {
        return stavecel.NewPredicateEval()
    },
}
Enter fullscreen mode Exit fullscreen mode

Commands receive factories, not instances:

// cmd/enforce/fix/cmd.go
type LoopDeps struct {
    NewCELEvaluator compose.CELEvaluatorFactory
    NewCtlRepo      compose.CtlRepoFactory
    NewObsRepo      compose.ObsRepoFactory
}
Enter fullscreen mode Exit fullscreen mode

The command calls the factory when it needs the dependency — not at construction time:

func (r *loopRunner) Run(ctx context.Context, req LoopRequest) error {
    ctlRepo, err := r.deps.NewCtlRepo()  // Created on demand
    if err != nil {
        return fmt.Errorf("create control loader: %w", err)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Why Function Types, Not Interfaces

Go interfaces are for behavior — methods that do something meaningful. A factory does one thing: create an instance. A func() (T, error) is the perfect type for this — it's self-documenting, requires no implementation file, and is mockable in tests:

// In tests: inline factory, no mock struct needed
deps := LoopDeps{
    NewCtlRepo: func() (appcontracts.ControlRepository, error) {
        return &stubRepo{controls: testControls}, nil
    },
}
Enter fullscreen mode Exit fullscreen mode

Compare with an interface-based factory:

// Unnecessary ceremony
type ControlRepoFactory interface {
    CreateControlRepo() (appcontracts.ControlRepository, error)
}

type defaultControlRepoFactory struct {
    validator *contractvalidator.Validator
}

func (f *defaultControlRepoFactory) CreateControlRepo() (appcontracts.ControlRepository, error) {
    return ctlyaml.NewControlLoader(f.validator), nil
}
Enter fullscreen mode Exit fullscreen mode

Three types and a file to do what a function literal does in one line.

2. Strategy Factory — Runtime Polymorphism

The Problem

The evaluation engine handles five control types — each with different evaluation logic (state checks, duration thresholds, recurrence windows, prefix exposure, unsupported). The engine needs to select the right evaluator at runtime based on the control's type field.

The Pattern

A factory function that maps a discriminator to an implementation:

func buildStrategy(deps strategyDeps, ctl *policy.ControlDefinition) strategy {
    switch ctl.Type {
    case policy.TypeUnsafeState:
        return &unsafeStateStrategy{deps: deps, ctl: ctl}
    case policy.TypeUnsafeDuration:
        return &unsafeDurationStrategy{deps: deps, ctl: ctl}
    case policy.TypeUnsafeRecurrence:
        return &unsafeRecurrenceStrategy{deps: deps, ctl: ctl}
    case policy.TypePrefixExposure:
        return &prefixExposureStrategy{ctl: ctl}
    default:
        return &unsupportedStrategy{ctl: ctl}
    }
}
Enter fullscreen mode Exit fullscreen mode

The strategy interface defines the contract:

type strategy interface {
    Evaluate(t *asset.ExposureLifecycle, now time.Time, ids IdentityIndex) (evaluation.ResourceCheck, []*evaluation.Finding)
}
Enter fullscreen mode Exit fullscreen mode

The caller doesn't know which strategy it got:

func (s *assessmentSession) applyControl(ctl *policy.ControlDefinition, lifecycles map[asset.ID]*asset.ExposureLifecycle) {
    strat := s.strategyFor(ctl)  // Factory call

    for _, id := range assetIDs {
        check, findings := strat.Evaluate(lifecycle, s.auditTime, s.idIndex)
        // ... record results
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Switch, Not Map

A map[ControlType]func() strategy would work but adds indirection without benefit. The switch:

  • Is exhaustive — the compiler warns about unhandled enum values (with exhaustive linter)
  • Passes different arguments to different strategies (prefixExposureStrategy doesn't need deps)
  • Is readable — a security researcher can see all five strategies in one place
  • Compiles to a jump table — same performance as a map without the allocation

The Session Wrapper

The factory takes a strategyDeps interface, not the concrete Assessor. This allows the evaluation session to inject the active trace span without changing the strategy interface:

func (s *assessmentSession) strategyFor(ctl *policy.ControlDefinition) strategy {
    return buildStrategy(
        &sessionDeps{Assessor: s.assessor, span: s.activeSpan},
        ctl,
    )
}
Enter fullscreen mode Exit fullscreen mode

The strategy doesn't know about tracing. It calls s.deps.currentSpan().RecordStep(...) through the dependency interface. The factory handles the wiring.

3. Registration Factory — Deferred Instantiation for Test Isolation

The Problem

14 security controls register themselves during package initialization via init() functions. Tests can't create isolated registries — every test that imports the compliance package gets all 14 controls loaded into a shared global.

The Pattern

Store factory closures, not instances:

var allControlConstructors []func() Control

func RegisterControl(factory func() Control) {
    allControlConstructors = append(allControlConstructors, factory)
    ControlRegistry.MustRegister(factory())  // Also register in global for production
}
Enter fullscreen mode Exit fullscreen mode

Each control registers a factory, not itself:

func init() {
    RegisterControl(func() Control {
        return &accessBlockPublic{
            Definition: NewDefinition(
                WithID("ACCESS.001"),
                WithSeverity(policy.SeverityCritical),
                WithComplianceProfiles("hipaa", "pci-dss", "cis-s3"),
            ),
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Tests create isolated catalogs by calling each factory:

func NewTestCatalog() *ControlCatalog {
    cat := NewRegistry()
    for i := range allControlConstructors {
        cat.MustRegister(allControlConstructors[i]())
    }
    return cat
}
Enter fullscreen mode Exit fullscreen mode

Each call to allControlConstructors[i]() creates a new instance. Two test catalogs share no state. They can run in parallel without races.

Why Not Just Clone?

The controls have internal state — evaluation results, mutable fields set during Prepare(). Cloning a struct with private fields requires either export (breaks encapsulation) or reflection (fragile). Calling the factory function creates a new instance with a fresh zero state — no cloning needed.

For focused tests that need only one control:

func NewCatalogWith(controls ...Control) *ControlCatalog {
    cat := NewRegistry()
    for _, ctl := range controls {
        cat.MustRegister(ctl)
    }
    return cat
}

// Test: evaluate ACCESS.001 in isolation
cat := NewCatalogWith(&accessBlockPublic{
    Definition: NewDefinition(WithID("ACCESS.001"), ...),
})
Enter fullscreen mode Exit fullscreen mode

4. Functional Options — Flexible Construction Without Overloading

The Problem

A Definition struct has 8 optional fields: ID, description, severity, compliance profiles, compliance refs, profile rationales, profile severity overrides, and scope tags. A constructor with 8 positional parameters is unreadable. A struct literal with exported fields loses validation.

The Pattern

type Option func(*Definition)

func WithID(id kernel.ControlID) Option {
    return func(d *Definition) { d.id = id }
}

func WithSeverity(s policy.Severity) Option {
    return func(d *Definition) { d.severity = s }
}

func WithComplianceRef(profile, citation string) Option {
    return func(d *Definition) {
        if d.complianceRefs == nil {
            d.complianceRefs = make(map[string]string)
        }
        d.complianceRefs[profile] = citation
    }
}

func NewDefinition(opts ...Option) Definition {
    var d Definition
    for _, o := range opts {
        o(&d)
    }
    return d
}
Enter fullscreen mode Exit fullscreen mode

Usage is declarative:

def := NewDefinition(
    WithID("CTL.S3.PUBLIC.001"),
    WithDescription("Block Public Access must be fully enabled"),
    WithSeverity(policy.SeverityCritical),
    WithComplianceProfiles("hipaa", "pci-dss"),
    WithComplianceRef("hipaa", "§164.312(a)(1)"),
    WithProfileRationale("hipaa", "Prevents public exposure of ePHI"),
)
Enter fullscreen mode Exit fullscreen mode

Why This Beats a Builder Struct

A builder struct (DefinitionBuilder) requires a separate type, a Build() method, and usually a validation step. The functional options pattern:

  • Uses the target type directly (no builder intermediary)
  • Options are reusable across constructors (WithSeverity works for both Definition and AssessmentConfig)
  • Adding a new option is one function — no builder method, no interface change
  • Options compose: defaultOpts := []Option{WithSeverity(SeverityHigh)}

The same pattern applies to AssessmentConfig:

cfg := NewConfig(plan,
    WithRuntime(stdout, stderr, clock, version),
    WithMaxUnsafeDuration(168 * time.Hour),
    WithCELEvaluator(celEval),
    WithTracer(tracer),
)
Enter fullscreen mode Exit fullscreen mode

10 optional parameters. Each call site lists only the ones it cares about. The rest get zero-value defaults.

5. Null Object Factory — The Zero-Cost Default

The Problem

The evaluation engine optionally records a logic trace. When tracing is disabled (the common case), every call to span.RecordStep(...) would need a nil check:

// Cluttered: nil checks on every trace point
if s.tracer != nil {
    span := s.tracer.BeginAssessment(resourceID, policyID)
    span.RecordStep("exemption_check", input, result)
    // ...
    span.End()
}
Enter fullscreen mode Exit fullscreen mode

13 instrumentation points × nil check = 13 branches that the CPU predicts as "not taken" 99% of the time. And the code is cluttered with conditional logic that has nothing to do with evaluation.

The Pattern

A null object that satisfies the interface with empty methods:

type nopSpan struct{}

func (nopSpan) RecordStep(string, any, any) {}
func (nopSpan) SetVerdict(string, string)   {}
func (nopSpan) SetFindingID(string)         {}
func (nopSpan) End()                        {}

var _ ports.AssessmentSpan = nopSpan{}
Enter fullscreen mode Exit fullscreen mode

A factory that returns the null object when the real implementation isn't available:

func (s *assessmentSession) beginTrace(resourceID, policyID string) ports.AssessmentSpan {
    if s.assessor.Tracer == nil {
        return nopSpan{}
    }
    return s.assessor.Tracer.BeginAssessment(resourceID, policyID)
}
Enter fullscreen mode Exit fullscreen mode

Now the instrumentation code is unconditional:

span := s.beginTrace(string(id), ctl.ID.String())
span.RecordStep("exemption_check", input, result)
// ... evaluation logic ...
span.SetVerdict(string(check.Verdict), string(check.Confidence))
span.End()
Enter fullscreen mode Exit fullscreen mode

No nil checks. No conditional branches. When tracing is disabled, nopSpan methods are empty — the compiler can inline and eliminate them entirely.

The nopSpan is Stateless

nopSpan is a zero-size struct (struct{}). Allocating it doesn't allocate memory. Returning it from beginTrace doesn't escape to the heap. Calling methods on it compiles to nothing. The cost of tracing disabled is literally zero.

Compare with the alternative — a *fileSpan with a disabled bool:

// Wasteful: allocates a real span just to check a flag
type fileSpan struct {
    disabled bool
    steps    []Step
    // ...
}

func (s *fileSpan) RecordStep(name string, input, result any) {
    if s.disabled { return }
    s.steps = append(s.steps, ...)
}
Enter fullscreen mode Exit fullscreen mode

This allocates memory for the span, initializes fields, and checks a boolean on every call. The null object pattern avoids all of it.

Choosing the Right Factory

Problem Pattern When to Use
I need a dependency later, not now Function-type factory Deferred construction across hexagonal layers
I need different behavior based on a discriminator Strategy factory Runtime polymorphism with a known set of variants
I need isolated instances for testing Registration factory Global registries that tests need to control
I have many optional configuration parameters Functional options Structs with 5+ optional fields
I need a default when the real thing isn't available Null object factory Optional features with zero-cost disabled path

The common thread: every pattern uses Go's type system (functions, interfaces, closures) instead of the class hierarchy that traditional factory patterns assume. No AbstractFactory. No FactoryMethod. No FactoryFactory. Just functions that return the right thing at the right time.


These 5 factory patterns are used across Stave, a Go CLI for offline security evaluation. The function-type factories connect hexagonal layers without import cycles. The registration factories enable parallel test isolation for 14 security controls. The null object factory adds zero overhead when the logic trace is disabled.

Top comments (0)