Four Models of One Person
In the customer service system I’ve been building at work, a support agent is four different things. To authentication, they’re a user who signs in and has roles. To the module that knows the people, they’re a profile with a name and a team. To the module that hands out work, they’re a ticket handler, someone who can be given tickets to work on. And to the module that owns the conversation, they only matter as someone who may reply and who wrote a message.
That’s four models of one person, and it’s on purpose. Each of them is small,
and each module can change its own without asking the others. One shared
Agent class would have tied all four modules together.
This post starts a series of articles based on presentations I’ve given at work about that system, a backend in .NET. Customers send tickets, agents reply to them, and in between the system decides who should handle what and by when. The series is a field report rather than a tutorial: six months of one team building one system, what we started with, and what changed along the way.
This first post is about domain-driven design, but it isn’t an introduction. For that, Martin Fowler’s short overview is a good start, and his notes on ubiquitous language, bounded contexts, aggregates and value objects cover the terms used here. For depth, there’s the blue book, Eric Evans' Domain-Driven Design, and the red book, Vaughn Vernon’s Implementing Domain-Driven Design. This post is about what those ideas look like in one real codebase. The code examples are simplified, and the business rules in them are generic examples rather than the real ones, but the shapes are the same.
The modelling effort goes where the business is complex. In this system that means deciding who works on which ticket, keeping track of deadlines, and deciding who may do what to a ticket. A list of reply templates doesn’t need aggregates.
Speak the glossary in code
The words the business uses are written down in a glossary in the repository,
where each term has an example sentence and a note on what it means in the
UI. It was one of the first files in the repository, written before most of
the code. Event Storming boards live there too, as text made with
estorm, a small tool I wrote for exactly that. The code
uses the same words. Methods are the verbs people say, and events are what
people say happened. “The customer follows up” is ticket.FollowUp(…), “an
agent replied” is the event TicketReplied, and “close it as spam” is
ticket.CloseAsSpam(…).
My favourite example is the queue. That’s what everybody calls the tickets
waiting for someone to pick them up, so the domain service that hands out the
next one is called TicketAssignmentQueue. .NET’s code analysis doesn’t like
that, because it has a rule that only collections should end in “Queue”. This
class isn’t a collection. We kept the word anyway and wrote down why:
[SuppressMessage(
"Naming",
"CA1711:Identifiers should not have incorrect suffix",
Justification = "Domain term: the tickets waiting to be picked up.")]
public sealed class TicketAssignmentQueue { … }When the analyzer and the language disagree, the language wins.
One module, one model
The backend is a modular monolith, and each module is a bounded context. A module owns its model, its own database context and even its own id types. Four of them carry most of the business:
- Catalog owns topics, the things customers can write about, and how each of them should be handled.
- Tickets owns the conversation: tickets, messages and their deadlines.
- Assignments decides who works on a ticket.
- Profiles knows the agents.
Tickets and Assignments are the core domain: they hold the rules that make the system worth building in the first place. Catalog and Profiles support them, and around them sit generic modules for things like attachments, notifications and authentication. Modules can ask each other for facts through public contracts, or tell each other that something happened with integration events. The third post in the series is about both.
Because of the boundaries, the same word can mean different things in different modules. A topic in Catalog is a full aggregate, with a name, a response time, a priority and rules about changing them. Tickets only needs enough to work out when a ticket is due, so it keeps its own small snapshot:
public sealed record TopicSnapshot(TopicId Id, TimeSpan ResponseTime, bool IsArchived);Assignments orders the queue by priority, so its snapshot is an id and a number:
public sealed record TopicSnapshot(TopicId Id, int Priority);Each module also has its own TopicId type. They all wrap the same Guid, but
they’re different types, so nothing from Catalog’s model leaks into the other
modules. It’s the same reason the agent at the start of this post is four
models instead of one.
Rules live on the type that owns them
Inside a module, the model lives in a project called Core, which references nothing but a small shared kernel: no database, no HTTP and no logging.
The main aggregate in Tickets is Ticket. It has no public constructor and no
setters. A ticket is born through a named factory like Ticket.Submit, and
every change after that is a verb from the glossary, with its rules inside it:
public void FollowUp(MessageContent content, CustomerId customerId, DateTimeOffset sentAt)
{
if (!CanCustomerFollowUp)
throw new CannotFollowUpOnClosedTicketException();
if (customerId != Owner)
throw new OnlyTicketOwnerCanFollowUpException();
AddMessage(content, Author.Customer(customerId), sentAt);
if (Lifecycle.Status == TicketStatus.Completed)
Lifecycle = Lifecycle.Reopen(sentAt);
RaiseDomainEvent(new TicketFollowedUp(Id, sentAt));
}The method checks the rules, changes the state and records what happened, so every follow-up goes through the same checks and nobody can forget to raise the event.
Value objects can hold behaviour, too. TicketLifecycle is the ticket’s state
machine:
public sealed record TicketLifecycle
{
public TicketStatus Status { get; }
public DateTimeOffset ChangedAt { get; }
private TicketLifecycle(TicketStatus status, DateTimeOffset changedAt) { … }
public TicketLifecycle Complete(DateTimeOffset at)
{
if (Status != TicketStatus.Open)
throw new TicketHasToBeOpenToBeCompletedException();
return new TicketLifecycle(TicketStatus.Completed, at);
}
public TicketLifecycle Reopen(DateTimeOffset at)
{
if (Status == TicketStatus.Closed)
throw new ClosedTicketCannotBeReopenedException();
return new TicketLifecycle(TicketStatus.Open, at);
}
// Close, CloseAsSpam…: the same shape, each with its own rule
}Every transition checks its own rule and returns a new lifecycle. The ticket
decides when to move to a new state, and the lifecycle decides whether it may.
In FollowUp, all the ticket decides is that a follow-up on a completed
ticket reopens it. The lifecycle does the rest.
Some rules don’t belong to any single object. Picking the next ticket for an
agent needs every waiting assignment at once, so no single assignment can own
that rule. It lives in TicketAssignmentQueue, the class the analyzer
complained about, which is a domain service: plain code in Core that the use
case calls after loading the data.
Events, errors and tests
Aggregates only record their domain events. Nothing is dispatched until the change is committed, and events for other modules go through an outbox, which is in the third post.
When a rule is broken, the exception says so in the domain’s words, like
CannotReplyToClosedTicketException. Where it’s thrown depends on what you
need to know to throw it. Core throws when an invariant breaks and no I/O is
needed to see it. Application throws when it takes a lookup to know, like a
ticket that doesn’t exist. Every exception extends one of a few categories,
like validation, conflict or not found, and the host maps those to HTTP status
codes. The domain never knows there’s HTTP in front of it.
We chose exceptions over result types for two reasons. With a handful of categories mapped in one place, use cases and endpoints carry no error plumbing at all. And C#’s support for discriminated unions is weak next to F#’s, so result types feel forced in C#, where exceptions feel natural.
Because Core doesn’t call out to anything, its tests need no mocks:
[Fact]
public void Customer_cannot_follow_up_on_a_closed_ticket()
{
var customerId = CustomerId.New();
var ticket = CreateSubmittedTicket(customerId, Now);
ticket.CloseAsSpam(Now.AddMinutes(5));
Assert.Throws<CannotFollowUpOnClosedTicketException>(() =>
ticket.FollowUp(MessageContent.From("Any news?"), customerId, Now.AddMinutes(10)));
}Time is always passed in, never read from a global clock, so deadlines come out the same on every run. The test names are the rules written as sentences, so the list of tests reads like a spec. If a domain test seems to need a mock, the type under test probably depends on something it shouldn’t.
Where it started
The model didn’t start out this strict. For the first four months, value
objects exposed their inner value as a public property, so a TicketId could
be unwrapped back into a Guid anywhere. Hiding that state behind
ToPrimitive(), and adding an architecture test that fails if a simple value
object has a public property, touched almost 60 files.
A value object only protects you when getting the raw value out takes a deliberate call, and making it deliberate from the start would have been much cheaper.
The next post is about everything around the model: how endpoints, databases and background jobs reach it without leaking into it.