Domain-Driven Design: a Practical Primer
Domain-Driven Design (DDD) is an approach to software design that puts the business problem, its language, and its rules at the centre of the model.
DDD is not a folder structure, a requirement to use classes, or a package that can discover the domain for you. Its value comes from developers and domain experts building a precise shared understanding, then making that understanding visible in code.
Factos is compatible with DDD, but it is not a DDD framework. This page explains the parts of DDD needed to understand Factos and draws a clear line between what the application must design and what the library provides.
Begin with the domain
The domain is the activity and knowledge the software serves: course enrolment, invoicing, identity, logistics, or another real problem area.
A useful model starts with concrete questions:
- What is someone trying to accomplish?
- Which facts must be known before accepting that change?
- Which rules must never be violated?
- What does the business call the accepted result?
- Which failures are meaningful to a user or domain expert?
For a course-subscription domain, the language might include:
pub type Command {
DefineCourse(course_id: String, capacity: Int)
ChangeCourseCapacity(course_id: String, new_capacity: Int)
SubscribeStudentToCourse(student_id: String, course_id: String)
}
pub type Event {
CourseDefined(course_id: String, capacity: Int)
CourseCapacityChanged(course_id: String, new_capacity: Int)
StudentSubscribedToCourse(student_id: String, course_id: String)
}
pub type DomainError {
CourseDoesNotExist(course_id: String)
CourseFullyBooked(course_id: String)
StudentAlreadySubscribed
StudentCourseLimitReached(limit: Int)
}
These names form part of a ubiquitous language: the same terms should mean
the same thing in conversations, examples, tests, and code inside one model.
Names such as InsertSubscriptionRow or UpdateCourseCount describe a database
operation instead of the business.
Essential DDD vocabulary
| Term | Practical meaning |
|---|---|
| Domain | The problem area and knowledge the software models |
| Subdomain | A coherent part of the wider domain, such as pricing or fulfilment |
| Bounded context | A boundary inside which a model and its language have one specific meaning |
| Ubiquitous language | Terms shared by domain experts and developers inside that context |
| Entity | Something whose identity persists while its attributes change |
| Value object | A concept defined by its value rather than a lasting identity |
| Invariant | A business rule that must hold whenever a change is accepted |
| Aggregate | A model boundary responsible for protecting a set of invariants |
| Domain event | A past-tense fact the domain accepted |
These are design tools, not mandatory code interfaces. A Gleam custom type can represent an entity, value, command, event, or temporary decision state without inheriting from a framework base type.
Bounded contexts come before storage boundaries
The same word can mean different things in different parts of a system. “Account” in identity may mean credentials and access; “account” in billing may mean balances and invoices. A bounded context makes that difference explicit so one universal model does not accumulate conflicting meanings.
Factos does not create or enforce bounded contexts. An application decides:
- which language and invariants belong together;
- which events are authoritative inside the context;
- what crosses the boundary through APIs, messages, or translated events; and
- whether contexts share infrastructure or use separate deployments.
A Factos event log should not be mistaken for the entire enterprise domain. Keep context ownership explicit even if several contexts happen to share one database.
Invariants drive command decisions
An invariant is not merely a validation rule over one input. It may depend on accepted history and concurrent activity.
For SubscribeStudentToCourse, the invariants can include:
- the course exists;
- its subscription count is below its current capacity;
- this student has not already subscribed to this course;
- this student has fewer than five subscriptions.
The important modelling question is:
Which accepted facts can change the answer to this command?
Factos calls that set the DecisionContext. A pure Model folds those facts
into temporary state and either returns new domain events or a domain error.
command + facts relevant to its invariants
-> accepted events or domain error
The temporary state can be shaped specifically for the command. Defining a course needs to know whether that course id already exists. Subscribing a student needs course capacity, course occupancy, the student’s total, and the student/course pair. They do not need identical state merely because both commands mention a course.
What about aggregates?
In DDD, an aggregate is a consistency boundary: changes inside it preserve its invariants as one unit, while other aggregates are normally referenced by identity. This is a valuable way to control model scope.
In traditional Event Sourcing, one aggregate instance is often mapped to one event stream. Optimistic concurrency then protects that stream’s expected revision. The mapping is convenient when every invariant naturally fits one aggregate, but it can turn a modelling guideline into a fixed storage constraint.
Rules such as course capacity plus a per-student limit do not fit neatly inside only the course stream or only the student stream. That does not mean DDD has failed. It means the consistency boundary required by this command differs from either fixed stream.
Dynamic Consistency Boundaries let the command select and protect the relevant facts directly. Aggregates can remain useful modelling concepts without every decision being forced through one permanent aggregate stream. Read Dynamic Consistency Boundaries for the full concurrency model.
DDD and Event Sourcing are separate choices
DDD does not require Event Sourcing. A domain model can be persisted as current state in relational tables, documents, or another form.
Event Sourcing does not guarantee a good domain model. An append-only history of technical CRUD mutations can still have weak language and misplaced rules.
They complement each other when domain events are meaningful business facts: DDD helps discover and name the behavior; Event Sourcing retains the accepted facts; Factos uses selected facts to make future decisions.
How Factos maps to a DDD-style model
The application owns
- commands named after business intent;
- events named after accepted business facts;
- domain errors meaningful to callers;
- pure
initial,evolve, andtryvalues; - the context of facts required by each command;
- stable event types, versions, payloads, and tags;
- bounded-context boundaries and integration contracts;
- projections, workflows, and external effects.
Factos provides
- a
Modeltype connecting decisions to codecs; DecisionContextpredicates over event types and tags;- event envelopes, metadata, global positions, and recorded-event types;
- simulation of domain and codec scenarios;
- backend dispatch implementations and append guarantees;
- transactional subscription hooks where supported.
Factos intentionally does not provide
- an aggregate or entity base class;
- repositories hiding event-store behavior;
- automatic discovery of invariants or context predicates;
- a command bus, HTTP framework, message broker, or process manager;
- a universal projection or eventual-consistency policy.
A practical modelling loop
- Write a concrete scenario in domain language.
- Name the command and its meaningful rejection cases.
- List the accepted facts that can change the decision.
- Define the event that records a successful decision.
- Model
evolveandtryas pure functions. - Build the narrowest complete
DecisionContext. - Attach stable type names, schema versions, and selection tags.
- Simulate success, rejection, and history-sensitive edge cases.
- Prove concurrent behavior through the chosen backend.
- Build projections for queries instead of using decision state as a view.
Return to domain language when the context becomes difficult to explain. A complicated predicate can indicate a genuinely complex invariant, but it can also reveal mixed bounded contexts or an event model that does not express the business facts clearly.
Common mistakes
Starting from tables or framework abstractions
This produces commands and events named after technical operations. Start from business scenarios; design persistence after the rule is understood.
Treating every validation as an invariant
Parsing a date or checking an input format may need no event history. Reserve a decision context for facts that can actually change whether the command is accepted.
Making all history the safe default
AllEvents is logically safe but creates a global consistency boundary. Prefer
the narrowest complete context so unrelated work does not conflict.
Performing IO inside the decision
Backends may retry. Decision code that calls an API, sends a message, reads the clock, or allocates an external id can produce duplicated effects or different answers. Keep the decision deterministic; persist durable intent and perform external work after commit.
Confusing projections with authoritative decisions
A projection is built for reading and may lag unless it shares the append transaction. Protect invariants from authoritative events selected by the decision context, not from a casually consistent UI view.
Next
Read Event Sourcing and command dispatch to understand how accepted facts become the source of truth, then Dynamic Consistency Boundaries to see how Factos protects cross-entity decisions under concurrency.