← Miscellaneous

Software design · a field guide from zero

Growing
a model

Domain-driven design, one idea at a time: how to find the words a business really uses, draw boundaries around them, and write Rust that keeps its rules.

No DDD background assumed
Illustrated tutorial · about 45 minutes
13 October 2026 · AI generated

FIELD NOTES / PLOT 14LETTINGSlet to Ann · since 15 MarchLETBILLING5 rods × £6.50 = £32.50 a yearINSPECTIONSovergrown · photo · revisit!ONE PLOT. THREE MODELS. ALL CORRECT.
THE DESTINATION

By the end, you should be able to read a DDD discussion without tripping over the jargon, sketch the bounded contexts of a system you know, and model an aggregate in Rust that refuses to get into an invalid state. Some Rust helps; every DDD term is introduced here.

01 / The hard part is rarely the code

Why model the domain?

Most software exists to help people do some job: lend books, ship parcels, settle insurance claims. That job, with its rules, exceptions and jargon, is the domain. In a lot of systems the domain is where the real complexity lives. Choosing a web framework is easy by comparison with working out what should happen when a tenant’s offer letter arrives after the offer has expired.

Domain-driven design(DDD) is an approach, named by Eric Evans in his 2003 book, that puts that complexity at the centre. Its core claims are simple:

  • Build a model of the domain: a deliberately simplified understanding of how it works, chosen because it helps solve a particular problem.
  • Build that model together with domain experts, the people who actually do the job, and express it directly in code.
  • Accept that one model cannot serve a whole organisation. Split the system into parts that each have a model of their own, and be explicit about how they relate.

DDD has two halves. Strategic design is about the big picture: language, boundaries and relationships between teams and systems (chapters 02–05). Tactical designis a toolkit of building blocks for writing a model inside one boundary: value objects, entities, aggregates and friends (chapters 06–12). Newcomers often start with the tactical patterns because they look like code. The strategic half usually pays off more.

Our running example

Throughout this guide we’ll build software for the Rotted Frog Allotment Society. It lets plots of land to members who grow vegetables on them. Plots are measured in rods(a full plot is ten rods). Applicants wait on a waiting list, receive an offer, and have fourteen days to accept. A tenant who lets a plot go wild can be served a non-cultivation notice and, after twenty-eight days, lose the tenancy. The rules are invented for teaching, but they’re typical of real societies.

02 / Words before types

Speak one language

Talk to the society’s secretary and you’ll hear words likeoffer, tenancy, half plot and non-cultivation notice. Look in a typical codebase and you’ll find status = 3and a table called user_plot_link. Every conversation between the two needs a translation, and translations lose things.

A ubiquitous language is a shared vocabulary that the team and the domain experts agree on and use everywhere: in meetings, in tickets, in tests and in the code. If the secretary says “serve a notice”, there should be a method called something very close to serve_non_cultivation_notice.

Before

// The code speaks database.
fn update_plot(rec: &mut PlotRecord, status: i32, user_id: Option<i64>) {
    rec.status = status; // 3 = let? 4 = offered? ask Dave
    rec.user_id = user_id;
    rec.updated_at = now();
}

After

// The code speaks allotment. (Signatures only; bodies in chapter 08.)
impl Plot {
    pub fn offer_to(&mut self, member: MemberId, today: NaiveDate)
        -> Result<(), LettingError>;
    pub fn accept_offer(&mut self, member: MemberId, today: NaiveDate)
        -> Result<(), LettingError>;
    pub fn serve_non_cultivation_notice(&mut self, today: NaiveDate)
        -> Result<(), LettingError>;
    pub fn end_tenancy(&mut self, reason: EndReason, today: NaiveDate)
        -> Result<(), LettingError>;
}

The second version isn’t just prettier. Each operation is something the business does, so a domain expert can read the list and say “you’ve missed one: members can swapplots.” That conversation is impossible withupdate_plot.

The language is never finished. When a discussion stumbles (“wait, is a half plot a plot?”), that’s a sign the model needs sharpening. Change the words, then change the code to match. Renaming is a modelling activity, not tidying.

Try it: listen for the language

Pick a system you work on. Write down ten nouns and verbs the users say out loud. Now search the code for each. How many appear unchanged? Which are hidden behind a generic name such as Item, Record, processor handle?

03 / Not everything deserves your best effort

Find the core

A business is made of several areas of activity, calledsubdomains. The allotment society lets plots, charges rent, inspects plots, sends emails and keeps accounts. DDD sorts subdomains into three kinds, because they deserve very different amounts of care.

KindWhat it isAt the societyWhat to do
CoreWhat makes this organisation distinctive. Getting it right is the point.Lettings: waiting list, offers, tenancies, noticesModel carefully. Put your best people and full DDD here.
SupportingNecessary and specific to you, but not a differentiator.Billing rent, recording inspectionsBuild simply. Plain CRUD may be fine.
GenericA solved problem every organisation has.Payments, email, authenticationBuy or use an off-the-shelf service.

For a tiny volunteer society, “core” is about where the tricky rules and the disputes are. For a company it is usually where the competitive advantage is. Either way the question is the same: where would a deep model actually pay for itself?

Subdomains describe the problem space: how the business divides up. The next chapter turns to the solution space: how you divide up the software. Ideally they line up, but they don’t have to.

04 / One word, many meanings

Draw the boundaries

Ask three volunteers what a “plot” is. The lettings officer talks about who holds it and whether it is vacant. The treasurer talks about its size, because that sets the rent. The inspector talks about weeds. All three are right, and they need different data and rules.

LETTINGS CONTEXTBILLING CONTEXTINSPECTIONS CONTEXT“Plot” meanssomething to let“Plot” meansa chargeable size“Plot” meansground in a stateoffertenancynotice to quitvacanttariffrent chargepayerarrearswalk-roundcultivatedovergrownphoto
01 — Each context gets its own model of a plot. Inside a context, the word has one meaning.

The tempting fix is one enormous Plotstruct with fields for everyone. It grows until nobody can change it without breaking someone else, and its meaning blurs into nothing. DDD’s answer is thebounded context: an explicit boundary inside which a particular model, and a particular dialect of the ubiquitous language, applies. Inside the Lettings context,Plot has a tenant. Inside Billing, it has a size and a tariff. Neither is “the real” plot.

A bounded context is a boundary in the software, and usually in the team too. In Rust it might be a module, a crate in a workspace, or a separate service. What matters is that the boundary is deliberate and that models don’t leak across it by accident. Two contexts can share an ID such asPlotId; they shouldn’t share a struct full of each other’s fields.

Subdomain or bounded context?

A subdomain is part of the business, whether or not software exists. A bounded context is part of your solution. One subdomain is often served by one context, but a legacy system can smear several subdomains into one context, and a large subdomain may be split across several.

05 / Boundaries still have to talk

Map the relationships

Contexts are not islands. Billing needs to know when a tenancy starts; Lettings needs a list of plots from the council. A context mapshows the contexts and how each pair relates. It is as much about people as software: who depends on whom, and who has to adapt when things change.

EXTERNALCouncil landregisterACLCORELettingsSUPPORTINGBillingSUPPORTINGInspectionsGENERIC · BOUGHT INPayment providerUDUDEVENTSPARTNERSHIPCONFORMISTDUU = UPSTREAMD = DOWNSTREAMACL = ANTICORRUPTION LAYER
02 — A context map. “Upstream” contexts affect “downstream” ones; changes flow with the river.

In each relationship one side is usually upstream(U) and the other downstream(D). If upstream changes, downstream feels it; the reverse is not true. DDD names common patterns for these relationships so teams can say which one they have.

PatternMeaningExample here
PartnershipTwo teams succeed or fail together and plan changes jointly.Lettings and Inspections, since notices depend on inspections
Shared kernelA small, jointly owned piece of model both sides use directly.A shared crate holding PlotId andRods
Customer–supplierDownstream’s needs influence what upstream builds.Billing asks Lettings to include the plot size in its events
ConformistDownstream simply adopts upstream’s model, with no translation.Billing uses the payment provider’s idea of a payment
Anticorruption layerDownstream translates upstream’s model into its own at the border.Lettings importing the council register (chapter 12)
Open host serviceUpstream offers a well-defined protocol for anyone to use.A documented Lettings API for other contexts
Published languageA documented shared format for exchanging information.The versioned shape of TenancyStarted
Separate waysIntegration isn’t worth it; the contexts don’t connect.The seed-swap noticeboard

The map should describe what is, not what you wish. If the “partner” team never answers your messages, you are conformist or need an anticorruption layer, whatever the org chart says.

06 / Tactical design begins

Value objects

Now we zoom into one bounded context, Lettings, and start writing code. The first building block is the one you’ll use most.

A value object describes something by its attributes alone. It has no identity: two £5 notes are interchangeable, and “five rods” equals any other “five rods”. Value objects are immutable: you don’t change a value, you replace it with another. And they are always valid: if you hold one, it obeys its rules.

Rust is very good at this. A newtype with a private field and a checking constructor means invalid values cannot be built outside the module. derive gives value equality for free, and Copy is natural for small values.

use std::fmt;
use std::ops::{Add, Mul};

/// Plot sizes are measured in rods, as the society always has.
/// A full plot is ten rods; a half plot is five.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Rods(u8);

#[derive(Debug, PartialEq, Eq)]
pub struct InvalidPlotSize(pub u8);

impl Rods {
    pub const HALF_PLOT: Rods = Rods(5);
    pub const FULL_PLOT: Rods = Rods(10);

    pub fn new(rods: u8) -> Result<Rods, InvalidPlotSize> {
        match rods {
            1..=20 => Ok(Rods(rods)),
            _ => Err(InvalidPlotSize(rods)),
        }
    }

    pub fn get(self) -> u8 {
        self.0
    }
}

/// An amount of money, held in pence. Never a float.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Money(u64);

impl Money {
    pub const fn pence(pence: u64) -> Money {
        Money(pence)
    }

    pub const fn pounds(pounds: u64) -> Money {
        Money(pounds * 100)
    }
}

impl Add for Money {
    type Output = Money;
    fn add(self, other: Money) -> Money {
        Money(self.0 + other.0)
    }
}

/// A rate per rod multiplied by a size gives a rent.
impl Mul<Rods> for Money {
    type Output = Money;
    fn mul(self, size: Rods) -> Money {
        Money(self.0 * u64::from(size.0))
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "£{}.{:02}", self.0 / 100, self.0 % 100)
    }
}
let per_rod = Money::pence(650);
let rent = per_rod * Rods::HALF_PLOT;
assert_eq!(rent.to_string(), "£32.50");

// There is no such thing as a zero-rod plot, so you cannot make one.
assert_eq!(Rods::new(0), Err(InvalidPlotSize(0)));

Notice what the types prevent. You can’t pass aRods where Moneyis wanted. You can multiply a rate by a size, but you can’t multiply two sums of money, because that means nothing in the domain. And every function that takes Rodscan skip checking for zero, because the check happened once, at the border. This idea is often summarised as “parse, don’t validate”.

Primitive obsession

A u8 can’t tell you whether it’s rods, square metres or a percentage. Passing raw primitives around is the most common way a model leaks out of its types. When a primitive carries a domain meaning or a rule, give it a name.

07 / Same thing, changing over time

Entities

Some things are not defined by their attributes. If Ann changes her email address, she is still the same member. If two members are both called John Smith, they are still different people. Anentityis an object with an identitythat runs through its whole lifecycle, even as its attributes change.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MemberId(pub u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PlotId(pub u32);

#[derive(Debug, Clone)]
pub struct Member {
    id: MemberId,
    name: String,
    email: EmailAddress,
}

impl Member {
    pub fn join(id: MemberId, name: String, email: EmailAddress) -> Member {
        Member { id, name, email }
    }

    pub fn id(&self) -> MemberId {
        self.id
    }

    pub fn change_email(&mut self, email: EmailAddress) {
        self.email = email;
    }
}

/// Two members are the same member if they have the same identity,
/// whatever else about them has changed.
impl PartialEq for Member {
    fn eq(&self, other: &Member) -> bool {
        self.id == other.id
    }
}

impl Eq for Member {}

Three Rust habits make entities pleasant:

  • Typed identifiers.MemberIdand PlotIdare both numbers underneath, but the compiler won’t let you swap them. That bug is common, and here it can’t compile.
  • Private fields, named methods.Changes go through methods like change_email, so the entity can enforce its rules and the method names stay in the ubiquitous language.
  • Equality by identity.The hand-written PartialEqsays what identity means. Use this with care: some teams prefer to compare IDs explicitly and not implement equality on entities at all, so nobody is surprised.

A useful rule of thumb: make something a value object unless you need to track it as the same thing over time. Most models have far more values than entities.

08 / Where the rules live

Aggregates

The society’s rules are about groups of objects. A plot can’t be offered while it is let. An offer can only be accepted by the member it was made to, within fourteen days. A tenancy can only end for non-cultivation if a notice was served at least twenty-eight days ago. Rules that must alwayshold are called invariants.

An aggregate is a cluster of entities and values treated as one unit for changes. It draws a consistency boundary: everything inside is kept valid together, in a single transaction. One entity is the aggregate root, and the outside world may only hold references to the root and change things by calling its methods.

COMMANDS ENTER HERE ONLYPlot · rootRodsPlotIdOccupancyTenancypending_eventsONE AGGREGATE · ONE TRANSACTION · RULES ALWAYS HOLDBY IDMemberWaitingListOTHER AGGREGATES
03 — The Plot aggregate. Tenancy lives inside it; Member and WaitingList are separate aggregates, referenced by ID.

Here is the state. The Occupancyenum makes illegal states unrepresentable: a plot can’t be both offered and let, and an offer always has an expiry date. Only Plotis public; the inner types are private to the module.

use chrono::{Days, NaiveDate};

const OFFER_PERIOD: Days = Days::new(14);
const NOTICE_PERIOD: Days = Days::new(28);

#[derive(Debug, Clone)]
pub struct Plot {
    id: PlotId,
    size: Rods,
    occupancy: Occupancy,
    pending_events: Vec<LettingsEvent>,
}

#[derive(Debug, Clone, PartialEq)]
enum Occupancy {
    Vacant,
    Offered { to: MemberId, expires_on: NaiveDate },
    Let(Tenancy),
}

#[derive(Debug, Clone, PartialEq)]
struct Tenancy {
    tenant: MemberId,
    started_on: NaiveDate,
    notice_served_on: Option<NaiveDate>,
}

#[derive(Debug, PartialEq, Eq)]
pub enum LettingError {
    NotVacant(PlotId),
    NoOfferFor { plot: PlotId, member: MemberId },
    OfferExpired { plot: PlotId, expired_on: NaiveDate },
    NotLet(PlotId),
    NoticeAlreadyServed(PlotId),
    NoticePeriodRunning { plot: PlotId, ends_on: NaiveDate },
    NoNoticeServed(PlotId),
}

And the behaviour. Every method checks the invariants, then either changes the state and records what happened or returns an error naming the broken rule. There is no way to setoccupancy directly, so no caller can skip a check.

impl Plot {
    pub fn register(id: PlotId, size: Rods) -> Plot {
        Plot {
            id,
            size,
            occupancy: Occupancy::Vacant,
            pending_events: Vec::new(),
        }
    }

    pub fn offer_to(&mut self, member: MemberId, today: NaiveDate) -> Result<(), LettingError> {
        if self.occupancy != Occupancy::Vacant {
            return Err(LettingError::NotVacant(self.id));
        }
        let expires_on = today + OFFER_PERIOD;
        self.occupancy = Occupancy::Offered {
            to: member,
            expires_on,
        };
        self.record(LettingsEvent::PlotOffered {
            plot: self.id,
            member,
            expires_on,
        });
        Ok(())
    }

    pub fn accept_offer(&mut self, member: MemberId, today: NaiveDate) -> Result<(), LettingError> {
        let expires_on = match self.occupancy {
            Occupancy::Offered { to, expires_on } if to == member => expires_on,
            _ => {
                return Err(LettingError::NoOfferFor {
                    plot: self.id,
                    member,
                });
            }
        };
        if today > expires_on {
            return Err(LettingError::OfferExpired {
                plot: self.id,
                expired_on: expires_on,
            });
        }
        self.occupancy = Occupancy::Let(Tenancy {
            tenant: member,
            started_on: today,
            notice_served_on: None,
        });
        self.record(LettingsEvent::TenancyStarted {
            plot: self.id,
            tenant: member,
            size: self.size,
            started_on: today,
        });
        Ok(())
    }

    pub fn end_tenancy(&mut self, reason: EndReason, today: NaiveDate) -> Result<(), LettingError> {
        let Occupancy::Let(tenancy) = &self.occupancy else {
            return Err(LettingError::NotLet(self.id));
        };
        if reason == EndReason::NonCultivation {
            let served_on = tenancy
                .notice_served_on
                .ok_or(LettingError::NoNoticeServed(self.id))?;
            let ends_on = served_on + NOTICE_PERIOD;
            if today < ends_on {
                return Err(LettingError::NoticePeriodRunning {
                    plot: self.id,
                    ends_on,
                });
            }
        }
        let tenant = tenancy.tenant;
        self.occupancy = Occupancy::Vacant;
        self.record(LettingsEvent::TenancyEnded {
            plot: self.id,
            tenant,
            ended_on: today,
            reason,
        });
        Ok(())
    }

    // serve_non_cultivation_notice follows the same shape.
}

Rust’s ownership model fits aggregates well. Changing a plot requires &mut Plot, so the borrow checker ensures nothing else in the process is looking at a half-updated plot. Code that holds a &Plot can read but can’t change the plot.

Designing the boundary

Choosing what goes inside an aggregate is the hard part. Vaughn Vernon’s widely used rules of thumb are:

  1. Protect true invariants.Put things in the same aggregate only when a rule must hold between them at every moment.
  2. Keep aggregates small.Large aggregates are slow to load and cause conflicts when two people edit different parts at once.
  3. Refer to other aggregates by ID.Tenancy holds a MemberId, not a Member. In Rust this also avoids a tangle of shared references and lifetimes.
  4. Change one aggregate per transaction.Rules spanning aggregates are made consistent eventually, usually with domain events.
Try it: “no member may hold more than ten rods”

This rule spans several Plot aggregates. You could make aMemberaggregate that owns all its tenancies, but then every letting change locks the member. Alternatively, check the rule when making an offer, using a read of the member’s current holdings, and accept a small risk of a race. Ask the domain expert how bad a brief breach would be. Often the honest answer is “not very; the secretary would sort it out”, which favours the simpler design.

What about typestate?

Rust can go further, with separate VacantPlotand LetPlottypes whose methods consume self. That catches misuse at compile time, but aggregates are usually loaded from storage at runtime, where the state isn’t known in advance. An enum inside one type is usually the better fit at this level.

09 / Telling others what happened

Domain events

When a tenancy starts, Billing should charge rent and the waiting list should drop the new tenant. The Plot aggregate shouldn’t know about either; that would tie Lettings to every context that cares. Instead, it records a domain event: a fact about something that happened which domain experts care about. Events are named in the past tense, because they can’t be undone or refused, only reacted to.

#[derive(Debug, Clone, PartialEq)]
pub enum LettingsEvent {
    PlotOffered {
        plot: PlotId,
        member: MemberId,
        expires_on: NaiveDate,
    },
    TenancyStarted {
        plot: PlotId,
        tenant: MemberId,
        size: Rods,
        started_on: NaiveDate,
    },
    NonCultivationNoticeServed {
        plot: PlotId,
        tenant: MemberId,
        served_on: NaiveDate,
    },
    TenancyEnded {
        plot: PlotId,
        tenant: MemberId,
        ended_on: NaiveDate,
        reason: EndReason,
    },
}

impl Plot {
    pub fn take_events(&mut self) -> Vec<LettingsEvent> {
        std::mem::take(&mut self.pending_events)
    }

    fn record(&mut self, event: LettingsEvent) {
        self.pending_events.push(event);
    }
}

The aggregate collects events as it changes, and the application drains them with take_eventsafter a successful command. Other parts of the system react, each in its own transaction.

LETTINGS · PLOT 14accept_offer()TenancyStartedBILLINGcharge £32.50WAITING LISTremove Ann“SOMETHING HAPPENED”— PAST TENSE, A FACTONE TRANSACTION PER AGGREGATE · THE REST CATCHES UP
04 — One command, one aggregate changed. Everything else reacts to the event.

Here is Billing reacting, in its own language. ItsRentCharge knows nothing about offers or notices. It only needs who pays, for which plot, how much and by when.

// In the Billing context. A "plot" here is just a size to charge for.
pub struct RentCharge {
    pub payer: MemberId,
    pub plot: PlotId,
    pub amount: Money,
    pub due_on: NaiveDate,
}

pub struct Tariff {
    pub per_rod: Money,
}

impl Tariff {
    pub fn annual_rent(&self, size: Rods) -> Money {
        self.per_rod * size
    }
}

/// Billing listens to Lettings and decides, in its own terms,
/// what a new tenancy means for money.
pub fn on_lettings_event(event: &LettingsEvent, tariff: &Tariff) -> Option<RentCharge> {
    match event {
        LettingsEvent::TenancyStarted {
            plot,
            tenant,
            size,
            started_on,
        } => Some(RentCharge {
            payer: *tenant,
            plot: *plot,
            amount: tariff.annual_rent(*size),
            due_on: *started_on + Days::new(30),
        }),
        _ => None,
    }
}

This is eventual consistency in practice: for a moment, a tenancy exists without a rent charge. For an allotment society that moment could be a day and nobody would mind. Domain experts are usually far more relaxed about delays than engineers expect, so ask them.

Domain events vs integration events

Here Billing consumes LettingsEventdirectly, which couples it to Lettings’ internal model. Across services or teams, it’s common to publish separate, versioned integration events(a published language) and keep the internal events free to change.

10 / Pretending everything is in memory

Repositories

Aggregates must be loaded and saved somewhere. Arepositorygives the domain the illusion of a collection of aggregates in memory: get one by ID, save it back. How it does that (SQL, files, an HTTP API) is hidden. There is one repository per aggregate type, and it loads and saves wholeaggregates, never a stray Tenancyon its own.

In Rust, a repository is a trait the domain defines and the infrastructure implements:

use std::collections::HashMap;

#[derive(Debug)]
pub struct RepositoryError(pub String);

pub trait PlotRepository {
    fn get(&self, id: PlotId) -> Result<Option<Plot>, RepositoryError>;
    fn save(&mut self, plot: &Plot) -> Result<(), RepositoryError>;
}

/// Good enough for tests, and for thinking about the model
/// before choosing a database.
#[derive(Default)]
pub struct InMemoryPlots {
    plots: HashMap<PlotId, Plot>,
}

impl PlotRepository for InMemoryPlots {
    fn get(&self, id: PlotId) -> Result<Option<Plot>, RepositoryError> {
        Ok(self.plots.get(&id).cloned())
    }

    fn save(&mut self, plot: &Plot) -> Result<(), RepositoryError> {
        self.plots.insert(plot.id(), plot.clone());
        Ok(())
    }
}

A real implementation would map Plotto rows and back, and use a version number to detect two users changing the same plot at once (optimistic concurrency). The domain code doesn’t change either way.

Creating a complex aggregate correctly is sometimes a job in itself; DDD calls the thing that does it a factory. In Rust, a named constructor such as Plot::registeris usually all you need. Reach for a separate factory type only when construction needs outside information.

11 / Orchestration without rules

Services and layers

The word “service” means two different things in DDD, and mixing them up causes a lot of confusion.

Domain services

Some domain logic doesn’t belong to any one entity or value. Deciding who gets a vacant plot involves the plotand the waiting list. Adomain service is a stateless operation named in the ubiquitous language. In Rust it is often just a free function:

/// Offering a vacancy is a rule about plots *and* the waiting list,
/// so it lives in neither.
pub fn offer_vacancy(
    waiting_list: &WaitingList,
    plot: &mut Plot,
    today: NaiveDate,
) -> Result<MemberId, AllocationError> {
    let member = waiting_list
        .next_wanting(plot.size())
        .ok_or(AllocationError::NobodyWaiting)?;
    plot.offer_to(member, today)
        .map_err(AllocationError::Letting)?;
    Ok(member)
}

It reads the waiting list but only changes the plot, keeping to one aggregate per transaction. The waiting list removes the member later, when it hears PlotOffered.

Application services

An application service runs a use case from start to finish: load the aggregate, call one domain method, save, publish events. It contains no business rules. If you find an ifabout offers or notices in here, it belongs in the domain.

pub trait Clock {
    fn today(&self) -> NaiveDate;
}

pub trait EventPublisher {
    fn publish(&mut self, events: Vec<LettingsEvent>);
}

pub struct AcceptOffer {
    pub plot: PlotId,
    pub member: MemberId,
}

#[derive(Debug)]
pub enum CommandError {
    UnknownPlot(PlotId),
    Rejected(LettingError),
    Storage(RepositoryError),
}

// From<LettingError> and From<RepositoryError> impls omitted.

pub struct Lettings<R, P, C> {
    plots: R,
    publisher: P,
    clock: C,
}

impl<R: PlotRepository, P: EventPublisher, C: Clock> Lettings<R, P, C> {
    pub fn accept_offer(&mut self, cmd: AcceptOffer) -> Result<(), CommandError> {
        let mut plot = self
            .plots
            .get(cmd.plot)?
            .ok_or(CommandError::UnknownPlot(cmd.plot))?;

        plot.accept_offer(cmd.member, self.clock.today())?;

        let events = plot.take_events();
        self.plots.save(&plot)?;
        self.publisher.publish(events);
        Ok(())
    }
}

Generic parameters keep the service ignorant of infrastructure. In tests, pass an InMemoryPlots, a fixed clock and aVec that collects events. In production, pass a database repository and a message bus. Taking the date from a Clockrather than calling the system time inside the domain keeps the aggregate deterministic and easy to test.

ADAPTERS · INFRASTRUCTUREHTTP / CLI → commandsAPPLICATION SERVICESDOMAIN MODELPlot · Tenancy · RodsMoney · LettingsEventtrait PlotRepository · EventPublisher · ClockCouncilimport(ACL)BillinghearseventsPostgres repo · message bus · system clock
05 — Ports and adapters. The domain depends on nothing; traits are the ports; infrastructure plugs in from outside.

This arrangement is often called hexagonal architectureor ports and adapters. It isn’t strictly part of DDD, but the two are commonly used together, because both aim to keep the model free of technical concerns. In a Cargo workspace you can enforce it physically: the domain crate simply doesn’t depend on sqlx or axum.

One note on publishing: if the save succeeds and the process crashes before publish, the event is lost. Real systems usually save events in the same database transaction as the aggregate and send them afterwards; this is the transactional outboxpattern.

12 / Translation at the border

Guard the border

The council owns the land and sends the society a spreadsheet export from its land register. It uses parcel references like ALT/0042, measures in square yards, and includes paths and sheds alongside plots. If Lettings used those concepts directly, the council’s model would leak into ours, and every change to the export format would ripple through the codebase.

An anticorruption layer(ACL) is a translation layer at the edge of a context. Upstream data comes in, is checked, and leaves as our own types. In Rust, TryFromis a natural home for it:

/// One row of the council's land-register export.
/// This is their language, not ours.
#[derive(Debug, Clone)]
pub struct LandRegisterRow {
    pub parcel_ref: String, // "ALT/0042"
    pub area_sq_yd: f64,
    pub use_code: String, // "ALLOT", "PATH", "SHED", ...
}

#[derive(Debug, PartialEq)]
pub enum ImportError {
    NotAPlot(String),
    BadReference(String),
    BadSize(InvalidPlotSize),
}

const SQUARE_YARDS_PER_ROD: f64 = 30.25;

impl TryFrom<&LandRegisterRow> for Plot {
    type Error = ImportError;

    fn try_from(row: &LandRegisterRow) -> Result<Plot, ImportError> {
        if row.use_code != "ALLOT" {
            return Err(ImportError::NotAPlot(row.parcel_ref.clone()));
        }
        let number = row
            .parcel_ref
            .strip_prefix("ALT/")
            .and_then(|n| n.parse().ok())
            .ok_or_else(|| ImportError::BadReference(row.parcel_ref.clone()))?;
        let rods = (row.area_sq_yd / SQUARE_YARDS_PER_ROD).round() as u8;
        let size = Rods::new(rods).map_err(ImportError::BadSize)?;
        Ok(Plot::register(PlotId(number), size))
    }
}

Everything specific to the council lives here: their reference format, their unit (a rod is 30¼ square yards), their use codes. Rows that aren’t plots are rejected with a reason rather than quietly imported. Beyond this file, nothing in Lettings knows the council exists. In a larger system an ACL might be a whole module or service, with adapters, translators and its own tests, but the job is the same.

13 / Judgement, not ceremony

Using DDD well

Discovering the model

None of the code above should be written first. The model comes from conversations. EventStorming, a workshop format created by Alberto Brandolini, is a popular way to start: put developers and domain experts in a room with a long wall and orange sticky notes, and ask them to write down every domain event (“Offer made”, “Offer expired”, “Notice served”) in time order. Arguments about the order and wording reveal the language, the invariants and the boundaries between contexts. Other techniques, such as domain storytelling and example mapping, serve the same aim.

Common pitfalls

  • The anemic domain model.Structs with public fields and no behaviour, with all the rules in “service” functions. It has the cost of DDD without the benefit. If your aggregate has no methods that can fail, look harder for its rules.
  • Patterns everywhere.A repository, factory and domain service for every table is ceremony. Use the tactical patterns in the core; let generic and supporting parts be simple.
  • One model to rule them all.A shared “domain” crate that every service depends on is a single bounded context in disguise, and all the teams end up coupled to it.
  • Tactical without strategic.Perfect aggregates in the wrong boundaries still give you a tangled system. Draw the context map first.
  • Huge aggregates.If loading one means loading half the database, split it and use events.

When not to use it

DDD is a response to complex domains. If your application mostly moves data between a form and a table, with few rules, simple CRUD is the right design and DDD will slow you down. The strategic ideas (shared language, explicit boundaries) are cheap and almost always useful. The tactical patterns are worth it only where the rules are rich enough to need protecting.

Glossary

TermIn one line
DomainThe area of activity the software serves.
ModelA deliberately simplified understanding of the domain, chosen for a purpose.
Ubiquitous languageThe shared vocabulary used by experts, developers and the code alike, within a context.
SubdomainA part of the business: core, supporting or generic.
Bounded contextAn explicit boundary inside which one model and language apply.
Context mapA picture of the contexts and the relationships between them.
Value objectImmutable, defined by its attributes, compared by value.
EntityHas an identity that persists while its attributes change.
AggregateA cluster changed as one unit; it protects invariants inside its boundary.
Aggregate rootThe single entity through which an aggregate is accessed and changed.
InvariantA rule that must hold whenever an aggregate is saved.
Domain eventA past-tense fact that domain experts care about.
RepositoryLoads and saves whole aggregates, hiding storage.
FactoryEncapsulates creating a complex, valid aggregate.
Domain serviceStateless domain logic that belongs to no single object.
Application serviceRuns a use case: load, call the domain, save, publish. No rules.
Anticorruption layerTranslates another context’s model into yours at the border.

Your next steps

  1. Draw a context map of a system you know, showing what actually exists. Mark the upstream and downstream ends.
  2. Find one primitive that carries a rule (an ID, an amount, a size) and turn it into a value object.
  3. Find one invariant enforced in a controller or service and move it into the entity or aggregate that owns it.
  4. Sit with a domain expert for an hour and write down every word they use that your code doesn’t.

Keep these beside your editor

Sources & further reading

The allotment society, its rules and the code are teaching examples. The code compiles against Rust 1.98 andchrono 0.4. For the ideas themselves, these are the places to go next:

Back to the beginning ↑