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:

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

TermPractical meaning
DomainThe problem area and knowledge the software models
SubdomainA coherent part of the wider domain, such as pricing or fulfilment
Bounded contextA boundary inside which a model and its language have one specific meaning
Ubiquitous languageTerms shared by domain experts and developers inside that context
EntitySomething whose identity persists while its attributes change
Value objectA concept defined by its value rather than a lasting identity
InvariantA business rule that must hold whenever a change is accepted
AggregateA model boundary responsible for protecting a set of invariants
Domain eventA 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:

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:

  1. the course exists;
  2. its subscription count is below its current capacity;
  3. this student has not already subscribed to this course;
  4. 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

Factos provides

Factos intentionally does not provide

A practical modelling loop

  1. Write a concrete scenario in domain language.
  2. Name the command and its meaningful rejection cases.
  3. List the accepted facts that can change the decision.
  4. Define the event that records a successful decision.
  5. Model evolve and try as pure functions.
  6. Build the narrowest complete DecisionContext.
  7. Attach stable type names, schema versions, and selection tags.
  8. Simulate success, rejection, and history-sensitive edge cases.
  9. Prove concurrent behavior through the chosen backend.
  10. 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.

Search Document