Making Illegal States Unrepresentable: Type-Driven Domain Modeling
One of my biggest concerns with dynamically typed languages is that they postpone errors to runtime, which is of course not ideal. However, I want to discuss something different today: how we can reduce runtime errors even in statically typed languages (i.e., Java, C#, etc.) by leveraging types more effectively.
Let's define a small editor
Use case: we have a system for documents where a document can be
Draft(having some id and maybe a history)UnderReviewwhere we can have some reviewers each of whom has potentially reviewedPublished: having published date and maybe DOI too
There are many problems we can discuss but the most interesting one (in my opinion) is how are we gonna handle the different states of the document? (I.e, how to check we would not try to publish a document before reviewing it?)
Most of the oop designs I have encountered usually handle this issue by having some Boolean isReviewed and in the publish member method, check whether isReviewed was set to true, otherwise throwing an exception.
This is of course a valid design, and we usually make sure it is correct throw Unit Testing (AssertThrows usually), but would not it be nice to make it impossible for this error to occur?
Get help from a friend: The compiler
When hearing about functional programming, the first thing we usually know is everything is a function and that functions are things / However, there is a bit not popular another idea of FP: Type everything!
Well the problem above was that the Document class holds many information and many responsibilities. Therefore it needed to keep track in what state it is currently in.
Extract your states
Whether we encode them in our code or not, but based on our understanding of the domain we have indeed three states for the document. Why not encode those states explicitly then?
using System;
using System.Collections.Generic;
using System.Linq;
// ---------------- Example ----------------
var coreData= new DocumentData("1", "Doc", "Alice", "Hello world");
var draft = new Draft(coreData);
// This won't compile - Draft doesn't have a Publish method!
//draft.Publish COMPILE Error!
var underReview = draft.Submit("Bob", "Carol");
underReview.AddComment("Bob", "Looks good!");
var published = underReview.Publish();
// Shared immutable data (owned by each document type)
public record DocumentData(string Id, string Title, string Author, string Content);
// ---------------- Draft ----------------
public record Draft
{
public DocumentData Data { get; }
public int Version { get; }
public Draft(DocumentData data, int version = 1)
=> (Data, Version) = (data, version);
public Draft WithEdit(string newContent) =>
new(Data with { Content = newContent }, Version + 1);
public UnderReview Submit( params string[] reviewers) =>
new(Data, reviewers.ToList()); // Only legal way to get UnderReview
}
public record ReviewerComment(string reviewer, string comment);
// ---------------- Under Review ----------------
public record UnderReview
{
internal UnderReview(DocumentData data, List<string> reviewers)
=> (Data, Reviewers) = (data, reviewers);
public DocumentData Data { get; }
public List<string> Reviewers { get; }
public List<ReviewerComment> ReviewerComments { get; } = new();
public UnderReview AddComment(string r, string c) {
ReviewerComments.Add(new (r,c));
return this;
}
public Draft SendBack() => new(Data);
public Published Publish() =>
new(Data, DateTime.UtcNow, Guid.NewGuid().ToString());
}
// ---------------- Published ----------------
public record Published
{
internal Published(DocumentData data, DateTime date, string doi)
=> (Data, Date, DOI) = (data, date, doi);
public DocumentData Data { get; }
public DateTime Date { get; }
public string DOI { get; }
public int ViewCount { get; private set; }
public void View() => ViewCount++;
}
As seen above, if we try to Publish something still draft we would get a Compile time error, it is literally impossible for this error to happen!
Type Everything!
Well, many good developers would think of this already following the SRP-Principle by heart. I have made this example to show that it works also on large scale and to also show the disadvantages of abstraction.
If we had gathered the three types under some abstract class Document, we would have had the problem we are trying to avoid. So the discussion ended up with the tip of using Composition instead of Inheritance whenever possible .
But we can take this even further! What if we applied this type-driven thinking to even the smallest units like GUID or string?
For example, you may have a Customer in your domain with a name represented as string, but we know strings can be null or empty, whereas customer names cannot be! So why not create a type like NonEmptyString?
Does it really make a difference?
If you were paying attention, you might notice something: this seems like just moving the problem around! You still have to validate when creating a NonEmptyString, right?
You are definitely right, but if we have applied this to all types, we would have the so called Functional Core, Imperative Shell pattern, so what the heck is this?
Clarify "Functional Core, Imperative Shell"
This pattern means having a pure, validated "core" of your domain logic surrounded by an "imperative shell" that handles I/O and validation. Think of it this way:
The Shell (boundaries of our system) : Reciever raw input (strings, nulls, invalid data) , validates it, and convert it to our domain types
The Core (The domain layer): works exclusively with validated types, making guarantees about Data Integrity
Once data enters your core as a NonEmptyString, you never need to check for null or empty again! The validation happens once at the boundary, and the type system enforces it everywhere else.
We just would have kind of a artificial layer surrounding our domain, responsible to give us the right types, meaning that we can be 100% at any point of time that NonEmptyStringis neither null or empty. I hope you can see how powerful this is, instead of checking this non-nullability after each possible modification, we can delegate this to the type itself.
In addition, now the creation of the name can be tested at one place only instead of each entity having some name in their attributes.
A Bonus: Rust's Ownership Model Takes This Further
The above Document example would work in any language, but Rust's ownership system adds an interesting dimension to this approach.
Note: If you haven't worked with Rust or C++, bear with me, I'll explain the concept simply.
In the C# example above, I still have the ability to access the old state of the document. For instance:
var draft = new Draft(coreData);
var underReview = draft.Submit("Bob");
var published = underReview.Publish();
// Oops! I can still call this:
underReview.Publish(); // Creates a second publication!
We'd still need internal state or checks to prevent duplicate operations.
Rust solves this with move semantics. When you call publish() on an UnderReview document, Rust moves (consumes) the value, making it inaccessible afterward:
let draft = Draft::new(data);
let under_review = draft.submit(reviewers); // draft is now moved/consumed (at least in this design)
let published = under_review.publish(); // under_review is now moved/consumed
// This won't compile:
under_review.publish(); // ERROR: value used after move
The compiler literally prevents you from using the old state! This makes certain classes of bugs not just unlikely, but impossible at compile time.
While this wasn't necessarily the intended use case for Rust's ownership model, it's a powerful side effect that takes type safety to the next level.
Wrapping Up
By encoding our domain states as distinct types rather than using flags and conditionals, we achieve several benefits:
Compile-time safety: Invalid state transitions become impossible, not just discouraged
Self-documenting code: The type system itself communicates the business rules
Reduced testing burden: We don't need to test for impossible states
Clearer intent: Each type has a focused responsibility
This approach isn't limited to document workflows. Any domain with distinct states (order processing, user registration flows, game states) can benefit from making illegal states unrepresentable.
The key takeaway? Use your type system as a design tool, not just a syntax requirement. The compiler is your friend. Let it help you catch errors before they happen!
