Backend
Entities Are Not Rows: A Domain Model That Behaves
Ritesh Totlani Dev.to (EN Zone)
1 views
Most codebases I've worked in call the classes in the middle of the diagram "entities" and call the layer they live in the "domain." Both words are doing less work than they look like they are. You can have a folder called Domain, a dependency rule that points every arrow inward, and still have a model that is a database schema with getters.
The example here is driving-hours compliance for a fleet of trucks — rest-and-break rules, not CRUD.
The word "entity" is doing two different jobs
In the onion (or clean, or hexagonal) diagram, Entities are the innermost ring and every other ring depends on them. That's a dependency rule, and it's a good one. But it says nothing about what those entities are.
In practice, they tend to become this: a class of public getters and setters, no methods, and every rule that governs them living in a service one or more rings out. Nothing stops you writing a driver's hours-driven-today straight past the legal limit — the object itself has no idea what a legal value looks like. Whether that number is allowed is DriverComplianceService's problem, somewhere else, if anyone remembers to ask it.
The other reading of "entity" — the one from Evans' book that the architecture diagram borrowed the word from — is that the entity is the behaviour. You cannot put a Driver into a state the business would reject, because Driver will not let you. There is no setter to call.
Same word. Two completely different objects. The dependency rule is satisfied by both.
The domain: driving-hours compliance
The system simulates drivers over time. On a regular cadence it has to answer one question per driver: may this driver legally keep driving right now, or must they stop — and for how long?
The real regulation this is modeled on — EU drivers'-hours rules — is dense: caps on continuous driving before a break, caps per day and per week, rolling multi-week limits, rest periods with their own variants. None of the specific numbers matter for what follows. What matters is the shape: a driver accumulates driving time, hits limits, and owes breaks or rest before continuing.
Two objects carry this. Driver is the identity — who they are, and the driving/break/rest cadence they're subject to. DriverComplianceState is the ledger — the running record of what they've actually driven, and how close they are to each limit. Driver owns the ledger; nothing outside Driver is allowed to create one.
The words in that relationship — ledger, break, daily cap, two-week cap — are the same words the regulation uses and the same words that appear in the class and method names. That correspondence is the ubiquitous language, and it's the thing the rest of this post is really about.
An aggregate you can't corrupt
Here is the Driver aggregate, trimmed (full file):
public sealed class Driver
{
public Guid Id { get; private set; }
public string FirstName { get; private set; } = null!;
public string LastName { get; private set; } = null!;
// The driving/break/rest cadence this driver is subject to.
public DrivingRules Rules { get; private set; } = null!;
// This driver's driving-time compliance ledger. Null until the driver
// first starts driving — see ResetComplianceForNewTrip.
public DriverComplianceState? ComplianceState { get; private set; }
private Driver() { } // EF Core materializer only
private Driver(Guid id, string firstName, string lastName, DrivingRules rules)
{
Id = id; FirstName = firstName; LastName = lastName; Rules = rules;
}
public static Driver Create(string firstName, string lastName, DrivingRules rules)
{
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException("First name is required.", nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException("Last name is required.", nameof(lastName));
ArgumentNullException.ThrowIfNull(rules);
return new Driver(Guid.NewGuid(), firstName, lastName, rules);
}
// The only state change application code can ask for. The driver builds
// its own ledger — you cannot hand it one from outside.
public void ResetComplianceForNewTrip(DateTime tripStartedAt)
{
ComplianceState = new DriverComplianceState(Id, tripStartedAt);
}
}
Every property is private set. There is no public constructor — the only way to make a Driver from application code is Create, which validates its inputs and throws on a blank name or null rules. (The parameterless constructor exists solely so EF Core's materializer can rehydrate one from a row; it is not a construction path anyone else can use.)
And there is exactly one mutation the outside world can request: ResetComplianceForNewTrip. Note what it does not do — it doesn't accept a DriverComplianceState. The driver constructs its own compliance ledger, anchored to the trip start. You cannot inject a ledger, cannot set one to null, cannot put the driver into a half-initialised state.
Compare that to the anemic version: no Driver { get; set; } soup, and no DriverValidator.Validate(driver) sitting in another assembly hoping every call site remembers to invoke it. The invariant and the data it protects are the same object.
One more thing worth noticing: Driver doesn't hold its compliance data as fields of its own. It holds a separate object, DriverComplianceState, and it's the only thing allowed to create one. The identity and the ledger are two objects, deliberately — but the ledger only ever comes into existence through the aggregate that owns it.
The rules live in the model, not a service layer
If Driver guards its own invariants, where does the actual regulation live — the logic that decides a driver has hit a limit and must now take a break or rest?
In a DriverRuleEngine (interface, implementation). Here is its core surface:
public interface IDriverRuleEngine
{
// Pure. Answered from the ledger and the driver's limits alone — no Driver, no DB.
DriverEligibility IsEligibleToDriveNow(
DriverComplianceState ledger,
RestRuleLimits limits); // the caps and break/rest durations, as one object
// Rolls the ledger forward one tick: accrues driving, begins the required
// break or rest when a limit is hit. Returns a domain outcome + events.
RestRuleOutcome Advance(
DriverComplianceState ledger,
TimeSpan elapsedTick,
DateTime simulatedNow,
DrivingRules rule,
RestRuleLimits limits);
}
public sealed record DriverEligibility(
bool IsEligible,
IneligibilityReason? Reason, // which limit was hit, if any — OnBreak, DailyCapReached, ...
int? MinutesUntilEligible);
Every parameter is a domain type — a ledger, a limits table, a rules variant, an elapsed tick. Never a Driver itself. Never a DTO, never a DbContext. And every return is a domain type: a DriverEligibility record whose Reason is a typed IneligibilityReason, not a bare status code; a RestRuleOutcome that carries domain events like TruckWentIntoRest and TruckResumedDriving.
Inside, every branch reads in the regulation's own language — hit the two-week cap, and the reason says so:
if (ledger.WeeklyDrivingMinutesThisWeek + ledger.WeeklyDrivingMinutesPriorWeek
>= limits.MaxTwoWeekDrivingMinutes)
return new DriverEligibility(false, IneligibilityReason.TwoWeekCapReached, null);
if (ledger.ContinuousDrivingMinutesSinceBreak
>= limits.MaxContinuousDrivingMinutesBeforeBreak)
return new DriverEligibility(false, IneligibilityReason.OnBreak, null);
This is the "use-case" logic that a clean-architecture diagram would put in an outer ring. But it isn't a service reaching into a bag of fields and writing them back — it's a domain service in the DDD sense: behaviour that doesn't belong to any single entity, still expressed entirely in domain terms, operating on domain objects, emitting domain events. IsEligibleToDriveNow is a pure function of (ledger, limits). IsEligibleToDriveFuture — "will this driver still be legal a while from now?" — replays the deterministic drive/break/rest sequence forward on a private clone of the ledger and never touches the real one.
Same computation, either way. One version you can read aloud to the person who wrote the regulation. The other you cannot.
The take
Onion architecture gives you a dependency rule. It does not give you a domain model. You can follow every arrow inward, keep your Domain project free of framework references, and still land on anemic entities pushed around by fat services — the structure is right and the model is hollow.
DDD's building blocks are what make the centre of the diagram mean something: an aggregate that guards its own invariants, a value object that travels between contexts but stays immutable, a domain service written in the model's own language.
The test isn't "do the dependencies point inward." It's: can I put this object into a state the business would reject? If yes, it's a row with extra steps.
The model above is from a freight-marketplace side project — github.com/RTO-The-Coder/freight-marketplace. The domain lives under backend/src/Freight.Domain; Fleet and Tracking are the two subdomains in this post.
Read original: https://dev.to/rit_the_coder/entities-are-not-rows-a-domain-model-that-behaves-c63
← Previous
Does Market Fear Actually Predict Trader Losses? I Tested It With Real Hyperliquid Data
Next →
How To Design a Database for an E-commerce App
Related
Building a Pons Copy-Trading System on Robinhood Chain
Backend
1
Dev.to (EN Zone)
FMZ Web3 in Practice — Riding the Robinhood Chain Wave: Build a Uniswap V4 New Pool Radar Step by Step
Backend
3
Dev.to (EN Zone)
Phone verification in Flask and Django with one API key
Backend
2
Dev.to (EN Zone)
I made a decentralised Minecraft server where the host can change between players
Backend
2
DEV Community
Comments0
No comments yet — be the first