Domain-Driven Design in Go doesn't look like DDD in Java. There are no annotations, no repository interfaces from a framework, no @Entity markers. The building blocks — Value Objects, Entities, and Aggregates — emerge from Go's type system and encapsulation rules.
Here's how each pattern appeared in a security CLI through refactoring, with the actual before/after code.
Value Objects: Equality by Content, Not Identity
A Value Object has no identity. Two instances with the same content are interchangeable. They're immutable, they validate at construction, and they carry domain behavior as methods.
Before: Raw Strings Everywhere
// BEFORE: ControlID is string — no validation, no behavior
type Finding struct {
ControlID string `json:"control_id"`
AssetID string `json:"asset_id"`
}
// Any string is accepted. No validation. No domain methods.
f := Finding{
ControlID: "not a valid id",
AssetID: "also anything",
}
After: kernel.ControlID Value Object
// AFTER: ControlID is a Value Object with validation and behavior
type ControlID string
var controlIDPattern = regexp.MustCompile(`^CTL\.[A-Z][A-Z0-9]*(\.[A-Z][A-Z0-9]*){1,}\.\d{3}$`)
func NewControlID(raw string) (ControlID, error) {
if err := ValidateControlIDFormat(raw); err != nil {
return "", err
}
return ControlID(raw), nil
}
// Domain behavior lives on the type
func (id ControlID) Provider() string {
parts := strings.Split(id.String(), ".")
if len(parts) < 2 { return "" }
return parts[1] // "CTL.S3.PUBLIC.001" → "S3"
}
func (id ControlID) Category() string { ... } // → "PUBLIC"
func (id ControlID) Sequence() string { ... } // → "001"
// JSON round-trip validates
func (id *ControlID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil { return err }
val, err := NewControlID(s)
if err != nil { return err }
*id = val
return nil
}
Value Object properties:
-
Equality by content: Two
ControlID("CTL.S3.PUBLIC.001")are equal -
Validated at construction:
NewControlIDrejects invalid formats -
Immutable:
ControlIDis astring— no mutation methods -
Domain behavior:
Provider(),Category(),Sequence()extract meaning -
Self-validating at boundaries:
UnmarshalJSONvalidates on deserialization
The codebase has 12 Value Objects in the kernel package:
| Value Object | Underlying | Domain Meaning |
|---|---|---|
ControlID |
string |
Structured security control identifier |
AssetType |
string |
Cloud resource category (s3_bucket, iam_role) |
Schema |
string |
Schema version (obs.v0.1, ctrl.v1) |
Duration |
time.Duration |
Human-formatted duration (7d, 24h) |
Vendor |
string |
Cloud provider (aws, azure) |
Digest |
string |
SHA-256 hex digest |
ObjectPrefix |
string |
S3 key prefix with matching semantics |
NetworkScope |
int |
Network boundary classification |
PrincipalScope |
int |
Identity trust boundary |
TimeWindow |
struct |
Start/end time pair with containment checks |
Severity |
int |
Control severity with ordering |
Status |
int |
Check result (Pass/Warn/Fail/Skipped) |
kernel.Duration: A Value Object with Custom Serialization
// kernel.Duration wraps time.Duration with human-friendly formatting
type Duration time.Duration
func (d Duration) String() string { return FormatDuration(time.Duration(d)) }
// 168h → "7d", 36h → "1d12h", 1h → "1h"
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String()) // Serializes as "7d", not 604800000000000
}
func (d *Duration) UnmarshalJSON(data []byte) error {
var s string
json.Unmarshal(data, &s)
parsed, err := ParseDuration(s) // Accepts "7d", "1.5d", "168h"
*d = Duration(parsed)
return err
}
Before: time.Duration serialized as "168h0m0s" in JSON. Users had to parse Go's duration format.
After: kernel.Duration serializes as "7d". ParseDuration accepts "7d", "1.5d", "1d12h" — human input formats.
Entities: Identity + Lifecycle
An Entity has a unique identity that persists across state changes. Two entities with the same data but different IDs are different entities. They have lifecycle methods that enforce state machine transitions.
Before: Flat Data Struct
// BEFORE: Flat struct with no lifecycle enforcement
type Timeline struct {
AssetID string
Unsafe bool
UnsafeSince time.Time
LastChecked time.Time
EpisodeCount int
}
// Caller manages state transitions manually
tl.Unsafe = true
tl.UnsafeSince = time.Now()
// What if caller sets Unsafe=true but forgets UnsafeSince?
After: ExposureLifecycle Entity
// AFTER: Entity with enforced state transitions
type ExposureLifecycle struct {
ID ID // ← unique identity
asset Asset // ← private state
activeWindow *ExposureWindow // ← lifecycle state
lastObservedAt time.Time
history ExposureHistory // ← encapsulated history
stats ObservationStats // ← encapsulated metrics
}
// Construction enforces identity invariant
func NewExposureLifecycle(a Asset) *ExposureLifecycle {
if a.ID.IsEmpty() {
panic("contract violated: requires non-empty asset ID")
}
return &ExposureLifecycle{ID: a.ID, asset: a}
}
// State transitions through methods — caller can't set fields directly
func (l *ExposureLifecycle) RecordCheck(a Asset, ts time.Time) { ... }
func (l *ExposureLifecycle) IsExposed() bool { ... }
func (l *ExposureLifecycle) IsSecure() bool { ... }
func (l *ExposureLifecycle) ExposureDuration() time.Duration { ... }
func (l *ExposureLifecycle) ExceedsSLA(max time.Duration) bool { ... }
// Read-only accessors return values, not pointers
func (l *ExposureLifecycle) Stats() ObservationStats { return l.stats }
func (l *ExposureLifecycle) History() ExposureHistory { return l.history }
Entity properties:
-
Identity:
IDfield persists across state changes -
Lifecycle:
RecordCheckdrives the state machine — callers can't setactiveWindowdirectly -
Encapsulation:
statsandhistoryare private, returned by value (no pointer aliasing) -
Contract enforcement:
panicon empty ID at construction
The ExposureWindow that the lifecycle manages is itself a Value Object:
// ExposureWindow is a Value Object — no identity, immutable after creation
type ExposureWindow struct {
openedAt time.Time
resolvedAt time.Time
active bool
}
func NewActiveWindow(openedAt time.Time) ExposureWindow {
return ExposureWindow{openedAt: openedAt, active: true}
}
func (w ExposureWindow) Resolve(end time.Time) ExposureWindow {
// Returns a NEW window — doesn't mutate
return ExposureWindow{
openedAt: w.openedAt,
resolvedAt: end,
active: false,
}
}
func (w ExposureWindow) IsActive() bool { return w.active }
func (w ExposureWindow) OpenedAt() time.Time { return w.openedAt }
func (w ExposureWindow) DwellTime(now time.Time) time.Duration { ... }
ExposureWindow.Resolve() returns a new window — it doesn't mutate the existing one. This is the Value Object pattern: operations produce new values.
Aggregates: Consistency Boundary
An Aggregate is a cluster of objects treated as a unit. External code accesses the aggregate through its root. Internal state is protected by the root's methods.
ComplianceReport: The Root Aggregate
// ComplianceReport is the root aggregate of an evaluation execution.
type ComplianceReport struct {
Run RunInfo `json:"run"`
Summary ComplianceSummary `json:"summary"`
SecurityState SecurityState `json:"security_state"`
RiskSignals risk.ThresholdItems `json:"risk_signals,omitempty"`
Findings []Finding `json:"findings"`
ExceptedFindings []ExceptedFinding `json:"excepted_findings,omitempty"`
SkippedControls []SkippedControl `json:"skipped_controls,omitempty"`
ExemptedAssets []asset.ExemptedAsset `json:"exempted_assets,omitempty"`
Checks []ResourceCheck `json:"checks,omitempty"`
}
The ComplianceReport is constructed by the Assessor (the evaluation engine). It contains:
-
Value Objects:
RunInfo,ComplianceSummary,SecurityState -
Entity collections:
[]Finding(each has a ControlID+AssetID identity pair) -
Derived state:
SecurityStateis computed from findings + risk signals
Before: Fat Struct with Mixed Concerns
// BEFORE: Summary mixed counts, gating, and metadata
type Summary struct {
TotalControls int // counting
PassCount int // counting
FailCount int // counting
FailOn Severity // gating logic
Gated bool // gating logic
StaveVersion string // metadata
EvidenceFreshness time.Duration // metadata
}
RecomputeSummary had to reset counts while preserving metadata — a recipe for bugs.
After: Split into Cohesive Value Objects
// AFTER: Three Value Objects, each with single concern
type ComplianceSummary struct {
TotalAssets int `json:"total_assets"`
ExposedResources int `json:"exposed_resources"`
Violations int `json:"violations"`
}
type GatingInfo struct {
FailOn Severity
Gated bool
GatedFindingCount int
}
type AuditMeta struct {
VulnSourceUsed string
EvidenceFreshness time.Duration
StaveVersion string
}
Each is a Value Object — no identity, no lifecycle, just data. They compose into the aggregate without coupling their concerns.
The Assessor Builds the Aggregate
func (s *assessmentSession) compileReport() ComplianceReport {
// Sort for deterministic output
SortFindings(s.collector.findings)
// Partition findings through exception rules
activeFindings, exceptedFindings := partitionFindings(
s.collector.findings, s.assessor.Exceptions, s.auditTime,
)
// Derive security state from findings + risk
riskSignals := risk.ComputeItems(...)
posture := DeriveSecurityState(len(activeFindings), riskSignals)
// Construct the aggregate
return ComplianceReport{
Run: RunInfo{...},
Summary: ComplianceSummary{...},
SecurityState: posture,
RiskSignals: riskSignals,
Findings: activeFindings,
ExceptedFindings: exceptedFindings,
SkippedControls: s.collector.skippedControls,
ExemptedAssets: s.collector.exemptedAssets,
Checks: s.collector.checks,
}
}
The aggregate is assembled inside the assessmentSession — a private type. External code receives the completed ComplianceReport and cannot modify the internal assessment state.
How These Map to Go Constructs
DDD doesn't need a framework in Go. The language provides everything:
| DDD Concept | Go Construct | Example |
|---|---|---|
| Value Object | Defined type + methods |
kernel.ControlID, kernel.Duration
|
| Value Object equality |
== on defined types |
id1 == id2 |
| Value Object immutability | Return new values from methods |
window.Resolve() returns new ExposureWindow
|
| Entity identity | ID field + constructor | ExposureLifecycle{ID: a.ID} |
| Entity lifecycle | Methods with state transitions |
RecordCheck(), IsExposed()
|
| Entity encapsulation | Unexported fields + value receivers |
stats ObservationStats (private), Stats() returns copy |
| Aggregate root | Struct with composed value objects | ComplianceReport{Summary, Findings, ...} |
| Aggregate construction | Factory method in private session |
compileReport() on assessmentSession
|
| Invariant enforcement |
panic on contract violation |
NewExposureLifecycle panics on empty ID |
| Boundary validation |
UnmarshalJSON with validation |
ControlID.UnmarshalJSON rejects invalid formats |
The Key Insight
In Go, the DDD building blocks aren't declared — they're enforced by the type system:
-
Value Objects are defined types with
MarshalJSON/UnmarshalJSONand no mutation methods - Entities have private fields, construction invariants, and lifecycle methods
- Aggregates are structs assembled by private factory methods that external code receives read-only
No framework needed. No annotations. No base classes. Just types, methods, and encapsulation.
These patterns evolved through 60 refactorings in Stave, an offline configuration safety evaluator. The kernel package contains 12 Value Objects. The asset package contains the ExposureLifecycle Entity. The evaluation package contains the ComplianceReport Aggregate.
Top comments (0)