5 Builder Patterns in Go — None of Them Are the Textbook Version

go dev.to

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
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 {
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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(),
})
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Why Exported Fields, Not Methods

The dependency aggregator uses exported fields (Logger, Opts, NewCtlRepo) instead of WithLogger(), WithOpts() methods. This is intentional:

  1. 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.
  2. Fields are read by multiple methods. buildAdapters(), buildProjectConfigFromLoaded(), and Build() all read from the builder's fields. With methods, you'd need private fields plus getters.
  3. No construction validation needed. A nil factory will fail at Build() 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
    },
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  • ErrorInfo is always constructed fresh (NewErrorInfo allocates)
  • 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.

Source: dev.to

arrow_back Back to Tutorials