Skip to main content

Enforcing Invariants with Domain Objects

How LZStock utilizes Value Objects, Entities, and Aggregate Roots in Go to eradicate primitive obsession and guarantee memory-level data consistency.

tactical ddd light tactical ddd dark

TL;DR
  • Eradicate Primitive Obsession: Replaced scattered, error-prone string and int validations with immutable Value Objects, ensuring strict formatting constraints are inherently validated at the exact moment of instantiation.
  • Enforce Invariants via Aggregate Roots: Established the Aggregate Root as the absolute gatekeeper for state mutations, strictly forbidding external layers from bypassing business rules to guarantee cross-entity consistency and relationship limits.
  • Preserve Domain Purity: Solved the cross-aggregate validation dilemma by utilizing Domain Services for external existence checks (e.g., verifying a ticker in the DB), keeping the core Aggregate Roots entirely isolated from infrastructure and repositories.

The Objective

A common anti-pattern in Go microservices is "Primitive Obsession"—passing naked strings and ints across layers and scattering validation logic (e.g., if name == "") throughout controllers, use-cases, and database repositories.

The objective of Tactical DDD is to push validation as deep into the core as possible. By modeling the business using strict Value Objects, Entities, and Aggregate Roots, we create a "Consistency Boundary." Once a domain object is instantiated, the rest of the application can trust it blindly without ever re-validating it.

The Mental Model & Validation Hierarchy

Domain objects act like a Russian nesting doll of validations. The outer layers handle complex business rules, while the inner layers handle strict formatting.

ScopeValidation FocusExample in LZStock
Value ObjectFormat, Length, Characters (Immutability)DashboardName, InvestorID
EntityIdentity, Required Fields, LifecycleWatchlist, USCompanyRef
Aggregate RootCross-Entity Rules, Business LimitsDashboard

Core Implementation

Below is the Go implementation of the domain object types, demonstrating how they encapsulate their specific invariants.

The Value Object (VO)

A Value Object has no identity. If two dashboard names have the same string value, they are identical. VOs are strictly responsible for their own formatting and are immutable.

// internal/domain/dashboard_name.go

type DashboardName string

// NewDashboardName acts as the ONLY gateway to create this VO.
// If it returns no error, the caller is guaranteed a valid DashboardName.
func NewDashboardName(value string) (DashboardName, error) {
if value == "" {
return "", apperrs.ErrEmptyDashboardName
}
if len(value) > 100 {
return "", apperrs.ErrDashboardNameTooLong
}
return DashboardName(value), nil
}

func (n DashboardName) String() string {
return string(n)
}

The Entity

An Entity has a unique Identity (e.g., WatchlistID). Its attributes can change over time, but its identity remains the same. It protects its own internal consistency constraints.

// internal/domain/watchlist.go

type Watchlist struct {
id WatchlistID // Identity
name WatchlistName // Value Object
dashboardID DashboardID
companies []*USCompanyRef
}

// Watchlist enforces internal entity-level constraints
func (w *Watchlist) AddCompanyRef(companyRef USCompanyRef) error {
// Green Box Check: Company CIK Uniqueness within Watchlist
if w.containsCompanyRef(companyRef.CIK()) {
return apperrs.ErrCompanyAlreadyExists
}

w.companies = append(w.companies, &companyRef)
return nil
}

The Aggregate Root (AR)

The Aggregate Root (Dashboard) is the Gatekeeper. External layers (like UseCases) are strictly forbidden from modifying inner Entities (like Watchlist) directly. All state mutations must pass through the AR so it can enforce cross-entity business rules.

// internal/domain/dashboard.go

type Dashboard struct {
id DashboardID
name DashboardName
status DashboardStatus
watchlists []*Watchlist // The AR owns these entities
}

// AddWatchlist enforces aggregate-level invariants before mutating state
func (d *Dashboard) AddWatchlist(name WatchlistName) error {
// Green Box Check: Dashboard Status Must Be Active
if d.status != DashboardStatusActive {
return apperrs.ErrDashboardNotActive
}

// Green Box Check: Watchlist Count Limits
if len(d.watchlists) >= MaxWatchlistsPerDashboard {
return apperrs.ErrMaxWatchlists
}

// Green Box Check: Unique Watchlist Names within Dashboard
if d.hasWatchlistWithName(name) {
return apperrs.ErrDuplicateWatchlistName
}

newWatchlist, _ := NewWatchlist(GenerateWatchlistID(), name, d.id)
d.watchlists = append(d.watchlists, newWatchlist)
return nil
}

Edge Cases & Trade-offs

  • The Cross-Aggregate Dilemma (Domain Services): What happens when adding a company requires checking if the CIK actually exists in the external Global Market database? An Aggregate Root should never inject a Database Repository (it must remain pure).

  • The Trade-off: Instead of violating the AR's purity, we introduce the Cross-Aggregate Level (Domain Services). The UseCase passes the required contextual data (or an interface to fetch it) into the Domain Service, which coordinates the "Company Existence Validation" before passing the validated data to the Aggregate Root.

  • Primitive Obsession vs. Boilerplate Fatigue: Wrapping every single string or int into a Value Object provides incredible type safety, but introduces massive boilerplate. We only create Value Objects for primitives that carry business rules (like DashboardName length constraints) or identity (InvestorID). For purely descriptive fields, standard primitives are used to prevent over-engineering.

  • Encapsulation Leaks via Slices: In Go, if the Aggregate Root exposes a slice func (d *Dashboard) Watchlists() []*Watchlist, a careless UseCase could modify the array directly, bypassing the AR's validation rules. To maintain absolute encapsulation, the architecture team must enforce strict code-review policies prohibiting the mutation of domain models outside of AR methods.

The Outcome

By strictly cascading validations through Value Objects, Entities, Aggregate Roots, and Domain Services, the Application (UseCase) layer is completely liberated from data validation. It simply orchestrates workflows, confident that if a Domain Object exists in memory, it is inherently valid and business-compliant.