Types as Proof
Only the agent who’s assigned to a ticket may reply to it. The obvious way to enforce that is a check before the reply:
if (!IsAssignedTo(ticketId, agentId))
throw new NotAssignedException();
// later: trusts that someone checked
ticket.Reply(content, agentId, now);This works until the next use case forgets the check. The check returns a
bool, and the answer is thrown away. When Reply runs, it gets an AgentId
that may or may not have been checked, and nothing in the types tells you
which.
This last post in the series is about making checks like that impossible to skip. The first post built the model, and the second and third put a boundary around it: ports keep technology out, and bridges keep other modules out. This one keeps invalid data out, using an idea from Alexis King’s 2019 post Parse, don’t validate.
Parsing keeps the answer
A parser does the same check, but instead of answering yes or no, it turns less structured input into more structured output. The output type records what was checked:
var agent = AssignedAgent.Create(agentId, ticketId, assignment);
// the type is the proof
ticket.Reply(content, agent, now);The idea goes much further than this post does. Scott Wlaschin’s Designing with types series and his book Domain Modeling Made Functional take it a long way, in F#. His parsers return a result type when the input is wrong. The ones here throw instead, for the reasons in the first post.
A type that can only exist if the rule held
AssignedAgent is a value object in Core. Its constructor is private, so
Create is the only way to get one:
public sealed record AssignedAgent
{
public AgentId AgentId { get; }
public TicketId TicketId { get; }
private AssignedAgent(AgentId agentId, TicketId ticketId) { … }
public static AssignedAgent Create(
AgentId agentId,
TicketId ticketId,
TicketAssignmentSnapshot assignment)
{
if (assignment.TicketId != ticketId)
throw new TicketAssignmentMismatchException();
var assigneeId = assignment.AssigneeId
?? throw new TicketNotAssignedToAgentException();
if (assigneeId != agentId)
throw new TicketAssignedToDifferentAgentException();
return new AssignedAgent(agentId, ticketId);
}
}There are three rules: the assignment is for this ticket, the ticket has an assignee, and the assignee is this agent. Each one fails with its own exception.
If you’re holding an AssignedAgent, all three have passed, and nobody
further down needs to check them again.
And Ticket.Reply only accepts the proof:
public void Reply(MessageContent content, AssignedAgent agent, DateTimeOffset repliedAt) { … }You can’t reply with an unchecked agent. It won’t compile.
Ports supply the facts
Who’s assigned to a ticket isn’t something Tickets knows. That belongs to the Assignments module, so the use case asks for it through a driven port, which a bridge implements as described in the previous post:
var ticket = await tickets.LoadAsync(ticketId, ct);
var assignment = await assignments.GetByTicketIdAsync(ticketId, ct);
var agent = AssignedAgent.Create(agentId, ticketId, assignment);
ticket.Reply(content, agent, now);
await changes.CommitAsync(ct);The port gives the use case facts, the value object turns the facts into proof, and the aggregate only accepts the proof.
Reply still checks that the proof is for this ticket and not some other one,
which is a cheap safety net.
The proof is only as good as the facts behind it, though.
TicketAssignmentSnapshot is a plain record, because the bridge in
Infrastructure has to build it, so a use case could build one by hand and get
a proof that means nothing. What makes the facts true is that they come from
the port. The proof is also a snapshot in time. Tickets and Assignments don’t
share a transaction, so the ticket could be reassigned a moment after the
read, and the reply would still go through. Proof types make a check
impossible to skip, not impossible to go stale.
The same recipe in another module
In Assignments, an agent may pick up a ticket only if they handle the ticket’s topic. The use case loads the assignment, fetches the agent’s topics through a driven port, and parses them:
public sealed record TicketHandlerSnapshot(TicketHandlerId Id, IReadOnlySet<TopicId> HandledTopics);
public sealed record SelfAssignmentEligibility
{
public TicketId TicketId { get; }
public TicketHandlerId HandlerId { get; }
private SelfAssignmentEligibility(TicketId ticketId, TicketHandlerId handlerId) { … }
public static SelfAssignmentEligibility Create(
TicketHandlerSnapshot handler,
TicketAssignment assignment)
{
if (!handler.HandledTopics.Contains(assignment.TopicId))
throw new SelfAssignmentForbiddenException();
return new SelfAssignmentEligibility(assignment.TicketId, handler.Id);
}
}Each fact arrives with its own id: the topics come with the handler they belong to, and the topic comes with its ticket. The proof takes its ids from the facts, so there’s no way to check one agent’s topics and hand the proof to another.
Here the agent is a TicketHandlerId, not an AgentId. That’s Assignments'
model of the same person, one of the four from the
first post. And the aggregate demands the
proof:
public void SelfAssign(SelfAssignmentEligibility eligibility, DateTimeOffset assignedAt) { … }Ports and parsing meet at the same boundary
Adapters speak the outside world’s language: commands carry Guids and strings, because that’s what HTTP, jobs and other modules have. The use case parses that input once. From there on, Core and every driven port only see types that are already valid:
adapters use case Core and driven ports
(outside) (parses once) (inside)
Guid topicId ──▶ TopicId.From(…) ──▶ TopicId
string content ──▶ MessageContent.From(…) ──▶ MessageContent
Guid agentId ─┐
├─▶ AssignedAgent.Create(…) ──▶ AssignedAgent
assignment ─┘That’s why driven ports never take a Guid, the rule that turned the build red
at the start of the
ports and adapters post. A
typed id doesn’t make the value valid, because TicketId.From accepts any
Guid. What it prevents is mix-ups. A driven port that takes a Guid will take a
customer’s id as happily as a ticket’s, and one that takes a TicketId
won’t. It also keeps parsing in one place: the use case turns Guids into ids
once, and the compiler checks every hand-off after that.
None of this was there from the start. The first cross-module port took a
Guid and returned a snapshot full of them, and the Guid rule only arrived
months later. The proof types came after it: AssignedAgent,
SelfAssignmentEligibility and one more eligibility record all arrived in the
same week, about seven weeks after the rule. My reading of the order: once
Guids could no longer travel past the use case, the use case became the one
obvious place to parse.
Wrapping up the series
If I had to boil the series down to five ideas, they’d be these:
- Speak the glossary in code. If the business wouldn’t say it, don’t name it that.
- Put each rule on the type that owns it, and make wrong states impossible to build.
- Name ports after what the application needs, and keep them in the application.
- Translate at the edges, whether the other side is a database or another module.
- Let types carry proof. Parse at the boundary, and the domain never re-checks.
None of them depend on .NET or need a framework. The series page has all the conventions on one page, as a cheat sheet.