DEV Community

Bala Paranj
Bala Paranj

Posted on

Value Objects, Entities, and Aggregates in Go — Without a Framework

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

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

Value Object properties:

  • Equality by content: Two ControlID("CTL.S3.PUBLIC.001") are equal
  • Validated at construction: NewControlID rejects invalid formats
  • Immutable: ControlID is a string — no mutation methods
  • Domain behavior: Provider(), Category(), Sequence() extract meaning
  • Self-validating at boundaries: UnmarshalJSON validates 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

Entity properties:

  • Identity: ID field persists across state changes
  • Lifecycle: RecordCheck drives the state machine — callers can't set activeWindow directly
  • Encapsulation: stats and history are private, returned by value (no pointer aliasing)
  • Contract enforcement: panic on 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 { ... }
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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/UnmarshalJSON and 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)