The textbook builder pattern has a Director, a Builder interface, a ConcreteBuilder, and a Product. In Go, you need none of that. What you need is a way to construct complex objects without 15-parameter constructors, without forgetting required fields, and without making tests unreadable.
Here are five builder patterns from a Go security CLI. Each solves a different construction problem. None of them involve a Director.
1. The Fluent Builder — When Construction Is the API
The Problem
A security finding has 7 fields: rule ID, severity, message, remediation, fix command, resource attributes, and sensitive attributes. Some are required, some are optional, some need special handling (sensitive data must be marked for redaction).
A constructor with 7 parameters:
f := diag.NewFinding("CTL.MISSING.ID", diag.SeverityError,
"Control ID is missing",
"Add a unique 'id' to the control definition",
"", // no fix command
map[string]string{"control_id": "CTL.S3.PUBLIC.001"},
map[string]string{"error": err.Error()}, // sensitive
)
Which string is the message? Which is the remediation? What's that empty string? The reader has to count positions.
The Pattern
type Builder struct {
finding Finding
}
func NewFinding(rule RuleID) *Builder {
return &Builder{
finding: Finding{
RuleID: rule,
Severity: SeverityError,
Resource: kernel.NewSanitizableMap(nil),
},
}
}
func (b *Builder) Error() *Builder {
b.finding.Severity = SeverityError
return b
}
func (b *Builder) Warning() *Builder {
b.finding.Severity = SeverityWarn
return b
}
func (b *Builder) Message(msg string) *Builder {
b.finding.Message = msg
return b
}
func (b *Builder) Remediation(r string) *Builder {
b.finding.Remediation = r
return b
}
func (b *Builder) FixCommand(cmd string) *Builder {
b.finding.FixCommand = cmd
return b
}
func (b *Builder) Attribute(key, value string) *Builder {
b.finding.Resource.Set(key, value)
return b
}
func (b *Builder) SensitiveAttribute(key, value string) *Builder {
b.finding.Resource.SetSensitive(key, value)
return b
}
func (b *Builder) Build() Finding {
f := b.finding
f.Resource = b.finding.Resource.Clone() // Defensive copy
return f
}
Usage reads like a sentence:
issue := diag.NewFinding(diag.RuleControlMissingID).
Error().
Message("Control ID is missing").
Remediation("Add a unique 'id' (e.g., CTL.S3.PUBLIC.001) to the control definition").
Attribute("control_id", ctl.ID.String()).
SensitiveAttribute("error", err.Error()).
Build()
Every call site is self-documenting. No positional ambiguity. Optional fields are simply omitted. The Build() method returns a value (not a pointer) with a defensively cloned resource map — the builder can be reused without aliasing.
Design Details
Why Error() returns *Builder, not *Finding: Severity is set inline in the chain, not at construction time. A finding starts as SeverityError by default. Calling .Warning() changes it. This is a departure from the textbook builder where "set severity" would be just another setter — here the method name IS the severity, making the chain readable.
Why Build() clones the resource map: The SanitizableMap tracks which keys are sensitive (for log redaction vs report display). Without cloning, two findings built from the same builder would share the same map. Mutating one finding's attributes would corrupt the other.
Why no Validate() in Build: The finding is structurally valid by construction — RuleID is required (passed to NewFinding), severity has a default, everything else is optional. There's nothing to validate. If RuleID were optional, Build() should return (Finding, error).
2. The Parameter Object — When a Function Has Too Many Inputs
The Problem
Creating a duration-based violation finding requires 6 pieces of data: the asset's exposure lifecycle, the control definition, the SLA threshold, the current time, cloud identities, and a predicate parser. As individual parameters:
func CreateDurationFinding(
lifecycle *asset.ExposureLifecycle,
control *policy.ControlDefinition,
threshold time.Duration,
now time.Time,
identities []asset.CloudIdentity,
predicateParser policy.PredicateParser,
) *evaluation.Finding {
Six parameters. If you need to add a seventh (say, a trace span), every call site changes.
The Pattern
Group related parameters into a named struct:
type DurationFindingInput struct {
ExposureLifecycle *asset.ExposureLifecycle
Control *policy.ControlDefinition
Threshold time.Duration
Now time.Time
Identities []asset.CloudIdentity
PredicateParser policy.PredicateParser
}
func CreateDurationFinding(in DurationFindingInput) *evaluation.Finding {
a := in.ExposureLifecycle.Asset()
duration, _ := in.ExposureLifecycle.ExposureDuration(in.Now)
ctx := policy.NewAssetEvalContext(a, in.Control.Params, in.PredicateParser, in.Identities...)
misconfigs := policy.ExtractMisconfigurations(&in.Control.UnsafePredicate, ctx)
f := newBaseFinding(in.Control, in.ExposureLifecycle)
f.Evidence = evaluation.Evidence{
FirstUnsafeAt: in.ExposureLifecycle.FirstExposedAt(),
LastSeenUnsafeAt: in.ExposureLifecycle.LastObservedAt(),
UnsafeDurationHours: duration.Hours(),
ThresholdHours: in.Threshold.Hours(),
Misconfigurations: misconfigs,
// ...
}
return f
}
Call site is explicit about what each value means:
finding := CreateDurationFinding(DurationFindingInput{
ExposureLifecycle: lifecycle,
Control: ctl,
Threshold: maxUnsafe,
Now: now,
Identities: ids.At(lifecycle.LastObservedAt()),
PredicateParser: deps.predicateParser(),
})
Why Not a Builder Here
A builder would add ceremony without value. There are no optional fields — all 6 are required for every finding. There's no conditional logic during construction — every field is set once and used once. The parameter object gives you named fields (readable) without method chains (unnecessary).
The rule: use a builder when construction has conditional steps, defaults, or validation. Use a parameter object when construction is a straight transfer of all inputs.
3. The Dependency Aggregator — When Wiring Is the Complexity
The Problem
The apply command needs to build evaluation dependencies from 12+ sources: a logger, stdout/stderr writers, stdin, sanitizer, output format, hasher, CLI options, resolved parameters, three factory functions, project config, config path, and a progress callback. This isn't object construction — it's dependency wiring.
The Pattern
A builder struct that collects dependencies from multiple sources, then wires them into the target:
type Builder struct {
Logger *slog.Logger
Stdout io.Writer
Stderr io.Writer
Stdin io.Reader
Sanitizer kernel.Sanitizer
Format appcontracts.OutputFormat
Digester ports.Digester
Tracer ports.Tracer
Opts *Options
Params applyParams
NewFindingWriter compose.FindingWriterFactory
NewCtlRepo compose.CtlRepoFactory
NewStdinObsRepo func(io.Reader) (appcontracts.ObservationRepository, error)
ProjectConfig *appconfig.WorkspacePolicy
ProjectConfigPath string
OnObsProgress func(processed, total int)
}
The Build method orchestrates the wiring — creating adapters, loading configs, resolving options, and assembling the final ApplyDeps:
func (b *Builder) Build(ctx context.Context, plan *EvaluationPlan) (*ApplyDeps, error) {
a, err := b.buildAdapters()
if err != nil {
return nil, fmt.Errorf("build adapters: %w", err)
}
exemptionCfg, err := loadExemptionConfig(b.Opts.ExemptionFile)
if err != nil {
return nil, fmt.Errorf("load exemption config: %w", err)
}
projCfgInput, err := b.buildProjectConfigFromLoaded(b.ProjectConfig)
if err != nil {
return nil, fmt.Errorf("resolve project config: %w", err)
}
celEval, err := stavecel.NewPredicateEval()
if err != nil {
return nil, fmt.Errorf("initialize CEL evaluator: %w", err)
}
built, err := appeval.BuildDependencies(ctx, &appeval.BuildDependenciesInput{
// ... wire everything together
})
if err != nil {
return nil, err
}
return built, nil
}
Why Exported Fields, Not Methods
The dependency aggregator uses exported fields (Logger, Opts, NewCtlRepo) instead of WithLogger(), WithOpts() methods. This is intentional:
- Fields are set from different sources at different times. The logger comes from bootstrap, the opts from flag parsing, the factories from provider construction. Method chains assume linear construction.
-
Fields are read by multiple methods.
buildAdapters(),buildProjectConfigFromLoaded(), andBuild()all read from the builder's fields. With methods, you'd need private fields plus getters. -
No construction validation needed. A
nilfactory will fail atBuild()time with a clear error, not at field-set time.
The difference from a fluent builder: the dependency aggregator collects inputs from 5 different sources across the request lifecycle, then produces the output in one Build() call. A fluent builder constructs linearly.
4. The Test Builder — When Setup Is the Noise
The Problem
Every assessor test requires the same boilerplate:
a := &Assessor{
Controls: []policy.ControlDefinition{
{
ID: "CTL.A.001",
Name: "Test",
Severity: policy.SeverityHigh,
Type: policy.TypeUnsafeState,
},
},
SLAThreshold: 1 * time.Hour,
Clock: stubClock{t: base.Add(48 * time.Hour)},
Exemptions: policy.NewExemptionConfig("", nil),
Exceptions: policy.NewExceptionConfig(nil),
PredicateEval: func(_ policy.ControlDefinition, _ asset.Asset, _ []asset.CloudIdentity) (bool, error) {
return true, nil
},
}
18 lines. The test cares about SLAThreshold and PredicateEval. The other 12 lines are required non-nil values that are identical across 16 tests.
The Pattern
A test-only builder with intent-revealing methods:
type assessorBuilder struct {
clock ports.Clock
sla time.Duration
controls []policy.ControlDefinition
predicateEval policy.PredicateEval
exemptions *policy.ExemptionConfig
exceptions *policy.ExceptionConfig
tracer ports.Tracer
}
func newTestAssessor() *assessorBuilder {
return &assessorBuilder{
clock: stubClock{t: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)},
sla: 168 * time.Hour,
exemptions: policy.NewExemptionConfig("", nil),
exceptions: policy.NewExceptionConfig(nil),
}
}
func (b *assessorBuilder) withClock(t time.Time) *assessorBuilder {
b.clock = stubClock{t: t}
return b
}
func (b *assessorBuilder) withSLA(d time.Duration) *assessorBuilder {
b.sla = d
return b
}
func (b *assessorBuilder) alwaysUnsafe() *assessorBuilder {
b.predicateEval = func(_ policy.ControlDefinition, _ asset.Asset, _ []asset.CloudIdentity) (bool, error) {
return true, nil
}
return b
}
func (b *assessorBuilder) alwaysSafe() *assessorBuilder {
b.predicateEval = func(_ policy.ControlDefinition, _ asset.Asset, _ []asset.CloudIdentity) (bool, error) {
return false, nil
}
return b
}
func (b *assessorBuilder) build() *Assessor {
a := NewAssessor()
a.Clock = b.clock
a.SLAThreshold = b.sla
a.Controls = b.controls
a.Exemptions = b.exemptions
a.Exceptions = b.exceptions
a.PredicateEval = b.predicateEval
a.Tracer = b.tracer
return a
}
Now the test reads as intent:
a := newTestAssessor().
withClock(base.Add(48 * time.Hour)).
withSLA(1 * time.Hour).
withControls(newTestControl("CTL.A.001").build()).
alwaysUnsafe().
build()
8 lines. Every line conveys what the test cares about. alwaysUnsafe() is more readable than a 3-line anonymous function. withSLA(1 * time.Hour) is more readable than SLAThreshold: 1 * time.Hour buried in a 18-line struct literal.
The Timeline Builder
Snapshot construction has the same problem — each snapshot needs a timestamp and an asset list, but the structure is always the same:
// Before: 12 lines
snapshots := []asset.Snapshot{
{
CapturedAt: base,
Assets: []asset.Asset{{ID: "bucket-1", Type: "s3_bucket"}},
},
{
CapturedAt: base.Add(48 * time.Hour),
Assets: []asset.Asset{{ID: "bucket-1", Type: "s3_bucket"}},
},
}
// After: 3 lines
snaps := newTimeline(base).
at(0, "bucket-1").
at(48*time.Hour, "bucket-1").
build()
The at method takes an offset from the base time and asset IDs. The struct construction is hidden inside the builder. Adding a third snapshot is one line, not four.
Why Test Builders Are Unexported
The builder types (assessorBuilder, controlBuilder, timelineBuilder) are unexported and live in _test.go files. They're test infrastructure, not production API. Keeping them unexported:
- Prevents production code from depending on test helpers
- Allows free refactoring without versioning concerns
- Keeps the test package's public API clean
5. The Error Chain Builder — When the Product IS the Chain
The Pattern
ErrorInfo uses With* methods that return the same pointer — not a builder that produces a separate product, but the product that builds itself:
func NewErrorInfo(code ErrorCode, message string) *ErrorInfo {
return &ErrorInfo{Code: code, Message: message}
}
func (e *ErrorInfo) WithTitle(t string) *ErrorInfo {
if e != nil { e.Title = t }
return e
}
func (e *ErrorInfo) WithAction(a string) *ErrorInfo {
if e != nil { e.Action = a }
return e
}
func (e *ErrorInfo) WithURL(u string) *ErrorInfo {
if e != nil { e.URL = u }
return e
}
Usage:
return NewErrorInfo(CodeViolationsFound, message).
WithTitle("Violations detected").
WithAction("Review findings and run `stave diagnose` for root-cause guidance.").
WithURL("https://docs.stave.dev/troubleshooting")
Why Nil-Safe Methods
Every With* method checks if e != nil. This allows chaining from a function that might return nil:
func errorInfoFromError(err error) *ErrorInfo {
if tmpl, ok := sentinelTemplates[ExitCode(err)]; ok {
return NewErrorInfo(tmpl.Code, err.Error()).
WithTitle(tmpl.Title).
WithAction(tmpl.Action)
}
return nil // No ErrorInfo for this error type
}
// Caller doesn't need to check nil before calling With*:
info := errorInfoFromError(err)
info.WithURL(docsRef) // Safe even if info is nil
Why No Build()
ErrorInfo doesn't have a Build() method because it IS the product. The With* methods mutate the struct directly. This is fine because:
-
ErrorInfois always constructed fresh (NewErrorInfoallocates) - It's not shared between goroutines
- There's no invariant that could be violated by partial construction
If ErrorInfo were shared or needed validation, a separate builder with Build() would be correct. For a one-shot construction that's immediately rendered, the self-building pattern is simpler.
Choosing the Right Builder
| Situation | Pattern | Key Indicator |
|---|---|---|
| Complex object with optional fields and validation | Fluent builder with Build()
|
Fields have defaults, some are optional, output needs defensive copying |
| Function with 6+ required parameters | Parameter object (struct) | All fields are required, no conditional construction |
| Dependencies from multiple sources | Dependency aggregator | Inputs arrive at different times, Build() does orchestration |
| Test setup is drowning the test intent | Test builder with intent methods |
alwaysUnsafe() is clearer than a 3-line closure |
| One-shot object with optional decoration | Self-building with With*
|
No sharing, no validation, immediate consumption |
The common thread: Go builders don't need a Director, a Builder interface, or a ConcreteBuilder. They need a struct, some methods that return *self, and optionally a Build() method that finalizes the product. The ceremony comes from the problem, not from the pattern.
These 5 builder patterns are used across Stave, a Go CLI for offline security evaluation. The fluent diagnostic builder constructs SARIF-compatible findings. The test builders reduced 18-line setup blocks to 8-line chains. The dependency aggregator wires 12+ dependencies from 5 different lifecycle stages.