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?)
)
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
}
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"),
),
})
}
Why functional options here:
- New compliance profiles can be added without changing existing options
- Optional fields (
ProfileRationale,SeverityOverride) are omitted without placeholdernils - 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),
))
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.",
}
}
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
}
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, "")
}
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
}
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 }
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
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)
},
}
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)
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)
}
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)
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)