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)
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()
},
}
Commands receive factories, not instances:
// cmd/enforce/fix/cmd.go
type LoopDeps struct {
NewCELEvaluator compose.CELEvaluatorFactory
NewCtlRepo compose.CtlRepoFactory
NewObsRepo compose.ObsRepoFactory
}
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)
}
// ...
}
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
},
}
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
}
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}
}
}
The strategy interface defines the contract:
type strategy interface {
Evaluate(t *asset.ExposureLifecycle, now time.Time, ids IdentityIndex) (evaluation.ResourceCheck, []*evaluation.Finding)
}
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
}
}
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
exhaustivelinter) - Passes different arguments to different strategies (
prefixExposureStrategydoesn't needdeps) - 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,
)
}
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
}
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"),
),
}
})
}
Tests create isolated catalogs by calling each factory:
func NewTestCatalog() *ControlCatalog {
cat := NewRegistry()
for i := range allControlConstructors {
cat.MustRegister(allControlConstructors[i]())
}
return cat
}
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"), ...),
})
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
}
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"),
)
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 (
WithSeverityworks for bothDefinitionandAssessmentConfig) - 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),
)
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()
}
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{}
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)
}
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()
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, ...)
}
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)