DEV Community

Bala Paranj
Bala Paranj

Posted on

4 Builder Patterns in Go That Aren't the Builder Pattern

The classic Builder pattern — New().WithX().WithY().Build() — is rare in Go. But the problem it solves (constructing complex objects with many optional fields) is everywhere.

In a Go CLI with 53 security controls, 20+ commands, and a security-audit pipeline with 14 finding builders, I used 4 different construction patterns. Each solved a specific problem that the others couldn't.

Pattern 1: Functional Options for Open-Ended Extension

The Problem

Security controls needed 7+ optional configuration fields. A constructor with 7 parameters was unreadable:

// BEFORE: Positional constructor — which string is which?
ctrl := NewDefinition(
    "CTL.S3.CONTROLS.001",       // id
    "Block Public Access",        // description
    Critical,                     // severity
    []string{"hipaa", "pci-dss"}, // profiles
    map[string]string{            // refs
        "hipaa": "§164.312(a)(1)",
    },
    "Access control — prevents public exposure", // rationale
    Critical,                     // severity override (or was it the same?)
)
Enter fullscreen mode Exit fullscreen mode

Seven positional parameters. Three are strings. One is a map. The caller has to memorize the order. Adding an 8th parameter means changing every call site.

The Fix: Functional Options

// AFTER: Each option is named and self-documenting
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 WithProfileRationale(profile, rationale string) Option {
    return func(d *Definition) {
        if d.profileRationales == nil {
            d.profileRationales = make(map[string]string)
        }
        d.profileRationales[profile] = rationale
    }
}

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

Usage at the registration site:

func init() {
    ControlRegistry.MustRegister(&accessBlockPublic{
        Definition: NewDefinition(
            WithID("CTL.S3.CONTROLS.001"),
            WithDescription("Block Public Access must be fully enabled"),
            WithSeverity(Critical),
            WithComplianceProfiles("hipaa", "pci-dss", "cis-s3"),
            WithComplianceRef("hipaa", "§164.312(a)(1)"),
            WithProfileRationale("hipaa", "Prevents public exposure of ePHI"),
        ),
    })
}
Enter fullscreen mode Exit fullscreen mode

Why functional options here:

  • New compliance profiles can be added without changing existing options
  • Optional fields (ProfileRationale, SeverityOverride) are omitted without placeholder nils
  • Each option initializes its own map — no caller-side make() boilerplate
  • Adding WithTimeout(d time.Duration) later requires zero changes to existing code

The same pattern was applied to the security-audit request with 14 options:

report, artifacts, err := runner.Run(ctx, NewRequest(
    WithNow(cfg.Now),
    WithStaveVersion(version.String),
    WithSeverityFilter(cfg.SeverityFilter),
    WithSBOMFormat(cfg.SBOMFormat),
    WithComplianceFrameworks(cfg.Frameworks),
    WithVulnSource(cfg.VulnSource),
    WithLiveVulnCheck(cfg.LiveVulnCheck),
    WithFailOn(cfg.FailOn),
    WithRequireOffline(cfg.RequireOffline),
))
Enter fullscreen mode Exit fullscreen mode

14 options, each named, each optional, each independent.

Pattern 2: Spec Struct for Repetitive Construction

The Problem

14 security-audit finding builders each had the same 3-path pattern:

// BEFORE: Each builder repeated the same structure
func findingFromFSDisclosure(in PolicyInspectionSnapshot, err error) Finding {
    if err != nil {
        return Finding{
            ID:             CheckFSAccessDisclosure,
            Pillar:         PillarRuntime,
            Status:         outcome.Warn,
            Severity:       policy.SeverityMedium,
            Title:          "Filesystem disclosure incomplete",
            Details:        err.Error(),
            AuditorHint:    "Read/write footprint declaration could not be generated.",
            Recommendation: "Rerun security-audit with writable bundle directory.",
        }
    }
    return Finding{
        ID:             CheckFSAccessDisclosure,
        Pillar:         PillarRuntime,
        Status:         outcome.Pass,
        Severity:       policy.SeverityMedium,
        Title:          "Filesystem access declared",
        Details:        fmt.Sprintf("Declared %d read paths and %d write paths.", ...),
        AuditorHint:    "Bundle includes explicit read/write footprint for review.",
        Recommendation: "Review filesystem_access_declaration.json.",
    }
}
Enter fullscreen mode Exit fullscreen mode

20 lines. 6 fields repeated (ID, Pillar, Severity). The same if err != nil / pass / fail structure in every builder. 14 copies.

The Fix: Spec Struct + Generic Builder

// Spec declares the three paths as data
type findingSpec struct {
    ID       CheckID
    Pillar   Pillar
    Severity policy.Severity

    // Error path
    ErrStatus outcome.Status
    ErrTitle  string
    ErrHint   string
    ErrReco   string

    // Pass path
    PassTitle string
    PassHint  string
    PassReco  string

    // Fail path
    FailStatus  outcome.Status
    FailTitle   string
    FailHint    string
    FailReco    string
}

// Generic builder handles the 3-path dispatch
func buildFinding(spec findingSpec, err error, pass bool, passDetails, failDetails string) Finding {
    base := Finding{
        ID:       spec.ID,
        Pillar:   spec.Pillar,
        Severity: spec.Severity,
    }
    switch {
    case err != nil:
        base.Status = spec.ErrStatus
        base.Title = spec.ErrTitle
        base.Details = err.Error()
        base.AuditorHint = spec.ErrHint
        base.Recommendation = spec.ErrReco
    case pass:
        base.Status = outcome.Pass
        base.Title = spec.PassTitle
        base.Details = passDetails
        base.AuditorHint = spec.PassHint
        base.Recommendation = spec.PassReco
    default:
        base.Status = spec.FailStatus
        base.Title = spec.FailTitle
        base.Details = failDetails
        base.AuditorHint = spec.FailHint
        base.Recommendation = spec.FailReco
    }
    return base
}
Enter fullscreen mode Exit fullscreen mode

Usage:

// AFTER: Spec is data, builder handles dispatch
var fsDisclosureSpec = findingSpec{
    ID:       CheckFSAccessDisclosure,
    Pillar:   PillarRuntime,
    Severity: policy.SeverityMedium,
    ErrStatus: outcome.Warn,
    ErrTitle:  "Filesystem disclosure incomplete",
    ErrHint:   "Read/write footprint declaration could not be generated.",
    ErrReco:   "Rerun security-audit with writable bundle directory.",
    PassTitle: "Filesystem access declared",
    PassHint:  "Bundle includes explicit read/write footprint for review.",
    PassReco:  "Review filesystem_access_declaration.json.",
}

func findingFromFSDisclosure(in PolicyInspectionSnapshot, err error) Finding {
    details := fmt.Sprintf("Declared %d read and %d write paths.",
        len(in.Filesystem.Reads), len(in.Filesystem.Writes))
    return buildFinding(fsDisclosureSpec, err, true, details, "")
}
Enter fullscreen mode Exit fullscreen mode

20 lines → 4 lines per builder. The spec is data. The builder is logic. Each new finding needs a spec (data) and a one-liner function (glue).

When NOT to use this: 7 complex builders were intentionally kept explicit because they had 4+ paths, extra parameters, or per-finding dynamic logic. The spec pattern would have been more complex than the hand-written version.

Pattern 3: Constructor with Sensible Defaults

The Problem

The evaluation engine had lazy defaults scattered across accessor methods:

// BEFORE: Defaults resolved lazily in accessors
type Runner struct {
    Logger          *slog.Logger
    MaxGapThreshold time.Duration
    Confidence      ConfidenceCalculator
}

func (r *Runner) maxGapThreshold() time.Duration {
    if r.MaxGapThreshold == 0 {
        return DefaultMaxGapThreshold  // ← lazy fallback
    }
    return r.MaxGapThreshold
}

func (r *Runner) confidenceCalculator() ConfidenceCalculator {
    if r.Confidence.HighMultiplier == 0 {
        return DefaultConfidenceCalculator()  // ← lazy fallback
    }
    return r.Confidence
}
Enter fullscreen mode Exit fullscreen mode

Every accessor had a conditional fallback. The object's state was ambiguous — was MaxGapThreshold == 0 intentional or uninitialized?

The Fix: Constructor Sets Defaults

// AFTER: Defaults set at construction — accessors are simple returns
func NewAssessor() *Assessor {
    return &Assessor{
        Logger:          slog.Default(),
        ContinuityLimit: DefaultContinuityLimit,
        Confidence:      DefaultConfidenceCalculator(),
    }
}

func (a *Assessor) continuityLimit() time.Duration { return a.ContinuityLimit }
func (a *Assessor) confidenceCalculator() ConfidenceCalculator { return a.Confidence }
Enter fullscreen mode Exit fullscreen mode

Callers override after construction if needed:

assessor := engine.NewAssessor()
assessor.Controls = catalog.List()
assessor.SLAThreshold = input.MaxUnsafeDuration
assessor.Clock = input.Clock
// ContinuityLimit and Confidence keep their defaults
Enter fullscreen mode Exit fullscreen mode

Why constructor defaults here: The engine has 3 required fields (Controls, Clock, SLAThreshold) and 3 optional fields with sensible defaults (Logger, ContinuityLimit, Confidence). A constructor handles the defaults; callers set the required fields explicitly.

Pattern 4: Config Struct for CLI Command Wiring

The Problem

CLI command runners accepted dependencies as closure variables from RunE:

// BEFORE: Closure variables captured from Cobra
cmd := &cobra.Command{
    RunE: func(cmd *cobra.Command, args []string) error {
        // 8 variables captured from outer scope
        return runEvaluate(cmd, obsRepo, ctlRepo, marshaler, enrichFn,
            format, quiet, sanitizer)
    },
}
Enter fullscreen mode Exit fullscreen mode

The runner function had 8 parameters. Testing required constructing a *cobra.Command to extract flags. The function was coupled to Cobra.

The Fix: Config Struct Decouples from Cobra

// AFTER: Config struct holds all resolved values
type AssessmentConfig struct {
    InventoryConfig
    SLAThreshold    time.Duration
    Clock           ports.Clock
    Hasher          ports.Digester
    Output          io.Writer
    ExemptionRules  *policy.ExemptionConfig
    ExceptionRules  *policy.ExceptionConfig
    BuildVersion    string
    PredicateEval   policy.PredicateEval
}

type AuditWorkflow struct {
    InventoryRepo   appcontracts.ObservationRepository
    PolicyRepo      appcontracts.ControlRepository
    ReportPublisher appcontracts.FindingMarshaler
    ContextEnricher appcontracts.EnrichFunc
}

func NewAuditWorkflow(invRepo, polRepo, publisher, enricher) *AuditWorkflow { ... }
func (w *AuditWorkflow) PerformAssessment(ctx context.Context, cfg AssessmentConfig) (ComplianceReport, SecurityState, error)
Enter fullscreen mode Exit fullscreen mode

Cobra's RunE resolves flags into the config struct, then passes it:

RunE: func(cmd *cobra.Command, _ []string) error {
    cfg := AssessmentConfig{
        SLAThreshold: parsedMaxUnsafe,
        Clock:        resolvedClock,
        // ... all resolved from flags
    }
    workflow := NewAuditWorkflow(obsRepo, ctlRepo, marshaler, enricher)
    report, state, err := workflow.PerformAssessment(ctx, cfg)
}
Enter fullscreen mode Exit fullscreen mode

Testing the workflow requires no Cobra:

// Test: no *cobra.Command needed
cfg := AssessmentConfig{SLAThreshold: 24 * time.Hour, Clock: ports.FixedClock(now)}
workflow := NewAuditWorkflow(mockObs, mockCtl, mockMarshaler, mockEnricher)
report, _, err := workflow.PerformAssessment(ctx, cfg)
Enter fullscreen mode Exit fullscreen mode

When to Use Which

Pattern Use When Example
Functional Options Open-ended optional fields; new options added over time NewDefinition(WithID(...), WithSeverity(...))
Spec Struct Repetitive construction with common paths; data varies, logic is fixed buildFinding(fsDisclosureSpec, err, pass, details)
Constructor Defaults Few required + few optional fields with known defaults NewAssessor() then override .Controls, .Clock
Config Struct Decoupling framework (Cobra) from business logic; testability AssessmentConfig{SLAThreshold: 24h}

The wrong choice:

  • Functional options for 3-field structs → overkill
  • Config struct for domain objects → wrong layer (config is CLI, not domain)
  • Spec struct for one-off construction → premature abstraction
  • Constructor defaults for open-ended extension → can't add new fields without changing constructor

The Missing Pattern

Go doesn't need the classic Builder.WithX().WithY().Build() chain. The language provides:

  • Struct literals with named fields for simple cases
  • Functional options for open-ended extension
  • Spec structs for repetitive templates
  • Constructors for defaults
  • Config structs for boundary decoupling

The fluent builder chain adds indirection without value in Go. Named struct fields already prevent positional swap bugs. Functional options already handle optional parameters. The builder pattern's job is already done by the language.


These 4 patterns were applied across 60 refactorings in Stave, an offline configuration safety evaluator. The functional options pattern alone eliminated 98 lines of getter boilerplate across 14 security controls.

Top comments (0)