For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /learn/tactical-ddd.md.

Tactical DDD: behaviour and consistency

Tactical DDD provides building blocks for implementing a domain model. Start with the decisions and rules, then choose the objects needed to express them.

Entities and values

An entity has continuity of identity. A Party remains the same canonical Party when its platform access state changes. A value object is identified by meaning and values. An exact provider/instance/external-reference tuple describes a binding without borrowing a provider's identifier as the canonical Party identity.

Aggregates and invariants

An aggregate groups state whose immediate rules are controlled through one root. An invariant must hold at the business boundary where it applies. Project owns its complete child membership set; independent membership writes would bypass its complete-observation and uniqueness rules.

PartyAuthentication owns its access state and authentication generation. The actual transition below validates its input, preserves unchanged state, protects overflow and records a private fact when the generation changes.

Source: authentication.go

func (p *PartyAuthentication) SetAccessState(state AccessState, reason ReasonCategory) error {
	if err := p.state.validate(); err != nil {
		return foundation.Corrupt(err.Error())
	}
	if !validAccess(state) {
		return foundation.Reject("invalid_access_state", "A recognised platform access state is required")
	}
	if !validAccessReason(reason) {
		return foundation.Reject("invalid_reason_category", "An administrative or security reason is required")
	}
	if p.state.AccessState == state {
		return nil
	}
	if p.state.AuthVersion == math.MaxUint64 {
		return foundation.Reject("auth_version_exhausted", "Authentication generation cannot advance")
	}
	p.state.AccessState = state
	p.state.AuthVersion++
	p.events = append(p.events, PartyAuthenticationChanged{
		PartyID: p.state.PartyID, AuthVersion: p.state.AuthVersion,
		AccessState: state, AuthenticationEligible: p.AuthenticationEligible(),
		ReasonCategory: reason,
	})
	return nil
}

Domain tests exercise these transitions. Cross-root uniqueness, such as issuer/subject ownership, also requires the owner repository's concurrent uniqueness enforcement.

Creation, restoration and repositories

Creation establishes valid initial state and may raise new facts. Restoration validates persisted state and treats invalid bytes as corruption. It raises no new business facts merely because an existing aggregate was loaded.

Source: project.go

func RestoreProject(snapshot ProjectSnapshot) (*Project, error) {
	if err := snapshot.Validate(); err != nil {
		return nil, tactical.Corrupt(err.Error())
	}
	return &Project{state: snapshot.Copy()}, nil
}

The application repository loads and saves the aggregate. Its persistence adaptor owns snapshot and database mappings. Aggregate children have no independent write repository. Database tables can differ from the object structure while preserving the root's atomic boundary.

A domain service expresses domain behaviour with no natural entity or value owner. An application handler coordinates a use case through ports. Keep provider I/O, transaction mechanisms and dependency-container resolution outside the domain.

Exercise: explain why Project memberships belong to the Project root while a PartyProviderBinding has its own lifecycle. Use their invariants as evidence.

Next: Hexagonal architecture.

Adapted from the architecture reference, with Pharos examples.

Page source

Source snapshot ยท Revision f928834c8.