<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Effective software engineering]]></title><description><![CDATA[The central theme connecting all articles on this site is the enormous efficiency — and long-term stability — that rich domain models bring to application development. These pieces aim to show, from multiple perspectives, that domain-centric modeling is not a trend, not an old-school technique, and certainly not optional. It is the only approach that puts understanding before doing.

Procedural programming, functional pipelines, vibe-coding, and framework-driven architectures all default to doing before understanding. They focus on producing behavior rather than capturing meaning. And while they can deliver short-term progress, they accumulate structural debt at alarming speed — because nothing in the code explains why anything exists.

Rich domain models counter that completely. They create systems where the code mirrors the mental model of the business — where reasoning, adapting, and evolving are not chores but natural consequences of clarity.

But this does not happen automatically. Domain modeling is not a pattern, nor a checklist, nor a technique you sprinkle on top. It is a skill — one that demands conceptual thinking, experience, curiosity, and the willingness to understand the problem deeply before encoding it.

This site exists to bring attention back to that essential foundation.
In most engineering fields, insufficient understanding leads to visible and immediate consequences. In software, the absence of reference implementations creates the illusion that misunderstanding is cheap — as explored in the “boat” article.
Yet the opposite is true: placing the domain model before the code yields extraordinary leverage. It turns software into an adaptable, comprehensible system rather than a growing liability.]]></description><link>https://blog.leonpennings.com</link><image><url>https://cdn.hashnode.com/uploads/logos/6909c071175a29281d26fa0e/900e1a53-b031-403c-8c84-5b0eef11011c.jpg</url><title>Effective software engineering</title><link>https://blog.leonpennings.com</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 15:51:05 GMT</lastBuildDate><atom:link href="https://blog.leonpennings.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Tech Debt Isn't a Backlog Problem. It's a "Done" Problem.]]></title><description><![CDATA[Technical debt is usually described as a bill that arrives later for a purchase made earlier: speed bought on credit, with the cost deferred to some future date. Ask practitioners why debt accumulates]]></description><link>https://blog.leonpennings.com/tech-debt-isn-t-a-backlog-problem-it-s-a-done-problem</link><guid isPermaLink="true">https://blog.leonpennings.com/tech-debt-isn-t-a-backlog-problem-it-s-a-done-problem</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Rich Domain Model]]></category><category><![CDATA[clean code]]></category><category><![CDATA[Clean Architecture]]></category><category><![CDATA[Java]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Mon, 07 Sep 2026 07:44:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/9ceb0061-97de-429f-baa1-8b311ba38857.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Technical debt is usually described as a bill that arrives later for a purchase made earlier: speed bought on credit, with the cost deferred to some future date. Ask practitioners why debt accumulates and the most common answer, by a wide margin, is some version of deadline pressure — not enough time, ship now, fix later. That framing assumes a conscious shortcut: someone saw the proper path and the fast path, and picked the fast one, aware of the trade.</p>
<p>Most technical debt doesn't work that way, and it isn't one single failure. Often, what gets built is an answer to <em>what does this need to do</em> — the verb — without ever asking <em>what is this</em> — the noun it's supposed to represent. Nothing gets bypassed here, because the better path was never in view to begin with. There is no shortcut, because there was no fork in the road.</p>
<p>But sometimes the noun does surface — partway through implementation, something makes the real shape of the thing visible. And it still doesn't get acted on, because there's a day left in the sprint and the verb version already works. That's a different failure than the first: not a question that was never asked, but one that got answered and then set aside. The first is a <strong>goal problem</strong> — attention was never pointed at the noun. The second is a <strong>process problem</strong> — attention found the noun, and process closed the door on it anyway.</p>
<p>Both produce the same debt, and both are more common than the conscious-shortcut story of deadline pressure suggests. What "done" ends up meaning, thousands of times across a codebase, is whichever of these two failures happened to occur: what does "done" mean for a piece of code?</p>
<h2>Two definitions of "done"</h2>
<p><strong>Process thinking</strong> defines done against the ticket. The ticket describes a required outcome — add this event type, fix this bug, expose this field in the API — and the work is finished when the code satisfies that description. This is a clean, almost tautological measure: done is done when the acceptance criteria are met, the tests pass, and the sprint goal is hit.</p>
<p><strong>Product thinking</strong> defines done against a second, independent question that no ticket ever asks: <em>is this still the right shape for what this thing actually is?</em> Call it product thinking not in the roadmap-and-discovery sense a product manager would recognize, but in the sense that a developer asking this question is doing for the code what a product owner does for the roadmap: refusing to let the immediate ask stand in for the real target. At the level where the application is being built, that question is domain thinking — is this a correct model of the thing being built. This question doesn't close when the ticket closes. It stays open regardless of what the ticket says, because it isn't about the requested outcome — it's about whether the system's model of the domain still holds up.</p>
<p>Here's the problem: process thinking's definition of done is entirely self-referential. It's measured against process artifacts — the ticket, the sprint, the acceptance criteria — never against anything outside them. "It works" becomes the terminal, closing argument, because "works" was defined by the ticket in the first place. There is no process artifact whose job is to ask the second question. So in a process-first environment, it simply never gets asked. Not because anyone decided the domain model didn't matter, but because nothing in the workflow ever put it on the table.</p>
<p>This is a sharper claim than "tech debt is caused by cutting corners under deadline pressure." Plenty of debt gets created by careful, unhurried developers doing exactly what was asked, cleanly, on time, with tests. The debt isn't a corner that got cut. It's a question that was never in scope to begin with.</p>
<h2>Verb-shaped tickets, noun-shaped things</h2>
<p>A ticket is verb-shaped. <em>Add</em> this event type. <em>Fix</em> this bug. <em>Expose</em> this field. Followed literally, each ticket gets satisfied by its own procedure: a sequence of steps producing the required output for that one case, and nothing else. Martin Fowler has a name for this style of organization — the <strong>Transaction Script</strong> pattern: logic organized as one procedure per use case, rather than as behavior belonging to a shared concept those use cases operate on. It isn't automatically wrong on its own; the trouble starts once several scripts need the same underlying data with no shared concept holding it together, and the natural next move is to push that shared logic into a fat service class every script calls into. Ticket-driven work doesn't inevitably produce this shape, but it produces exactly the conditions Transaction Script fits best — because a ticket only ever asks for the procedure, never for the concept the procedure operates on.</p>
<p>Domain thinking asks the other question: what is the underlying concept, independent of any single ticket, and does the behavior belong to it. That's not automatically the "more sophisticated" answer, either. Asking the question and concluding "this really is just a value, with no behavior of its own" is domain thinking done correctly. The failure isn't picking the simple version — it's arriving at the simple version by default, because the question of which one was correct never got asked in the first place. Building the plain version because nobody asked is not the same thing as building it because the honest answer was genuinely "plain," even though the two can look identical on the page.</p>
<p>Multiply the ticket-by-ticket version across a codebase and the result is spaghetti — not from any one bad decision, but from many individually reasonable, ticket-scoped scripts accumulating around a shape that was never named as the wrong shape in the first place. Nobody wrote spaghetti on purpose. Everyone wrote the smallest correct script for the ticket in front of them. This is the goal-problem failure from the opening, playing out at the scale of a codebase rather than a single decision: not that the shared concept was considered and rejected, but that it was never in view to reject.</p>
<h2>"It works" as a closing argument</h2>
<p>The staying-inside-the-ticket move is straightforward: satisfy the request in front of you, and file a separate ticket for the underlying redesign the work exposed. That second ticket rarely gets scheduled. Not from negligence, but for a completely defensible reason: <em>it works</em>. There's no bug, no outage, no complaint. Against the process definition of done, there is no case for spending a sprint on it.</p>
<p>This isn't a story about cutting corners under pressure. Satisfying a ticket exactly, cleanly, and on time is not reckless — it's correctness by the only definition that was visible. The debt accumulates anyway, silently, because "it works" is a sufficient closing argument inside process thinking and an insufficient one inside product thinking.</p>
<p>The process-first version of this gets defended in almost identical language across very different teams: <em>"It does what the ticket says, and it got done in the time allotted."</em> <em>"At least the sprint goal got made."</em> These aren't admissions of a corner cut. They're sincere descriptions of a job done correctly, by the only definition of the job that was visible. That's the trap — process thinking doesn't read as negligence from inside it. It reads as diligence.</p>
<p>There's a psychological effect underneath this worth naming directly: a ticket, treated as the boundary of the work, quietly becomes the boundary of what questions a developer feels <em>authorized</em> to ask. "It does what the ticket says" isn't really a statement about effort — it's a statement about jurisdiction. <em>The question of whether this is the right shape for the domain was never asked; only whether it satisfies the requirement in front of it.</em> Multiply that across a team and a codebase, and the result is a system built entirely out of locally-correct answers to a question nobody ever meant to be the only one asked.</p>
<h2>So is tech debt an indicator of missing product thinking?</h2>
<p>The strong version of that claim doesn't survive an obvious counterexample: a team that knowingly ships an imperfect design to hit a market window, fully aware of what it will owe later, has clearly asked the noun question — the debt was seen, named, and understood before it was accepted. That's product thinking present, not absent. So debt itself isn't reliable evidence that the question was never asked.</p>
<p>What it's evidence of is the other failure from the opening: the question was asked, answered, and then set aside anyway, because naming a piece of debt doesn't create any mechanism that reopens it later. The debt gets named once, at the moment of the decision, then meets the same process filter as everything else — it works, no urgency, off the sprint. This is the process-problem failure playing out at the scale of a whole team's roadmap rather than a single sprint. Named-and-abandoned debt and silent-and-undiscovered debt tend to converge on the same outcome, because neither has an artifact whose job is to keep the question alive once "it works" is satisfied.</p>
<p>So the indicator isn't the debt, and it isn't even whether the debt was consciously named. It's this: <strong>is there anything in the system that treats "it works" as insufficient, independent of the ticket that produced it?</strong> If yes — if someone's job, habit, or authority is to keep asking "is this still the right shape for what this actually is," regardless of what's currently on fire — debt gets caught early, named, and often fixed on the spot, because the question never fully closes. If no, debt accumulates invisibly, ticket by ticket, correct answer by correct answer, until the shape is wrong enough that the next requirement can't be satisfied without a rewrite.</p>
<p>Technical debt, in other words, isn't the disease. It's what accumulates in the space where a question should have stayed open and didn't. And whether that question stays open isn't a matter of talent or care — ticket-scoped work of this kind is neither careless nor unskilled — it's a matter of whether the <em>system around the work</em> ever asks it.</p>
<h2>The uncomfortable implication</h2>
<p>If that's right, then "reduce technical debt" is the wrong target for most organizations. You can't ticket your way out of a problem that is caused by the unit of work being a ticket. Refactoring sprints, debt-paydown quarters, and architecture review boards are all still process artifacts — useful, but structurally the same kind of thing that let the debt form in the first place, just aimed backward instead of forward.</p>
<p>The actual fix is smaller and harder to schedule than a new ceremony: making "what is this thing, structurally" a legitimate, expected question at the moment something is first built — not a virtue some developers happen to have, and not a separate ticket that competes with delivery for sprint capacity, but part of what "done" is understood to mean before the first line is written.</p>
<p>Concretely, that can be as small as a design review or pairing session that is explicitly allowed to reopen the <em>shape</em> of a ticket mid-implementation, not just its correctness — and a shared norm that "it works" is necessary but never sufficient to close a piece of work. Neither costs a sprint. Both require someone to have the standing to ask the question and not be overruled by the calendar.</p>
<p>That's a cultural condition, not a process — which is precisely why it's so often missing.</p>
]]></content:encoded></item><item><title><![CDATA[The Technical Candy Problem in Software Development]]></title><description><![CDATA["When a measure becomes a target, it ceases to be a good measure."
Goodhart's Law is usually told as a story about metrics gaming: set a target, watch people optimize the number instead of the thing t]]></description><link>https://blog.leonpennings.com/the-technical-candy-problem-in-software-development</link><guid isPermaLink="true">https://blog.leonpennings.com/the-technical-candy-problem-in-software-development</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Rich Domain Model]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Sat, 29 Aug 2026 12:20:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/0e791140-c8d6-4a67-a91a-76ed9d4e71e1.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>"When a measure becomes a target, it ceases to be a good measure."</em></p>
<p>Goodhart's Law is usually told as a story about metrics gaming: set a target, watch people optimize the number instead of the thing the number was supposed to represent. In software, the story is worse than that. The proxy doesn't just get gamed — pursuing it actively burns the bridge back to the real thing. It doesn't merely fail to deliver quality; it forecloses the path to it later.</p>
<p>This article is about why that keeps happening, decade after decade, under a different name each time.</p>
<h2>Two teams, same language, different sociology</h2>
<p>Take two Java teams of equal size, given the same ticket volume.</p>
<p>Team A treats each ticket as a self-contained unit of work: pick it up, implement it, write tests, open a merge request, get it reviewed, done. The code is organized into services, entities, repositories, DTOs — a recognizable, auditable recipe. Nobody designs anything, exactly, because there's nothing to design. There's a template, and you fill it in. It is, in a real sense, an assembly line. It is also an accountant's dream: simple, auditable, predictable steps.</p>
<p>Team B treats each ticket as a question first: <em>what does this belong to?</em> Before code gets written, a quick conversation happens — sometimes a scheduled discussion, more often just someone acting as a two-minute mirror to bounce an idea off. Does this fit the existing model? Does the model need to extend, or is this evidence that an earlier understanding was wrong and the model needs correcting? Logic ends up living on the domain object it actually concerns, not in a service that pokes at data from outside.</p>
<p>Both teams are nominally writing object-oriented Java. Only one of them is doing object-oriented <em>design</em>. The other is writing procedural code with class syntax — a warning about Java that's been repeated since the early 2000s and apparently needs repeating every decade since.</p>
<p>The visible difference is architectural. The real difference is social. Team A's structure requires no shared understanding beyond "here's how we lay out a service." Team B's structure requires continuous, cheap, ongoing conversation about what the domain actually <em>is</em>. That conversation is what produces the two real payoffs people associate with good OO: knowledge spreads across the team because everyone's heard the two-minute version of what everyone else is building, and bugs are easier to find because behavior lives at the logical place it concerns, not scattered across a service that orchestrates several unrelated objects at once.</p>
<p>Team A's review process, by contrast, happens too late to catch any of this. A merge request review can genuinely catch things: an actual bug, an off-by-one, a missed edge case, even a template followed incorrectly — wrong layer called from the wrong place, a repository doing something a repository shouldn't. What it essentially never catches is a wrong <em>concept</em>. Nobody reviews a merge request and concludes that the domain itself has been misunderstood, because by the time the diff exists, the architectural choice is sunk cost. Rejecting it means throwing away finished work; approving it means leaving a comment about a variable name or a missing null check. Those costs aren't symmetric, so review reliably degrades to what's cheap and checkable against a template, never to "is this even the right shape for this to take." That question needed to be asked while the shape was still free to change, not after.</p>
<h2>The candy problem</h2>
<p>Here's where it gets interesting. Team A's approach isn't the result of laziness or incompetence. It's the <em>default</em> outcome whenever the tools make it easy to avoid asking "what owns this."</p>
<p>Every framework and pattern that gets marketed as good architecture offers a way to satisfy that immediate need without addressing it — technical candy, in a fairly literal sense. Candy solves hunger for the next twenty minutes; it does nothing for what the body actually needed, and the debt it leaves doesn't come due immediately, or at low volume. A domain model is supposed to capture the actual mechanics of the business, not just whatever a ticket asked for — it's meant to hold the rule the ticket was a symptom of. Each tool below satisfies the ticket instead. On a small domain, with few objects and little accumulated history, that trade barely registers, because there isn't enough complexity yet for the missing mechanics to matter. It's precisely as complexity rises that the debt compounds — there's more of it to service, and less slack left to absorb the interest:</p>
<ul>
<li><p><strong>Fat services</strong> give logic a home that isn't the domain concept it concerns. The common result is what's usually called an anemic domain model: entities that are little more than getters and setters, with every rule about what they're allowed to do living somewhere else. "OrderService" can hold behavior that has nothing to do with what an Order fundamentally is — it's just where the ticket's logic went.</p>
</li>
<li><p><strong>Technical layering</strong> puts accidental complexity first. Brooks' distinction still holds: essential complexity is the actual complexity of the problem domain, accidental complexity is whatever the tools and techniques used to solve it add on top. Accidental complexity is supposed to serve essential complexity — the technical structure exists to express the domain, not to compete with it. Mainstream layering conventions routinely invert that order. A vocabulary of technical roles — Repository, Value Object, Aggregate, Factory, among others — gets applied first, and domain responsibility gets fit into whatever slot that vocabulary provides, rather than the other way around. The object best placed to own a piece of behavior, by the actual shape of the domain, doesn't stop being the right owner just because the technical vocabulary has a separate box that convention says the behavior should go in instead. Where the two disagree, the convention usually wins, because it has a name and a slide in the architecture deck, and the essential answer doesn't. That's accidental complexity leading essential complexity, exactly backwards from what Brooks described — and it's popular for the same reason fat services are: it gives everyone a template to follow instead of a domain to understand.</p>
</li>
<li><p><strong>Workflow and eventing engines</strong> let one process step outside multiple domain objects and orchestrate them from above — the same move a hand-rolled orchestration script makes, except now it's an industry-standard framework, which makes it <em>harder</em> to notice as procedural. "We're using a workflow engine" sounds like an architecture decision. It's usually a way of saying "we don't want to figure out whose responsibility this is" and letting a generic runtime hold the ambiguity instead.</p>
</li>
<li><p><strong>Microservices</strong> are a bet on where the domain's real seams are — placed while the team has the least evidence it will ever have about where those seams actually sit. That's true even when the split isn't the obviously lazy version, drawn along the org chart or wherever the system currently hurts. A service boundary is, functionally, a hypothesis about the model made expensive to revise: encoded in network contracts, versioning, and deploy coordination, rather than in files that happen to sit near each other and can be moved in an afternoon. Drawing bounded contexts up front is the same category error Waterfall made — deciding the shape of the whole thing before enough is known to decide it — except the bill for microservices comes due later and larger.</p>
</li>
<li><p><strong>Boilerplate reduction</strong> promises simpler code by making meaning-bearing decisions disappear, not by removing decisions that never existed in the first place. A blanket-generated equals and hashCode makes a call about what identity means for that object; a generated setter decides the field is freely mutable; a query built from a method name by naming convention replaces text a reviewer could check against the schema with a rule that has to already be known to be checked at all. None of that is repetition being eliminated — it's a decision that used to be visible in the code, now made invisibly, by a framework, on the team's behalf. What disappears from the IDE doesn't disappear from the system: it moves into container wiring, dynamic proxies, and a classloader hierarchy that never shows up in a diff or a LOC count, but still has to spin up correctly at runtime and still has to be understood in full the moment something inside it breaks. The codebase reads smaller. The machine that actually runs it has more cogwheels, most of them out of view.</p>
</li>
</ul>
<p>None of these are wrong to reach for in every circumstance. A service genuinely earns its keep sometimes; a Factory can be exactly the right tool for a genuinely complex construction step. Microservices earn theirs too, but for a narrower reason than usually advertised: independent scaling under genuinely different load profiles, independent deployment cadence — not complexity management, which is the justification most often given for them. The problem is that each tool is always available, always sounds like sound engineering, and is always cheaper in the moment than asking what a piece of logic actually belongs to. So they get reached for by default, not by exception — and once reached for, they don't just fail to help. The logic now has a plausible-sounding home that isn't its real one, which makes the real question harder to raise later than if no home had been offered at all.</p>
<p>Bounded contexts deserve one specific caveat, since they're often cited as the case that legitimizes a split — and the caveat holds even for a split made carefully, not just the reckless kind drawn along an org chart. A domain object is a fact for the whole model: Customer means one thing, and if a subdomain seems to need it to mean something different, that's not a context boundary being discovered, it's a sign the object is wrongly defined or being pulled toward a god object, and the fix is finding the second object and letting it point back. What a "Shipping subdomain" can legitimately mean is narrower: today, nothing in the business requires shipping logic to know about billing logic — ordinary OO design working correctly, not a boundary that was designed. That can stop being true the moment the business states a connection that didn't exist before, which is exactly why a split based on it, however carefully reasoned, assumes today's understanding is final at the moment it's had the least time to be corrected — a direct contradiction of what Agile was supposed to guarantee, that the cost of changing course stays low precisely because requirements and understanding are expected to change. It costs what these splits always cost regardless: referential integrity that used to be a foreign key becomes hand-written reconciliation, invisible on the happy path and paid for the moment something fails partway through and two systems are left holding two different versions of the same fact.</p>
<p>The procedural team is the least likely to catch this, not because its engineers are less capable, but because nothing in the day-to-day work gives anyone a reason to ask. As long as the ticket fits the template — service, entity, repository, DTO, and now: which service does this call — there's no moment where "does this boundary still match what we understand the domain to be" comes up. Nobody is defending the boundary. It's simply never examined, because the process that generates the work never pauses to raise the question.</p>
<h2>Why the proxy always wins</h2>
<p>This is where Goodhart's Law earns its place in the story, and why it's a sharper diagnosis than "some teams are more disciplined than others." Classic Goodhart is a measure that stops representing what it measured once it's optimized for. Software adds a third step: the architecture that grows up around the proxy actively resists being undone, not just un-tracked — a gamed coverage number just tells you nothing useful, but the test suite or the service boundaries built to hit it fight back when someone tries to remove them.</p>
<p>"Well-designed" is hard to measure and hard to put in a status report. So it gets replaced by proxies that are easy to measure and easy to report:</p>
<table>
<thead>
<tr>
<th>Real goal</th>
<th>Proxy that replaces it</th>
</tr>
</thead>
<tbody><tr>
<td>Well-tested</td>
<td>Test coverage percentage</td>
</tr>
<tr>
<td>Well-designed</td>
<td>Proper layering (services, repositories, DTOs)</td>
</tr>
<tr>
<td>Scalable</td>
<td>Number of services</td>
</tr>
<tr>
<td>Maintainable</td>
<td>Adherence to a named technical pattern (layering conventions, workflow orchestration)</td>
</tr>
<tr>
<td>Reduced complexity</td>
<td>Reduced lines of code (boilerplate elimination)</td>
</tr>
</tbody></table>
<p>Each proxy is legible in a way the real goal isn't. "95% coverage" fits in a slide. "We're microservices now" is a sentence a VP can repeat. "We had a genuinely good conversation about what a Company is during a merger" is not a sentence that survives being put in a quarterly update, even though it's the thing that actually determines whether the system stays maintainable.</p>
<p>Unit testing is the clearest small-scale case. Chasing coverage produces tests that assert on implementation detail — this method calls that mock with these arguments — rather than on behavior through a stable interface. That test suite becomes a second copy of the system's internal wiring, expressed again in test form, with no reason to exist except that a coverage number demanded it. Refactor the production code and a parallel structure breaks with it, one that had no business caring about the wiring in the first place. A production change of a few hours turns into days of fixing tests that were never really testing behavior, just repeating structure.</p>
<p>Microservices do the same thing at the org level: a second copy of coupling, encoded in network contracts and deploy schedules instead of mock assertions. Coupling that used to be visible as "these two classes call each other a lot" becomes invisible as "these two teams need to sync their release," and stops looking like a modeling problem at all — it just feels like the normal cost of distributed systems, so the root cause never gets revisited.</p>
<p>Either way, the team hasn't just failed to invest in quality. It has spent real effort making the eventual correction more expensive than doing nothing would have.</p>
<h2>The two questions that resist the proxy</h2>
<p>This is the practical version of Team B's two-minute mirror: not a design review, just two questions asked in sequence, before any technical machinery — a new library, a workflow engine, a service split — gets reached for.</p>
<p>The first is <em>why do you need it</em>. Not as gatekeeping, but as a genuine question that, answered honestly, tends to fall back into a domain discussion: what is actually supposed to happen, and whose responsibility is it? Often the answer turns out to be simpler than the machinery proposed to solve it, and the need for the machinery quietly disappears.</p>
<p>Sometimes the need is real, though, and that's where the second question does the work the first one can't: <em>where in the model does this belong?</em> A workflow engine that seemed necessary to coordinate steps across three objects often turns out to be standing in for an invariant that belongs on one of those objects natively, as its own behavior — the engine wasn't decoupling anything, it was providing a home for logic that hadn't found its real one yet. The first question establishes that a home is needed. The second stops the answer from defaulting to a new service, a new engine, a new boundary — the candy that's always sitting there, ready-made — when the harder and usually correct answer is that it belongs on something that already exists.</p>
<p>Together the two questions describe a different order of operations than the one most tickets follow by default. The usual path runs <em>ticket → technical mechanism → implementation</em>: pick a plausible tool, then build. The alternative runs <em>need → domain behavior → ownership → mechanism</em>: establish what's actually required, find what in the model that requirement changes, decide who owns the change, and only then reach for a mechanism to express it — by which point the mechanism is often unnecessary, or much smaller than first assumed.</p>
<p>Neither question scales by making everyone a domain expert. Both scale because they're cheap and because of <em>when</em> they happen: before the code exists, while the answer is still free to change, rather than after a merge request or a deployed service boundary has already made changing it expensive.</p>
<p>This is where the candy debt comes due for the largest bet on the list. Splitting an application into services doesn't touch the business domain's complexity at all — the domain is exactly as complicated the day after the split as it was the day before. What changes is where the bill gets paid: the same rules, the same conditions, the same relationships still have to be honored, except now some of them have to be honored across a network instead of inside one model, with everything that adds — latency, versioning, partial failure. Complexity that lived in the business doesn't go away because it now lives in five services instead of one. It just gets a passport.</p>
<p>That's the specific case. The general one is the same shape everywhere in this piece: not a system that breaks, but a system that quietly costs more to run than it needed to.</p>
<p>None of this is an argument that the alternative doesn't function. A fifty-service application built by asking "which service handles this" instead of "what does this belong to" will run. It will serve traffic, pass its uptime targets, and ship features on a roadmap. Working is a low bar, and nearly every architecture clears it. What changes is what it costs to keep clearing it: extending it means finding which of fifty services should grow, rather than which existing object should; debugging it means tracing a request across network hops and logs instead of reading one call stack; understanding it means holding fifty deploy units in your head instead of one model; and refactoring it — the moment something is discovered to be wrong, which on a long enough timeline is not an if — means a migration across contracts and teams instead of a same-day change to a class. None of that shows up as a failure. It shows up as things simply, permanently, taking longer than they should, in a way nobody can point to a single decision for.</p>
<p>That's the actual lever. Not "use fewer frameworks," not "adopt a named pattern" — both become proxies of their own the moment they're followed as rules instead of understood as consequences. Coverage numbers, service counts, technical layering, workflow engines: all of it is what fills the space by default when nobody asks what a thing belongs to while asking is still cheap. The window in which that question is cheap to ask is also the only window in which it gets asked at all — which is exactly why it has to be asked early, on purpose, every time.</p>
]]></content:encoded></item><item><title><![CDATA[Tooling Is the Icing on the Cake, Not the Cake Itself]]></title><description><![CDATA[There's a recurring message on LinkedIn:
Java 8 is legacy.
Java 21 is enterprise-ready.
Upgrade, or fall behind.
Ask the same people directly whether a better language makes for a better product, and ]]></description><link>https://blog.leonpennings.com/tooling-is-the-icing-on-the-cake-not-the-cake-itself</link><guid isPermaLink="true">https://blog.leonpennings.com/tooling-is-the-icing-on-the-cake-not-the-cake-itself</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Thu, 20 Aug 2026 13:14:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/f5640d56-71d6-4006-9b80-18e55ad6a4ad.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There's a recurring message on LinkedIn:</p>
<p>Java 8 is legacy.</p>
<p>Java 21 is enterprise-ready.</p>
<p>Upgrade, or fall behind.</p>
<p>Ask the same people directly whether a better language makes for a better product, and most will say no, of course not — that's obviously not how it works. Then, in the next breath, they'll defend the upgrade on the grounds that it makes things simpler.</p>
<p>Notice what just happened. "Simpler" isn't a different, humbler claim than "better." It's the same claim, one inferential step removed, laundered through a word that doesn't sound like the thing they just denied.</p>
<p>But there's a second problem underneath the first one, and it's the more important of the two: two people can say "simpler" and mean opposite things.</p>
<h2>Two Kinds of Simple</h2>
<p>One kind of simple means: the responsibility was resolved, correctly placed, and no longer needs restating. A well-designed enum-and-interface pattern that routes a payment to the right handler is simple because the thinking is already done — who's responsible for what was decided once, deliberately, and using it afterward is just applying a decision that's already correct.</p>
<p>The other kind of simple means: the question was never asked. This is the kind a framework usually sells. Spring's <code>@Transactional</code> is the cleanest example available, because it's precise about what it removes. Slap the annotation on a method and you no longer have to write <code>begin</code>, <code>commit</code>, <code>rollback</code> by hand. That looks like the same kind of win as the first case — less to write, less to think about. It isn't.</p>
<p>A transaction boundary is an answer to a specific design question: what has to succeed or fail together, as one indivisible thing. That question doesn't go away when the annotation handles the mechanics. It just stops being asked out loud. Someone who scoped their transaction deliberately can tell you exactly what's inside that boundary and why, with or without the annotation — the annotation is just executing a decision they'd already made. Someone who never asked the question has nothing underneath the annotation at all. It isn't answering "what should be atomic here." It's letting the feature ship before that question was ever raised.</p>
<p>That's the actual distinction hiding inside the word "simpler," and it's worth a single image, because once you see it, the entire debate reorganizes around it:</p>
<p>Tooling is the icing. The model is the cake.</p>
<h2>The Test</h2>
<p>Here's how to tell which kind of simple you're looking at, for any feature, framework, or convention: strip it away and see what's left standing.</p>
<p>Take a system where responsibility was actually modeled — a domain object that only knows what it needs to know, a boundary that exists because something real forced it there. Delete every framework annotation, every piece of syntactic sugar, every clean-code convention. What's left is uglier. More verbose. Harder on the eyes. But it still does the right thing when you call it. It still fails in predictable places. The cake is worse without icing. It's still cake.</p>
<p>Now run the same subtraction on a system that was held together by its tooling — where <code>@Transactional</code> was never backed by a real answer to what should be atomic, where the framework's conventions were doing the structural work nobody did by hand. Strip that away and there's no cake underneath. There's a pile of glue between library calls with no organizing principle of its own, because the organizing principle was never the code's. It was borrowed from the framework the whole time. Take the framework away and the code doesn't get harder to read. It stops having a shape to read at all.</p>
<p>That's the actual test. Not "does this look modern." Not "does this compile cleanly, pass the linter, follow convention." Does the structure survive the subtraction — or was the structure always the icing, arranged carefully enough that nobody noticed there was nothing under it.</p>
<h2>Icing, Honestly Sold</h2>
<p>Some of what Java 21 offers passes this test cleanly, and it's worth saying so rather than treating everything after Java 8 as suspect by default.</p>
<p>Text blocks and enhanced switch are decoration on a model that already exists. Nobody's structure depends on them. Fine, take them or leave them.</p>
<p><code>var</code> doesn't belong in that group, and it's worth being precise about why. A declared type isn't incidental to a line of code — it's the one place a reader can see, without inference or navigation, exactly what a value is. <code>var</code> doesn't relocate that fact somewhere slightly less convenient. It removes it from the line entirely and asks the reader to reconstruct it — from the method name, from the assignment, from wherever the declaration actually lives. That's not a minor tax. It's the same move the rest of this piece is arguing against, just at the smallest possible scale: something that was explicit becomes something you have to go find. The industry already settled this question once, for the same reason, when nobody adopted <code>Object x = ...</code> as a style — an unstated type was recognized as a readability cost then. <code>var</code> reintroduces the same gap with better manners.</p>
<p>Sealed types with exhaustive pattern matching go a step further and deserve real credit: for a genuinely closed set of alternatives, the compiler now verifies every consumer has been updated when a new case appears. That's icing catching an actual gap. But notice it only matters if the cake underneath is the kind of cake that has closed sets of alternatives in the first place. It's a tool for a specific shape of problem, not an improvement to problems in general.</p>
<h2>Icing Mistaken for Cake</h2>
<p>This is where the actual damage lives, and <code>@Transactional</code> is only the entry point.</p>
<p>Sometimes a saga is the right answer on its own merits — independently owned systems, genuinely separate transactional authorities, a business operation that legitimately spans boundaries nobody could have collapsed by modeling harder up front. That case is real, and the saga machinery built for it is doing honest work. The problem isn't sagas. It's using a saga as the mechanism by which a team discovers, for the first time, that nobody ever decided where the business transaction actually ended. Follow the unasked question from <code>@Transactional</code> downstream and that's usually what's happened by the time it surfaces: "what should succeed or fail together" went unanswered at the point where it was cheap to answer, the operation quietly grew to span more than one service, and by the time the gap becomes visible, a single transaction is no longer on the table — not because the domain required the split, but because nobody was in the room asking the question when it was still answerable.</p>
<p>This is the tell for the whole category. A team using sagas well will honestly report that their system feels simpler to reason about — and they're right, locally, about the specific pain the tooling was built against. What they won't ask, because the pain is now survivable, is whether the split should have existed at all. The tooling optimizes the consequence of a decision and quietly forecloses the question of the decision itself. Icing doing cake's job: making a shape that was never structurally sound feel finished.</p>
<p>Virtual threads sit in the same lineage from the other direction. They're usually marketed as Java catching up to the reactive, event-driven world — worth being careful with that framing rather than overselling it. Some event-driven design solves problems virtual threads don't touch: decoupling independently owned systems, durable messaging, throughput shaping, isolating failure domains. That part isn't going anywhere. But a real share of reactive adoption had a narrower cause — threads were expensive, so splitting an operation into events and handlers was how you got concurrency without paying for it. Virtual threads remove that specific cost. What they actually do is take away an excuse: the accidental complexity that came from working around expensive threads is now optional, which means whatever's left over — the parts of a reactive system that persist even once thread cost is no longer a factor — is a much cleaner signal of what was actually needed for domain reasons versus what was needed to survive the runtime. They don't tell you the operation should have been one coherent thing. They just remove the one justification that had nothing to do with whether it should have been.</p>
<h2>What's Actually Being Optimized</h2>
<p>Sort everything in this piece — <code>@Transactional</code>, sagas, virtual threads, sealed types, <code>var</code> — into two bins, and the pattern underneath all of it stops being about Java at all.</p>
<p><strong>One bin is essential complexity</strong>: what does this domain actually need, what has to succeed or fail together, who is responsible for what. That's modeling. It doesn't compile away and no framework does it for you, because it isn't a syntax problem. It's a thinking problem.</p>
<p><strong>The other bin is accidental complexity</strong>: the ceremony and mechanics around expressing a decision once it's already been made. That's where tooling genuinely earns its keep — sealed types expressing a closed set that was already understood as closed, <code>@Transactional</code> executing a boundary that was already deliberately scoped.</p>
<p>Tooling is disproportionately good at the second bin, and there's a simple reason for the imbalance: accidental complexity is mechanically observable. Does it compile, does it pass the linter, does it follow the convention, does the demo work. Essential complexity — was the atomicity boundary actually thought through, does this object know only what it should — isn't observable by anything short of someone doing the modeling themselves, and that doesn't fit in a release note or a conference talk. So feature after feature gets good at delivering the expression of a decision, without anything checking whether the decision was ever made. <code>@Transactional</code> shipped years before most of the codebases using it had ever asked what should be atomic. That gap didn't close. It just got easier to not notice.</p>
<p>Push the metaphor one step further and the relationship between the two bins gets clearer than "icing on top of cake" alone suggests. Icing doesn't attach to just any cake. It attaches to a cake baked to the right shape, the right structure, ready to receive it — a shapeless batter doesn't hold icing, it just absorbs it and stays shapeless. Essential complexity isn't only prior to accidental complexity, it's what accidental complexity depends on to mean anything at all. <code>@Transactional</code> needs a real transaction boundary underneath it or it isn't shorthand for a decision, it's shorthand for nothing. Sealed types need a real closed set underneath them or the exhaustiveness check is verifying an alternative that was never actually closed. Icing has no substance of its own. It was never meant to. Its entire value is contingent on there being a cake shaped correctly enough underneath it to hold the icing's shape — and no amount of icing, however elegant, bakes the cake retroactively.</p>
<h2>The Actual Question</h2>
<p>Don't ask whether Java 21 is better than Java 8. Nobody has ever built the same system twice, once on each, to find out, and nobody ever will — it's the wrong question regardless of which side answers it.</p>
<p>Ask this instead, of your own system, today: if every framework convenience, every syntax feature, every clean-code convention were stripped away, is there still a cake underneath? Does the structure survive, uglier but intact — or was the structure the icing the whole time, arranged carefully enough that nobody had to notice there was nothing under it?</p>
<p>None of this is an argument against upgrading. It's an argument against attributing architectural improvement to the upgrade — those are two different claims, and only one of them is defensible. Move to Java 21 for the honest reason: Java 8 stopped receiving public patches, and unpatched dependencies are a real operational risk regardless of what version number sits next to them. That's legitimate on its own and doesn't need dressing up as anything more.</p>
<p>Just don't move believing the new syntax will make a badly modeled system well designed. It won't. It never has. The tools got sweeter.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Tell Your Software Application Is of High Quality? Part 2]]></title><description><![CDATA[Part 2 — Essential and Accidental Complexity
Part 1's answer to that question was 'you can't' — and that still stands. No controlled counterfactual exists, and it isn't coming. What's fixable is narro]]></description><link>https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality-part-2</link><guid isPermaLink="true">https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality-part-2</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software quality]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Java]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Thu, 13 Aug 2026 08:54:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/bd7471fa-d680-4dca-82fe-0c947fd793ec.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3>Part 2 — Essential and Accidental Complexity</h3>
<p><a href="https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality">Part 1</a>'s answer to that question was 'you can't' — and that still stands. No controlled counterfactual exists, and it isn't coming. What's fixable is narrower: not whether you can verify quality, but what you build when you accept you can't.</p>
<h2>The Ferrari That Plows the Field</h2>
<p>Picture a user story: <em>"As a farm owner, I need my field plowed."</em></p>
<p>Someone unfamiliar with farming, handed only that sentence, could reasonably reach for the most capable, most desirable, most obviously powerful means of getting across a field fast: a Ferrari. It satisfies the stated behavior. It moves. It's fast. Nothing in the sentence says it's wrong.</p>
<p>To a farmer, it's a ridiculous choice — no tow hook, no ground clearance, no grooved tires for mud, a chassis that will bottom out on the first furrow. But none of that is in the story. It was never going to be. The farmer didn't leave it out through carelessness. It's tacit — decades of lived experience about soil, terrain, and machinery that never had to be stated because, to the farmer, it goes without saying. We don't make this mistake with tractors specifically, because tractors are common enough that everyone has already absorbed the association. But swap the domain for something less familiar — a car mechanic parachuted into farm equipment, an engineer parachuted into logistics or lending or insurance — and the Ferrari mistake stops being obvious and starts being the default outcome.</p>
<p>This is the risk sitting underneath every request phrased as behavior: <em>plow the field</em>, <em>approve the loan</em>, <em>route the shipment</em>. Each one is satisfiable by something that technically works and is completely wrong for the domain it has to operate in — and the gap between those two doesn't show up in the sentence. It shows up later, in a field that got plowed fast and shallow because nobody told the machine, or the engineer, that depth mattered more than speed.</p>
<h2>Why Requirements Can't Be Perfect — By Definition</h2>
<p>There's a comfortable assumption underneath a lot of software process, waterfall included: that if the domain expert just writes the requirement carefully enough, up front, the ambiguity goes away. Get the specification right, then build to it.</p>
<p>This isn't a matter of trying harder, and it's worth being precise about why. The farmer isn't withholding the ground-clearance requirement. He doesn't know it's missing. Knowing what needs to be said requires knowing what the listener doesn't already know — and the farmer has no way to model that, because he's never had to. Everyone he's ever talked to about plowing already knew what a field needs. The gap only exists between him and someone who doesn't share his history, and he can't see the gap from his side of it, because from his side there isn't one.</p>
<p>This is a different claim than "requirements are often incomplete because people are rushed or careless." Even a patient, careful, engaged domain expert cannot fully specify a domain to someone outside it, because the missing information isn't information he's choosing not to share — it's information he doesn't know he has. It only becomes visible in contact with someone who lacks it, which means it can only surface <em>during</em> the conversation, or during the build, never fully in advance. A requirement gathered once, however carefully, is a snapshot of what one person thought to say to another person, not a complete transfer of a domain. It can't be, by the nature of what tacit knowledge is.</p>
<p>That's the actual argument against waterfall, for domains like this one — not that big upfront design is inconvenient or slow, but that for a business domain built substantially from tacit knowledge, "complete upfront understanding" isn't achievable in practice, for any domain expert, no matter how good. There will be domains that can be specified more precisely in advance, but it's doubtful even those can arrive at a fully correct and complete picture in version one. More time invested up front gets you closer, and leaves less to correct later, but the picture only gets validated through contact with the real system and the feedback loop that comes with building it. What changes by domain is how much iteration is needed, not whether it's needed at all — and for most business software, that need is large enough that shortening the loop matters more than lengthening the upfront phase. If the goal is to reduce the risk of building the Ferrari, the only real lever is shortening the distance between the requirement being stated and the mismatch being discovered — not writing better requirements up front.</p>
<h2>Why the Farmer Never Needed a Model — and the Engineer Does</h2>
<p>Here's the detail worth sitting with: the farmer has never built a model of his own domain. He doesn't have a diagram of soil types, terrain gradients, and their effect on plow depth. He doesn't need one. He simply <em>does</em> the domain — years of accumulated pattern-matching that never had to be made explicit because it was never being transferred to anyone.</p>
<p>The engineer is in the opposite position. They have no lived history with the domain, and they're being asked to produce something that behaves correctly inside it anyway. A model — the actual object of domain modeling — isn't paperwork layered on top of the "real" work of writing code. It's a substitute for the lived intuition the engineer doesn't have and can't acquire the way the farmer did, by doing the job for twenty years. It's the compressed, explicit version of what the domain expert holds tacitly, built because the person who needs to act correctly in this domain doesn't have the history that would let them act correctly without it.</p>
<p>This reframes what a domain model is <em>for</em>. It isn't there to make the code look organized. It exists because the engineer's ignorance of the domain is the default starting condition, and the model is the mechanism that closes that gap enough to build something that survives contact with the field.</p>
<h2>The Model Talks Back</h2>
<p>There's a second function a domain model performs that's easy to miss if it's only thought of as documentation that happens to run: it becomes the actual medium of conversation with the domain expert.</p>
<p>Show a farmer a diagram of <em>Field</em>, <em>Terrain</em>, <em>PlowDepth</em>, and <em>SoilCondition</em>, and something happens that a user story never achieves. The farmer recognizes it — or doesn't. If a concept is named wrong, missing, or conflated with something that should be separate, he sees it immediately, the way any expert notices a wrong description of their own domain on sight. He doesn't need to read code. He needs to recognize the shape of his own field of work, and either nod or correct it.</p>
<p>That makes the model more than a private substitute for the lived experience the engineer doesn't have. It becomes a shared artifact — a way for the domain expert to correct what's wrong and add what's missing, in a form neither side had to translate through unrelated technical vocabulary first. It's the same discovery process from earlier in this series, except now there's something concrete to point at instead of only a conversation to have. That's the difference between discovery as a one-time interview and discovery as something a project keeps doing throughout its life: the model gives the domain expert something to react to, and every reaction is new information the model can absorb.</p>
<h2>The Sundial and the Clock</h2>
<p>There's a cleaner way to see the difference between satisfying a requirement and understanding a domain, and it doesn't need farming at all.</p>
<p>Someone asks what time it is. The fast way to answer, once, is to look at the sun's position and work it out — a stick in the ground, a shadow, a quick calculation. It's correct. It satisfies the request. It took almost no effort.</p>
<p>It also only works at that moment, under those conditions. Ask again at night. Ask on a cloudy day. Ask indoors. The stick-in-the-ground approach doesn't fail because it was built badly — it fails because it was never a model of <em>how time works</em>. It was a calculation performed once, for the specific conditions present when the question was asked, dressed up as an answer to a more general question than the one it can actually address.</p>
<p>A clock is a different kind of thing entirely. Nobody asked it what time it is right now — it was built to encode the actual mechanism, gears turning at a known rate, and it keeps producing the correct answer regardless of sun, cloud, or time of day, including for moments nobody thought to test it against. The clock isn't smarter than the sundial. It isn't even answering a different question. It's just built from the mechanism instead of from the answer, and that's the entire difference.</p>
<p>This is the real distinction underneath "satisfying behavior" versus "building a model" — not a language choice, not procedural code versus object-oriented code, since either can be written either way. The sundial approach calculates the specific answer a specific requirement asked for, at the moment it was asked, and stops there. The model approach encodes the relevant mechanism that produces the answer, so that it keeps producing correct answers under conditions nobody explicitly enumerated. A green test suite is a sundial reading — proof the answer was right at noon, on a clear day, for the case that got tested. It says nothing about midnight, or rain, or the question nobody thought to ask yet. This is also why the <a href="https://blog.leonpennings.com/rich-domain-modelling-a-library-story">library article</a>'s LendableItem example gave you loan-frequency reports and reorder signals nobody requested: not a bonus feature, just what a correct mechanism does once it's built — it keeps being correct for cases it was never specifically asked about, the same way a clock keeps telling time nobody checked it against.</p>
<p>This is worth stating plainly, because it's easy to misread as an edge-case problem rather than what it actually is. If a sundial-style implementation only ever gets checked during an MVP's daytime hours, its tests pass — cleanly, repeatedly, every time. The mechanism looks solid, because nothing has contradicted it yet. It isn't solid. It was never validated against the domain, only against the sliver of it the current tests happened to cover. When the requirement expands to round-the-clock, the failure isn't a missing case to patch in. The entire mechanism was built for the wrong problem, and none of it survives the expansion intact — because there was never a mechanism there to begin with, only an answer that happened to be correct once.</p>
<p>That's the part easy to miss about a domain model: it isn't documentation of understanding, sitting beside the code as a reference. It's the engine that generates correct behavior going forward, including for the parts of the domain nobody has asked about yet.</p>
<h2>Naming It: Essential and Accidental Complexity</h2>
<p>Fred Brooks named this distinction in 1986, in <em>No Silver Bullet</em>, and it's worth using his terms directly rather than reinventing them, because the distinction predates every architectural trend either side of any debate could otherwise appeal to.</p>
<p>Essential complexity is the complexity inherent in the problem itself — the rules, relationships, and constraints that exist regardless of how the problem gets implemented: soil type and terrain in farming, the mechanics of timekeeping, gear ratios in an engine. Accidental complexity is complexity introduced by whatever representation and technology gets chosen to implement that problem: a framework, a deployment topology, the specific way a service happens to be split from another. Essential complexity cannot be removed without changing what the system does. Accidental complexity exists purely to make the essential complexity operational, and none of it should be allowed to dictate what the essential complexity is allowed to look like.</p>
<p>The sundial and the Ferrari are both implementations that ignore the essential complexity of their domains — they satisfy the stated requirement while encoding none of the mechanism underneath it. The clock and the tractor are implementations that respect it: built from what the domain actually requires, which is why they keep working outside the exact conditions of the original ask.</p>
<h2>Why This Compounds With Scale</h2>
<p>For a small system, none of this costs much. A handful of concepts, a single developer who can hold the whole domain in their head — the difference between a sundial and a clock barely registers, because nobody's asking the hard question yet.</p>
<p>It stops being free for two separate reasons once a system grows.</p>
<p>The first is contradiction-spotting. In a small system, if two pieces of logic quietly disagree about what a "loan" or a "position" means, one person notices, because one person can still see both pieces at once. Past a certain size, nobody can see both pieces at once — which means an essential concept can silently diverge into two or three slightly different meanings across the system, and nothing surfaces the contradiction until it produces an inconsistency somebody has to explain after the fact.</p>
<p>The second is the bounded-context problem from Part 1, restated: a human mind can hold only so much structure at once, the same way any representation has a limit to how much a person can carry in working memory. When the essential complexity is explicit — named, owned, in one place — a person only has to load the relevant piece to reason about a change. When it's dissolved into accidental structure, reasoning about any one change requires reconstructing understanding scattered across services, layers, and naming conventions that don't map to the domain at all.</p>
<h2>Premature Boundaries Are Waterfall, Applied to Structure</h2>
<p>There's a specific version of this mistake worth calling out on its own: drawing service or module boundaries before the domain's actual contours are visible.</p>
<p>Imagine splitting an engine department from a transmission department early in a car's design, based on the first impression that they're separate concerns with a clean interface — say, a shared notion of rotational speed. Only later does it become clear the two are coupled through torque, not speed, and that the "clean" interface drawn on day one was never the seam the domain actually has. Correcting that after the fact doesn't mean editing a diagram. It means unwinding a boundary that's since been built on, deployed around, and staffed for — two teams, two codebases, two deployment pipelines, all organized around a seam that turned out to be in the wrong place.</p>
<p>This is the same mistake as waterfall, one level up. Waterfall assumes the domain can be fully specified before any code exists. Premature service decomposition assumes the domain's <em>boundaries</em> can be fully known before any of it has been built and tested against reality — and boundaries are exactly the kind of thing that only reveals itself under contact, the same way the torque coupling only shows up once the engine and transmission actually have to work together.</p>
<p>It's worth being precise here rather than sweeping: this isn't an argument that distributed architecture is wrong. Microservices can have a legitimate place. It's a much narrower one than the default status they're often given in "enterprise development" today, where reaching for them has become closer to a starting assumption than a decision made in response to anything the domain actually demonstrated.</p>
<p>The lesson isn't "never split." It's that a boundary drawn before the domain's contours are known is a bet, not a finding — and bets made early, staffed and deployed around, are exactly the kind of decision this whole series has been describing: cheap to make, and expensive to unmake, precisely because nothing about splitting early announces itself as a mistake until the wrong seam has already been built on for years.</p>
<h2>Where This Leaves Us</h2>
<p>The Ferrari, the sundial, and the engine split early are the same failure, three times over: something that satisfies what was asked, built without the mechanism that would let it keep being correct once conditions change. The fix isn't more upfront specification — that's unavailable for the same reason perfect requirements are: tacit knowledge, not effort, is the limiting factor.</p>
<p>The obvious mistake sitting underneath most long-lived systems is optimizing for the wrong finish line. Shipping working behavior gets treated as the goal, when what actually determines whether a system survives is something else: how fast a bug gets found and fixed, how easily new understanding gets absorbed without a rewrite, how quickly a new hire can trace what happens where. Those aren't secondary qualities layered on top of "does it work." They're the entire difference between a system still running, and still comprehensible, twenty years from now, and one quietly marked for a rewrite every five to seven years because nobody can afford to keep changing it.</p>
<p>What actually produces that difference isn't a methodology. It's the discovery of the essential complexity — the actual mechanism, found the way the sections above describe, not assumed on day one — and encoding it explicitly, in one place, as a model.</p>
<p>Once that happens, something inverts that's easy to miss. Accidental complexity — the framework, the deployment topology, whatever technology happens to run any of it — starts adhering to the model, instead of the model quietly bending to fit whatever the framework assumed the domain should look like. That's the direction the dependency is supposed to run: technology in service of the domain, replaceable and swappable, without ever touching the part that represents what the business actually is. When it runs the other way — domain bent around framework convention — the technology becomes load-bearing, and a framework migration stops being an engineering project and becomes a rewrite.</p>
<p>None of this requires getting the model right the first time — that's the same impossible demand as perfect upfront requirements, just moved one layer down. What it requires is a model that can be corrected, and scattered logic isn't correctable in the same sense, whatever it looks like from the outside. Changing it isn't a controlled correction, because nobody can fully trace what happens where without a model to trace it against — every change to logic spread across services and layers is closer to a gamble than a certainty, a bet that this was the only place the old assumption got encoded. A model doesn't eliminate that risk. It's what makes the risk visible and containable instead of silent and compounding, and it's what lets a wrong assumption discovered in year three cost a week instead of a quarter.</p>
<p>That inversion — model as the fixed point, tooling as the replaceable part — is what lets a system's core outlive any single technology choice, any framework version, any team that happens to be maintaining it this year. And the fact that a model exists at all, however incomplete, is already evidence that someone attempted to understand what the domain actually is, rather than only what it was asked to do. Not documentation. The engine — and the only part of the system built to still be correct, and still be there, decades from now.</p>
<p>Correct behavior is part of quality. The machine that produces that behavior — the part with no reference class, nothing to compare it against — is the much larger part, and the part nobody can see. Just because it can't be tested in a CI/CD pipeline doesn't mean it isn't there. It's the actual driver of cost and stability, and over the long run, those are what decide whether an application survives.</p>
]]></content:encoded></item><item><title><![CDATA[How Do You Tell Your Software Application Is of High Quality?]]></title><description><![CDATA[Part 1 — Problem Definition
You can't. Not with any test you currently run. Every test in a standard suite answers one question: does the software do what it's supposed to do. None of them answer a se]]></description><link>https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality</link><guid isPermaLink="true">https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[software quality]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Java]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Mon, 10 Aug 2026 09:48:38 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/eec89dff-1bf4-4e5f-8da1-300829b3e969.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Part 1 — Problem Definition</h2>
<p>You can't. Not with any test you currently run. Every test in a standard suite answers one question: does the software do what it's supposed to do. None of them answer a second, entirely different question: was it built well. Those two questions get treated as though they're the same question, asked at different levels of rigor. They aren't. A system can answer the first with a clean, unbroken green run and still fail the second completely — and nothing in how software gets built or evaluated will ever tell you that it did.</p>
<p>This is the unfalsifiability problem. Not because a structure can never be judged at all — duplicated rules, rising coupling, a change that gets slower every quarter are all observable after the fact. What's actually missing is narrower and harder to route around: a controlled counterfactual. The same problem, built by the same team, once with this structure and once without it, so the difference in outcome could be attributed to the structure rather than to everything else that also happened to be different. That comparison is the one nobody ever runs.</p>
<h2>What "No Comparison" Actually Means</h2>
<p>When a bridge fails, the failure is physical and attributable. Steel behaves the same way every time it's tested, across thousands of prior instances, under a huge range of known conditions. Materials science doesn't need to rebuild a bridge a second way to know something about how the first one will behave — it has a reference class large enough that a new instance can be evaluated against the pattern the class as a whole has already produced. But the reference class isn't only doing validation after the fact. It's what forces a specific conversation to happen before anything gets built: what load must this carry, what span, under what conditions. A bridge engineer can't skip that conversation, because skipping it produces a structure that visibly falls down. The physics isn't just a check on the design. It's what makes asking the right question first unavoidable.</p>
<p>Software has no equivalent reference class for the part that actually matters, and no equivalent forcing function either. It's tempting to reach for frameworks as software's version of tested materials — they're used by thousands of teams, documented at scale, with well-understood failure modes. And that reach is basically correct. Frameworks <em>are</em> the tested material of software: reliable, reference-classed, evaluable. But frameworks aren't where the essential core of the business lives. They're the machinery, not the product. Business logic — the actual rules, relationships, and responsibilities of a specific domain — is the part with no reference class at all, because it's bespoke by definition. Two businesses that sound similar on paper differ enough in team, history, and specific rule that no clean comparison across them is possible. The one part of software that <em>is</em> well-tested (the tooling) gets treated as a stand-in for evaluating the one part that isn't (the logic it's used to build) — which is precisely backwards, and precisely why framework choice gets mistaken for architectural rigor so often.</p>
<p>This is also where the bridge analogy earns its keep rather than just illustrating the point: the available tooling in software has standardized to the point where the question a bridge is compelled to ask — what does this actually need to carry — can be skipped entirely, and skipping it produces no visible failure at all. The framework compiles either way. A request gets implemented either way. Nothing about the system objects to being built on top of an answer nobody actually worked out.</p>
<p>What would actually resolve this is a within-system comparison: the same business problem, same team, same timeframe, built two structurally different ways, run side by side. That comparison would be clean. It's also, for all practical purposes, impossible — no organization pays twice for the same system to find out which structure served it better. The closest thing to a controlled experiment that software architecture could have is one that will never be run, not because nobody has thought of it, but because the cost of running it equals the cost of the thing it exists to evaluate.</p>
<p>This isn't a temporary gap waiting for better metrics. It's structural. And it means every test written against a piece of software was, from the outset, only ever capable of answering the first question.</p>
<h2>The Only Metric Available: Does It Work</h2>
<p>In the absence of that comparison, something fills the evaluative gap, and it's the only property a system demonstrates directly: whether it works. A feature satisfies the requirement or it doesn't. The suite is green or it isn't. These are checkable facts in an environment starved of any other kind, so they become the entire basis for evaluation — not because anyone decided quality didn't matter, but because working is the only thing left standing once the real comparison is removed from the table.</p>
<p>The problem is that working is compatible with almost any underlying structure. A three-way join across concepts that never should have touched produces the same green checkmark as a clean, well-placed query. A rule duplicated across four services, slightly differently each time, passes every test written against each service individually — because no test was ever written to check whether the four should have been one thing. Tests are very good at confirming behavior. They have no mechanism for confirming that the structure producing the behavior was the right one, because structural fit isn't a behavior. It's a property tests were never designed to see.</p>
<p>This isn't unique to automated testing, either. There is no automated test — none, at any level of the pyramid — that answers "was this the simplest solution to this problem." QA departments in software test function: does this input produce this output, does this flow complete, does this edge case get handled. That's a meaningful and necessary job. It is not the same job a QA discipline does in most other industries, where quality assurance routinely includes composition, tolerance, and design review, not just whether the finished part performs on the test bench. In software, that half of quality assurance doesn't quietly get done worse. It gets bypassed entirely, because nothing in the standard toolchain was ever built to ask the question.</p>
<p>The same limit applies to code review, which is usually the first thing offered as a counterexample. In practice, review operates on the diff in front of the reviewer — syntax, naming, local correctness, whether this specific change looks right. Catching a wrong domain model requires reconstructing the understanding the implementer built across the whole feature, not the twenty lines in the pull request, and reconstructing that understanding costs roughly what building it did in the first place. Nobody budgets review time at that depth, so it doesn't happen at that depth. This isn't a failure of diligence on any reviewer's part. It's the same economics that rule out the controlled comparison elsewhere in this piece, showing up again at review time instead of at build time.</p>
<h2>Boilerplate Reduction Is Not Quality Either</h2>
<p>It's worth naming a second false proxy that fills the same gap, quieter than "does it work" but just as widespread: measures of the code itself, taken in isolation from the business it exists to represent. Fewer lines. Lower cyclomatic complexity. A clean linter run. Boilerplate stripped out by a framework's conventions or a code generator.</p>
<p>None of these are quality, and it's worth being precise about why. They're measurable properties of the text, and because they're measurable, they get treated as a proxy for the thing that actually matters — for the same reason "does it work" does. But a syntax check has no opinion on whether a rule lives where it belongs. A boilerplate reducer can make a wrong structure shorter without making it correct. Code can pass every static check available and still scatter one business concept across four classes that were never meant to be separate. Readability — actual readability, the kind that lets a new developer understand what's happening without being told — isn't a property of line count or linter compliance. It's a separate thing entirely, and reducing the code is not the same activity as making that thing true, even though both produce a smaller diff.</p>
<h2>The "Working Ships Beat Well-Structured" Dogma</h2>
<p>There's a piece of received wisdom that follows directly from all of this, repeated often enough that it rarely gets questioned: shipping software that works beats well-architected software that doesn't ship yet. Taken at face value, this is true — software that doesn't ship has no value regardless of how well it's structured. But the comparison hiding inside that sentence is a false one.</p>
<p>The dogma implicitly compares working software against not-yet-working software, as if the well-structured option is the slower one, still being polished while the pragmatic option is already live. That's backwards. A well-structured system doesn't reach "working" later than a poorly structured one — it typically reaches it <em>faster</em>, because less time is spent fighting the accidental complexity a rushed structure generates along the way. The dogma survives specifically because the comparison it implies — the same team, same requirement, building it the well-structured way and the fast-and-loose way, side by side, to see which reaches "working" first — is exactly the comparison unfalsifiability prevents anyone from running. Nobody has the counterfactual. So the belief persists on the strength of an intuition that feels true and has never once been tested against its alternative.</p>
<p>The dogma also survives because it's true often enough to feel universal — just not at the scale where it actually matters. A five-entity prototype reaches "working" quickly whether it's thrown together procedurally or modeled properly, because the system is still small enough that structure barely constrains velocity. Most people's lived experience of "working beats well-structured" comes from exactly this kind of small, early build, where the claim is essentially correct. The claim quietly stops being retested at the point where it stops being true — somewhere between that five-entity prototype and the fifty-entity enterprise system it eventually grows into, if it succeeds. Nobody reruns the comparison at the new scale. The belief formed at five entities just gets carried forward unchanged into a context where the unfalsifiability problem is now doing all the work of keeping it unchallenged.</p>
<h2>Symptom: Tooling Validates Itself — Twice</h2>
<p>Frameworks and tooling win under these conditions almost by definition, because they optimize for exactly the metric actually being measured: does it work, and how fast can it be made to work. In practice, tooling doesn't necessarily deliver "working" faster in any measurable sense — it only appears to, because there's no counter-evidence available to challenge the appearance. The comparison that would test it, the same team building the same requirement the disciplined way, is the same comparison unfalsifiability rules out everywhere else in this piece.</p>
<p>This reaches into hiring as well. Design ability — the skill that would actually address the structural side of this problem — is slow to observe and hard to screen for; verifying it means watching someone reason through a domain over weeks, not evaluating an interview answer in fifty minutes. Tooling knowledge is the opposite: does this candidate know the framework, can they name the pattern. It's checkable in exactly the way "does it work" is checkable, one level up in the pipeline, so it becomes the actual filter — not because anyone decided design didn't matter, but because it's the thing that can be verified under time pressure and the other thing can't.</p>
<p>One consequence of this is worth stating carefully, because it's a claim about environments rather than people: an engineer working inside a tooling-driven, framework-first environment is rarely put in a position where they have to reason through "how do I solve this" from first principles — the framework has usually already made that decision for them. That isn't a judgment on any individual's ability. It's a statement about what an environment does or doesn't require of the people in it. An environment that never demands structural reasoning doesn't produce engineers who lack the capacity for it — it produces engineers who've simply never had the occasion to build it, because nothing in their day-to-day work ever required it and nothing ever punished its absence.</p>
<h2>Symptom: Dunning-Kruger, Structurally Guaranteed</h2>
<p>There's a psychological consequence worth naming directly, because it explains something that otherwise looks like a personality problem rather than a systemic one: engineers who are genuinely weak at structural design have almost no way to discover that about themselves.</p>
<p>The Dunning-Kruger effect describes people who lack a skill also lacking the ability to recognize that they lack it, because recognizing the gap requires the very competence that's missing. Software supplies the conditions for exactly this, mechanically. The feedback signal available to any engineer — did it work, did it ship, did the tests pass — is orthogonal to the skill that actually determines long-term cost. A feature built on a fundamentally wrong structure ships successfully as often as one built on a sound one. The signal an engineer receives, repeatedly, over years, is: this worked, therefore what I did was fine. That signal doesn't discriminate between "the structure was sound" and "the structure was wrong but hadn't been stressed yet." It only ever says yes.</p>
<p>This cuts in both directions, and the second direction is the crueler one. An engineer who has genuinely internalized good structure produces boring, unremarkable code — a small number of objects doing exactly what their names say, nothing that looks impressive because nothing had to be forced. That work is, by its nature, invisible: the whole achievement is that it doesn't look hard. Meanwhile the engineer solving the same class of problem with three fat services and an orchestrator gets visible credit for visibly wrestling something difficult — difficulty that, in a better structure, was entirely optional. Unfalsifiability doesn't just fail to reward good design. It rewards the appearance of difficulty over its absence, because appearance is the only thing anyone in the room has to go on.</p>
<p>Extend this across a career and the consequence is that an engineer's calibration ends up a function of which teams they happened to work in, not a function of any independent standard. Someone who spent years surrounded by procedural, fat-service default code, shipping successfully the entire time, has no internal signal telling them there was another way — every shipped feature confirmed the approach worked.</p>
<h2>Symptom: Cost That Accumulates Without a Cause</h2>
<p>The costs of a missing structural design are not subtle in aggregate — longer development time, longer bug resolution, more instability, larger teams required to deliver the same scope. What's subtle is that none of these arrive as an event. They arrive as a slope.</p>
<p>A tax rate change that should be a single edit becomes a three-week audit of every place the rate was independently encoded. A team experiencing that doesn't conclude "our structure was wrong." They conclude "this was a harder problem than expected," because there's no comparison available — no version of the same team doing the same change against a better-structured system — to reveal that three weeks was a structural cost, not an inherent one. The expense gets absorbed into the ambient sense that software is just like this, rather than attributed to a decision made at the outset.</p>
<p>The symptoms compound rather than sit side by side. Instability gets answered with more code review and more QA rather than a question about why the system is unstable. A team that can't move fast gets answered with more headcount rather than a look at whether headcount is compensating for a structure that makes parallel work harder. Both responses look reasonable in isolation. Both also add process, roles, and apparent normalcy that further bury the original cause under what now looks like ordinary cost-of-scale.</p>
<p>And cost, on its own, is often a number an organization is willing to absorb. Most businesses will tolerate a slower, more expensive delivery pipeline as long as delivery keeps happening — cost overruns get budgeted around, not treated as emergencies. The place this actually stops being tolerable is different, and it's worth naming directly: it's stability and data integrity. A system that takes three weeks instead of one day to change a tax rate is expensive. A system where that same tangle of duplicated, half-synchronized logic produces an inconsistency between what invoicing believes and what reporting believes is not expensive — it's a trust problem, and trust problems don't get solved with a bigger budget. In large, long-lived systems, this is where the real price of a missing structure eventually gets paid, and it's a price a line item can't absorb the way it can absorb cost.</p>
<h2>Where This Leaves Us</h2>
<p>None of the above resolves anything, and that's deliberate. The point of laying it out this carefully is to be honest about the size of the problem before reaching for an answer to it. Software has no bridge-equivalent feedback loop. In its absence, "does it work" — and its quieter cousins, does it pass the linter, does it use the right framework — has become the only available standard: for the code, for the architecture, for the tooling adopted around it, for the engineers hired to build it, and for those engineers' own sense of whether they're any good at the job.</p>
<p>Part 2 picks up from here — not with a fix for unfalsifiability itself, which isn't fixable, but with a distinction that does the work of managing it: what's fundamental to the business a system serves, and what's merely the tooling and structure chosen to build it. Keeping those two things visibly apart turns out to be the closest thing software has to the feedback a bridge gets for free.</p>
]]></content:encoded></item><item><title><![CDATA[Discovery and Fit Testing: The Two Jobs of a Domain Model]]></title><description><![CDATA[Every experienced architect has had this happen. A domain expert asks for feature X. An hour of conversation later, it turns out they don't actually need X at all — they need something X happened to b]]></description><link>https://blog.leonpennings.com/discovery-and-fit-testing-the-two-jobs-of-a-domain-model</link><guid isPermaLink="true">https://blog.leonpennings.com/discovery-and-fit-testing-the-two-jobs-of-a-domain-model</guid><category><![CDATA[software development]]></category><category><![CDATA[Java]]></category><category><![CDATA[Rich Domain Model]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[Object Oriented Programming]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Tue, 21 Jul 2026 07:49:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/2ef3c1fc-a7a3-4863-b0a7-e9f043283980.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every experienced architect has had this happen. A domain expert asks for feature X. An hour of conversation later, it turns out they don't actually need X at all — they need something X happened to be the only tool they knew for. Months after that, while building the thing they actually needed, the code starts fighting you: a special case here, a workaround there, a class that has to reach somewhere it shouldn't. Those two moments feel unrelated. They're not. They're the same activity, happening at two different points in time.</p>
<p>Call the first one <strong>discovery</strong>: finding out what the domain actually is by refusing to accept a stated want as the real need. Call the second one <strong>fit testing</strong>: using implementation itself as the check on whether that theoretical model actually holds up against the real thing it's supposed to represent. A rich domain model is not a coding style. It's the artifact that sits between these two — built by the first, tested against reality by the second.</p>
<h2>Discovery: Finding Out What the Domain Actually Is</h2>
<p>Domain experts are precise about their domain and imprecise about their own requirements, and this isn't a criticism — it's structural. Someone who has done a job fluently for years has usually stopped noticing which parts of how they do it are essential and which parts are habit or inherited tooling. Ask a metadata specialist who has spent a career hand-writing XPath what they need from a new tool, and the answer arrives instantly and confidently: XPath support. That answer is true. It is also the current solution, offered back as if it were the problem.</p>
<p>Discovery is the work of not stopping there. Ask what the request is trying to achieve. Ask what happens if that specific mechanism isn't available. Ask what the same outcome looks like described without reference to any particular tool. Somewhere under three or four of these questions, the actual need usually surfaces — not "give me XPath," but "let me identify a piece of a structured document without first having to learn a query language." That reframing changes what gets built: a hierarchy of named parts that mirrors how anyone would describe the document out loud, with XPath kept as an escape hatch for the minority who want it, instead of the interface the whole tool is built around.</p>
<p>A request is a fact about the domain — proof that some need exists — not a specification of the correct implementation. That's the whole method, really. The rest is just the discipline to keep asking past the first fluent answer, and enough earned standing that the fourth question lands as insight rather than as second-guessing an expert on their own domain.</p>
<h2>Fit Testing: The Code Is the Reality Check</h2>
<p>Once a theoretical model exists, it has to be built — and this is where a different kind of test takes over. Not a unit test. A unit test verifies that a given input produces a given output, and it will pass just as happily against a model that took three workarounds to get there as it will against one that didn't. It has no opinion on the workarounds. A fit test does: did this shape fall into place naturally, or did the theory have to be bent to survive contact with the real thing it was supposed to describe?</p>
<p>Take an overdue invoice that needs to affect a customer's account. The fast implementation has Invoice reach into Account and change its status directly. It works. It also requires Invoice to know something about Account that was never its business to know — and that mismatch shows up as friction: an import and a method call that cross a boundary the model was supposed to respect. The corrected version has Invoice record a CustomerFact on Account; Account alone decides, from its own history of facts, whether a status change is warranted. Invoice never learns blocking exists. Account never learns anything about invoices, only about facts. Nothing had to be bent to make it work — and that absence of bending is the fit test passing. A green test suite tells you the code returns the right answer. A friction-free implementation tells you the theory actually fit the real domain. Those are different claims, however often they get treated as one.</p>
<p>This shows up anywhere code starts accumulating branches instead of concepts, not just in the classic entity-relationship examples:</p>
<pre><code class="language-plaintext">if (customer.isVIP())
    ...
else if (customer.wasVIP())
    ...
else if (customer.isTemporaryVIP())
    ...
</code></pre>
<p>The common response is "add another flag, another enum value, another branch." The fit-testing response is a different question: why am I accumulating special cases instead of finding the concept that was missing — a MembershipStatus, a BenefitsPolicy, something that already explains all three cases as states of one idea instead of three unrelated flags bolted on as they came up. The same signal shows up as a Controller, a Processor, or an Orchestrator materializing to coordinate behavior that no single object seems to own; as a Map&lt;String, Object&gt; standing in for a type nobody named yet; as a Strategy hierarchy invented purely to keep something "flexible" rather than because the domain actually varies that way. None of these are technically wrong. All of them are the same tell: a concept is missing, and something generic has been built to cover for its absence.</p>
<p>The whiteboard version of a model and the code version should be the same shape, box for box, arrow for arrow. When they diverge — when four clean concepts on paper need three extra abstractions before they can become four collaborating objects — that divergence is the fit test already failing, before a single unit test has been written.</p>
<p>There's a second, quieter gain that fit testing produces almost as a side effect, and it's worth naming directly because it doesn't show up on any dashboard: when the model actually fits, the engineer building it ends up understanding the business, not just the ticket. Following a model that fits reality means following the same shape the business itself already has. An engineer who's built CustomerShipping, or CustomerFact, or LendableItem understands what those things mean in the business, not just what fields they carry. That shouldn't be a controversial claim, but it's rarely stated plainly: engineers who understand what they're actually automating make fewer mistakes, catch wrong requirements before they ship instead of after, and are visibly more engaged in the work than engineers translating a ticket into code they don't have to understand to close it. Fit testing isn't just how you check the model. It's how the team ends up actually knowing the business it's building for.</p>
<h2>Why These Are Two Skills, Not One</h2>
<p>Discovery is closer to a research-interview skill than a technical one — listening for the gap between a stated want and an underlying need, and having built enough credibility that pushing back on a domain expert lands as expertise rather than obstruction. None of that depends on a language or a framework. It's earned slowly, one correctly-challenged assumption at a time.</p>
<p>Fit testing is a technical habit of noticing resistance and refusing to route around it — asking, every time a shortcut presents itself, what concept its absence is covering for. It's learnable in a way discovery isn't; it's closer to a discipline that improves with deliberate attention than to an interpersonal skill that has to be earned in real time with a real person.</p>
<p>Most engineers who are strong at one are mediocre at the other, which is part of why the combination looks rarer than either skill actually is on its own. Frameworks make it worse by supplying a default shape, removing the need to properly investigate a request before reaching for whatever pattern already exists for requests roughly like it.</p>
<h2>Where Discovery Went</h2>
<p>Ask most teams what "design" means at the start of a project and the answer, in practice, is a technology decision. Spring or not. Event-driven or request-response. Microservices or a monolith. CQRS or plain CRUD. These conversations happen early, they happen with real seniority in the room, and they get treated as <em>the</em> design phase — the moment the important architectural thinking supposedly takes place.</p>
<p>None of it is design. It's procurement. Design, properly understood, is the work of figuring out what the core automation is actually supposed to do — what the business logic is, what the concepts are, how they interact, who owns which decision. Technology is supposed to answer to that understanding, not substitute for it. A framework, a message bus, a deployment topology — all of it exists in service of the business logic once that logic is understood. None of it can tell you what the logic is. Only discovery can do that, and discovery is precisely the step that gets skipped when "design" has already been spent on choosing a stack.</p>
<p>This isn't a minor sequencing mistake. Choosing the technology before the domain is understood guarantees the domain gets bent to fit whatever was chosen, rather than the other way around. A team that has already committed to microservices has already, silently, committed to wherever the service boundaries end up being drawn — before anyone has done the work of finding out whether the business actually has boundaries there at all. The technology conversation feels like design because it's substantive, technical, and happens among senior people. It produces artifacts — diagrams, ADRs, a slide deck. Discovery, by comparison, produces a shared understanding that's much harder to put in a deck, which may be exactly why it's easier for an organization to skip: it doesn't look like work in the way a technology decision does, even though it's the harder and more consequential decision of the two.</p>
<h2>What AI Changes, and What It Doesn't</h2>
<p>AI-generated code doesn't experience friction. A three-way join across concepts that never should have touched gets written with exactly the same fluency as a clean one-concept query. The resistance that used to nudge a developer toward asking whether the model was wrong doesn't register anywhere in the process — not because the tool is careless, but because friction was never really "the thing that mattered." It was a side effect of implementation being expensive enough to hurt. Remove the cost and the alarm goes quiet. The question it used to raise is exactly as important as it always was.</p>
<p>Fit testing now has to be a deliberate habit instead of something that used to announce itself as pain. Discovery, if anything, gets more valuable, not less — it's the one part of this that was never about code in the first place. It happens in a conversation, built on trust that took time to earn, and nothing about implementation being free changes how that trust gets built or removes the need for someone to ask the fourth question that gets past the tool being asked for and down to the need underneath it.</p>
<h2>So What</h2>
<p>The best domain models don't read like clever engineering. They read like something a child could follow — Mickey Mouse, not general relativity. That's not a lack of ambition. It's the actual goal. General relativity is undeniably more impressive to look at, and it is also vastly harder to maintain, harder to hand to the next person, and far more expensive every time it turns out to be slightly wrong. The closer a model gets to correct, the more boring and obvious the resulting code looks — no controller coordinating things that don't know about each other, no orchestrator standing in for a decision nobody assigned an owner to, just a small number of objects doing exactly the thing their name says they do. Impressive code is usually a sign that a concept is still missing. Boring code is usually a sign that it isn't.</p>
<p>This has an uncomfortable corollary worth saying plainly, because it's the part nobody warns you about: correct modelling work is invisible by design. The whole point of getting the model right is that the result looks like it wasn't hard — which means the people doing it well are structurally at risk of looking like they did less than whoever is maintaining the impressive, complicated, wrong version next door. Nobody throws a project a parade for being simple. The praise goes to whoever is visibly wrestling something difficult, even when the difficulty was optional.</p>
<p>Software architecture isn't primarily about drawing better class diagrams. It's about asking better questions before the code exists, and paying attention when the code quietly tells you those answers were wrong.</p>
]]></content:encoded></item><item><title><![CDATA[Why Expensive Software Development Never Looks Expensive]]></title><description><![CDATA[Every organisation that has run a significant software system for more than a few years has felt a version of the same thing: a change that should have taken days takes months, nobody can quite explai]]></description><link>https://blog.leonpennings.com/why-expensive-software-development-never-looks-expensive</link><guid isPermaLink="true">https://blog.leonpennings.com/why-expensive-software-development-never-looks-expensive</guid><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[technical-debt]]></category><category><![CDATA[cto]]></category><category><![CDATA[business]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Thu, 16 Jul 2026 06:45:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/540aa0ca-5afd-4a2c-81ae-6535635bb858.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every organisation that has run a significant software system for more than a few years has felt a version of the same thing: a change that should have taken days takes months, nobody can quite explain why, and the explanation that eventually gets offered — the domain is complex, the requirements changed, the previous team was careless — is almost never checked against an alternative approach for the software architecture or alternative framework choices, because the alternative was never built. There is no possible comparison to determine the solution chosen is a good one and there is no benchmark to measure "fit for purpose."</p>
<p>This is the unfalsifiability problem, and it is worth stating plainly before anything else in this piece, because it is the reason the cost described below is so rarely traced back to its actual cause. Every system is built once. There is no version of your platform built the other way, running alongside it, that anyone can compare it to. So when a system works, the approach that produced it gets read as validated. When a system becomes expensive to change, the cost gets attributed to anything except the structural decision that caused it — because that decision was made years ago, by people who may have moved on, and there is no control group to prove that the structure was the variable that mattered.</p>
<p>That absence of a control group is not a minor academic point. It is the reason a specific, avoidable pattern of cost has been able to spread through the industry for decades, get taught in courses, get validated in interviews, and still never be clearly named as a mistake. This article is an attempt to name it — and to offer something more useful than a diagnosis: a way to check, this week, whether it applies to you.</p>
<h2>The Villain: Process Over Product</h2>
<p>Ask almost any team building a significant piece of software what the goal of the project is, and the honest answer, more often than anyone would like to admit, is not "build the best-fitting product for this business." It is "follow the process correctly." Those two goals overlap often enough that the difference goes unnoticed for long stretches — right up until the moment they diverge, at which point one of them has to be compromised, and it is almost always the product that loses, because the process is what gets measured.</p>
<p>The clearest place to see this is the sprint. A sprint is regularly treated as something close to a small contract: a ticket is opened, work begins, and the implicit goal becomes finishing what was started, as written, inside the sprint boundary. But a sprint was never supposed to be a contract. It is a checkpoint — a scheduled moment to ask whether the team is still building the right thing, not a deadline that obligates them to keep building whatever they said they would two weeks ago, even after it has become clear the ticket was wrong. When a team discovers, mid-sprint, that a task was scoped incorrectly or is solving the wrong problem, the question that should get asked is simply "are we building this correctly," not "how do we still close this ticket by Friday." Closing the ticket is a process goal. Building the right thing is a product goal. Optimising for the first at the expense of the second is a choice, made quietly, dozens of times a sprint, that nobody frames as a choice at all.</p>
<p>Test coverage carries the same tension in a different form, though it deserves a careful statement, because tests are genuinely valuable — for preventing regressions, for guaranteeing behaviour that actually matters, for catching what a reader would miss in logic that is legitimately intricate. The problem is not testing. It is treating a coverage number as the goal itself, rather than as a tool applied where the underlying risk actually warrants it. "Write tests for everything" is a policy, and once it is a policy, coverage becomes something to satisfy rather than something to reason about — the same substitution as closing the ticket standing in for building the right thing. The actual question is proportional, not universal: how much does this particular piece of code need a test, given how easy it already is to read and predict? Code whose structure makes its purpose explicit lets the next developer catch a functional gap by understanding it, the same way a domain expert notices a wrong assumption by reading a model rather than by waiting for something to fail. Code that requires a large surrounding suite just to reveal what it is supposed to do in a given case is not made safer by that suite — the suite has become the place the missing legibility went to hide. Tests earn their cost against genuinely error-prone or consequential logic. Applied as a blanket mandate regardless of what is actually being tested, they are a process metric standing in for a product judgment.</p>
<p>Neither example is an argument against sprints or against tests. Both are examples of the same underlying pattern: a process artefact, adopted for a good reason, quietly becoming the thing being optimised for, while the product — the thing the process was supposed to be in service of — becomes secondary, and nobody notices the moment it happened.</p>
<h2>Why This Produces the Same Tools, Regardless of Fit</h2>
<p>Process-over-product does not announce itself as a mistake. It shows up as a default: a framework, an architectural style, a way of organising a codebase, chosen not because anyone evaluated whether it fit the problem, but because it is what the process expects, what the team already knows, or what the last project used.</p>
<p>It is worth being honest about why patterns like heavy service layering, event-driven architecture, or a framework's particular conventions get chosen this often, because the honest answer is not stupidity — it is a genuine, defensible fit for a specific kind of team. When developers rotate frequently, when work is deliberately broken into small, tightly specified tickets, when the organisation is optimising for the ability of any developer to pick up any task with minimal ramp-up, a rigid, uniform, low-context structure is a reasonable trade. It sacrifices peak comprehension of the system as a whole in exchange for consistency and a short on-ramp for whoever picks up the next ticket. That is a legitimate choice, for that context — the same logic that makes an assembly line sensible for certain kinds of manufacturing.</p>
<p>The failure is not using that structure. It is using it by default, applied to a context where the actual constraint is the opposite one — a long-lived, complex, low-turnover core system, where the thing that matters most is not how quickly an unfamiliar developer can complete an isolated task, but how deeply the team's understanding of the business is reflected in the code, and how cheaply that understanding can be corrected when it turns out to be wrong. Applying an interchangeability-optimised structure to a comprehension-optimised problem is a mismatch, and the mismatch is invisible on the day the choice is made, because both approaches compile, ship, and pass their tests. It only becomes visible years later, in the cost of the change that should have been simple.</p>
<p>Take an ordinary example. A tax rate changes — in principle, a one-line update: a single number, changed in one place. In a system where that calculation exists once, as a single rule a finance person would recognise, it usually is exactly that: one change, applied everywhere it needs to apply, because there is only one place it could live. In a system where the same calculation was implemented separately, over several years, by whichever part of the system happened to need it at the time — order processing, invoicing, refunds, reporting — the same change now means finding every place the rate was written down, confirming none of them quietly diverged from the others somewhere along the way, and testing each one independently, because nothing in the system ever enforced that they were the same rule to begin with. A one-day change becomes a three-week project. Nothing about the system is broken. Every part of it still works. The cost was always there — it was simply invisible until something forced it into view.</p>
<h2>The Bigger the System, the Simpler the Code Needs to Be</h2>
<p>This mismatch does not cost a fixed amount. It scales — directly, and in the wrong direction — with the size and longevity of the system it is applied to.</p>
<p>A human mind can hold a limited amount of context at once. A small system fits inside that limit regardless of how it is structured, which is why a mismatched architecture on a small application rarely causes visible pain — there simply isn't enough complexity yet for the mismatch to matter. As a system grows, that stops being true. The team's collective ability to hold the whole system in mind does not grow at the same rate the system does, and at some point the only thing that keeps the system navigable is whether its structure makes the business explicit — whether a developer can look at the code and see what the business actually is, rather than having to reconstruct that understanding from services, layers, and event subscriptions scattered across the codebase.</p>
<p>This is precisely backwards from how complexity tends to get handled in practice by the process/framework driven approach. The more complex an application becomes, the more elaborate its supporting structure tends to get — more services, more layers, more indirection, more process — on the theory that complexity should be met with more machinery. But machinery is not what makes a large system comprehensible. Explicitness is. The bigger and more consequential the system, the simpler and more direct the code needs to be, because simplicity is what makes the essential complexity of the business — the actual rules, relationships, and responsibilities — visible to a mind that cannot hold the whole system in memory at once. A system that makes its structure explicit tells a developer when something is wrong. A system that has dissolved its logic into layers and services waits for a customer, an auditor, or a very expensive incident to tell you instead.</p>
<h2>What Actually Controls the Lifetime Cost of a System</h2>
<p>There is a second, related cost that rarely gets priced in at the point of decision: the lifespan of the framework and the architecture the system is built on is not the same as the lifespan the business needs from the system itself.</p>
<p>Frameworks version, deprecate, and eventually stop receiving free support. Distributed architectures accumulate their own maintenance burden — contracts between services that need to be kept synchronised, infrastructure that needs to be operated, failure modes that need to be observed and traced across boundaries that a single deployable unit would never have needed in the first place. None of this is a one-time cost. It recurs, every year, for as long as the system runs, and it is paid regardless of whether the business logic underneath it has changed at all.</p>
<p>The degree to which this matters depends entirely on how entangled the business logic is with the framework and the distribution model sitting underneath it. When the two are kept genuinely separate — when the logic that represents what the business actually does does not depend on a particular framework's conventions or a particular network topology to be correct — the framework and the distribution model become swappable. A major version upgrade, a framework migration, even a move away from a distributed architecture back to a simpler one, becomes an engineering project with a bounded cost, rather than a rewrite. When the two are entangled — when the business logic is expressed in terms of the framework's annotations, or depends on a particular sequence of asynchronous events across services to behave correctly — every upgrade, every migration, every framework decision made by a vendor becomes a forced decision for the business, on someone else's timeline, at a cost the business had no say in setting.</p>
<p>Independence from framework and from physical distribution is not a purity concern. It is the single largest control most organisations have over the lifetime cost of a system, and it is decided almost entirely at the point the system is first structured — long before anyone is thinking about framework versions or infrastructure bills.</p>
<h2>A Simple Test</h2>
<p>None of the above is abstract. It can be checked, directly, against a system you already have, in a few minutes. Ask:</p>
<ul>
<li><p>Can a new developer read the core logic and understand what the business actually does directly from the code — or does understanding it depend on documentation, a wiki page, or someone explaining it out loud? If non-code documentation is required to understand what the system does, that is itself a signal that the code is not legible.</p>
</li>
<li><p>If your current framework disappeared tomorrow, how much of your actual business logic would survive untouched?</p>
</li>
<li><p>When a rule needs to apply in two different places, does it genuinely live in one place — or has someone had to remember to update it twice?</p>
</li>
<li><p>Are your service or module boundaries a reflection of the business, or a reflection of how the org chart, the deployment pipeline, or last year's team structure happened to be drawn?</p>
</li>
</ul>
<p>A "no" to any of these is technical debt — not a crisis on its own, but a specific, named cost that is now accumulating quietly, whether or not anyone is tracking it. Most systems carry some of this, and carrying a little is rarely fatal. What is worth paying attention to is how many of these land on "no," and how long they have been accumulating — because that is what predicts whether the next significant change costs what it should, or costs considerably more.</p>
<p>It is worth being honest about the limits of asking these questions yourself, or having them answered by someone already inside the team — and it is worth being equally honest that "independent" is not, by itself, enough. A review by someone who is simply outside the team but fluent in the same framework and the same conventions will still tend to evaluate the system from inside those conventions: a Spring application reviewed by a Spring expert will get a great deal of useful feedback on how well Spring was used, and very little on whether Spring, or the architecture built around it, was the right choice for this problem in the first place. That is not a competence problem. It is a vantage point problem, and a shared vantage point does not get corrected by adding a second person who shares it.</p>
<p>What is actually useful here is a genuinely contrarian review — one that starts from the domain rather than from the conventions already in place, and is explicitly looking for reasons the current choices might be wrong, not confirmation that they were applied correctly. The goal of an engagement like that is not to produce advice you are obligated to adopt. It is to generate as many concrete remarks, objections, and alternative approaches as possible, so that even the recommendations you choose not to act on have at least been seen from outside the room they were originally decided in. A focused engagement — a few days in the codebase and with the team, examining the core domain, the framework's boundaries, and where the two are entangled — is enough to produce a written report naming the specific structural decisions currently driving cost, in terms a budget owner can act on, along with concrete, sometimes contrarian, recommendations for what to do about it.</p>
<p>If the test above raised more questions than it answered, that is usually the point at which a second, independent set of eyes is worth more than another sprint of guessing.</p>
]]></content:encoded></item><item><title><![CDATA[Why Software Can't Tell You It's Wrong]]></title><description><![CDATA[Software architecture debates have a problem that most other engineering disciplines don't: the alternative was never built.
When a bridge fails, the failure is physical, attributable, and measurable ]]></description><link>https://blog.leonpennings.com/why-software-can-t-tell-you-it-s-wrong</link><guid isPermaLink="true">https://blog.leonpennings.com/why-software-can-t-tell-you-it-s-wrong</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Wed, 08 Jul 2026 06:50:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/a10e0b92-55b9-4d3c-91d8-e29c78114727.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Software architecture debates have a problem that most other engineering disciplines don't: the alternative was never built.</p>
<p>When a bridge fails, the failure is physical, attributable, and measurable against every other bridge that didn't. The engineering decisions that caused it can be isolated, traced, and corrected — not just in theory, but in the next bridge, because the material itself produces feedback that no amount of professional opinion can override. Steel deflects. Concrete cracks. Physics doesn't care what the architect believed.</p>
<p>Software produces no equivalent feedback. A system built around the wrong abstractions compiles, runs, ships, and passes its tests just as readily as one built around the right ones. A bug introduced by a misaligned domain model looks identical, from the outside, to a bug introduced by a typo. A feature that took three times longer than it should have, because the structure made it harder than the business logic warranted, produces no artifact that distinguishes it from a feature that was simply difficult. The cost is real. The cause is invisible.</p>
<p>This is the unfalsifiability problem, and it runs deeper than "we can't measure everything." It means that when a system becomes expensive to change, the diagnosis almost always lands on the wrong variable. The domain is complex. The requirements changed. The previous team was careless. Almost never: the structure was wrong, and the structure was wrong because nobody ever built the other version of it to compare against. That version doesn't exist, it never will, and every architectural argument in the industry is conducted in its absence.</p>
<p>This would be a purely philosophical problem if there were nothing to do about it. There is something to do about it — but it requires accepting that the standard metric for software quality, whether it works, is measuring the wrong thing entirely.</p>
<hr />
<h2>The Metric That Hides the Problem</h2>
<p>The natural substitute for "is this good engineering" is "does it work," because working is the only property the system demonstrates directly and continuously. A feature either satisfies the requirement or it doesn't. Tests are either green or they aren't. The sprint either shipped or it didn't. These are checkable facts, and in an environment with no physical feedback to measure against, checkable facts become the entire basis for evaluation.</p>
<p>The problem is that working is compatible with almost any underlying structure, good or bad, for a surprisingly long time. A system can satisfy every functional request in its backlog while its essential complexity — the actual rules and relationships of the business — is scattered across service methods, duplicated across layers, encoded differently in three places because three developers made the same inference independently without any structural pressure to put it in one place. None of that appears as a failure. It appears as a working system, until the day a new requirement needs to reach across all three of those places simultaneously, and what should be a small change becomes an investigation into how many versions of the rule actually exist and which ones are authoritative.</p>
<p>By then the cause is undiagnosable. The system is large. The rule has accreted across two years of sprint reviews. The developers who wrote the original three versions are gone. The structure that caused the problem will be attributed to enterprise complexity, changing requirements, accumulated technical debt — anything except the decision, made early and silently, to let working stand in for well-structured.</p>
<p>When behavior is the only metric, behavior is what gets optimized. Frameworks that produce correct behavior fast become the natural answer. Templates that generate working code without requiring structural reasoning become best practice. The question that would actually determine the ten-year cost — what are the mechanics that produce this behavior, and do those mechanics live in a place that makes them findable, correctable, and legible — never gets asked, because asking it requires a comparison that isn't available.</p>
<hr />
<h2>The Mechanics, Not the Behavior</h2>
<p>The substitute for physical falsifiability isn't a better way to measure behavior. It's a deliberate shift in what gets examined — from the surface of the system, what it does, to the structure underneath it, what it is.</p>
<p>Every piece of business behavior is produced by mechanics: relationships between concepts, rules that govern those relationships, responsibilities that belong to one concept and not another. A checkout process is behavior. The mechanics underneath it are what actually need to be represented correctly.</p>
<p>Consider a library lending system. The behavior is simple: a borrower takes an item, returns it later. The mechanic, examined carefully, is that a loan is a distinct event — something that happened between a person and an item, at a point in time, with its own start, end, and history. It is not a property of the item itself. A book does not become a different kind of thing when it is borrowed. The loan is a separate concept with a separate lifecycle. That distinction is not an implementation preference. It is what is actually true about the business — and a system that puts loan dates on the Book entity has encoded a false mechanic, one that will prevent the system from answering basic questions about lending history without structural rework.</p>
<p>The behavior can be implemented many ways. The mechanic is either represented correctly or it isn't, and unlike behavior, a misrepresented mechanic produces a specific, locatable kind of wrongness: the rule exists in the wrong place, the concept carries responsibility it was never supposed to own, the assumption is encoded somewhere it can't be found when it turns out to be wrong.</p>
<p>This is not a theoretical exercise. It is a structuring and learning activity. The goal is not to produce a perfect model before a line of code is written — it is to make the structure of the business explicit enough that building it becomes a matter of finding the right place for each piece, rather than making an independent decision every time about where things should go. A user story is not a specification to implement. It is information about what the business is — a piece of evidence that either fits the current understanding of the mechanics or reveals that the understanding was incomplete. Both outcomes are useful. The first confirms the model. The second improves it.</p>
<p>Once the mechanics are explicit, simplicity becomes achievable in a way it never is when the focus stays on behavior alone. The simplest thing that works is not the simplest thing to type — it is the implementation that puts each responsibility exactly where it belongs, with no duplication, no misdirection, no logic living in a place that requires explanation rather than being found by intuition. That kind of simplicity is legible to a new developer on their first day. It is transferable to a team member who didn't write it. It is correctable when an assumption turns out to be wrong, because the assumption lives in one place that can be questioned directly rather than in a diffuse implication scattered across the system.</p>
<hr />
<h2>Essential and Accidental Complexity</h2>
<p>Fred Brooks named this distinction decades ago, and it has lost none of its precision: essential complexity is the difficulty that comes from what the problem actually is; accidental complexity is the difficulty introduced by the tools and representations chosen to solve it.</p>
<p>Essential complexity cannot be removed without changing the problem. The fact that a loan involves a lender, an item, and a time period — and that these are three separate things with separate lifecycles — is not a decision anyone made about how to model a library system. It is what a loan is. Strip it out and the system no longer represents what the business does. Every piece of structure that reflects this accurately is essential complexity made explicit.</p>
<p>Accidental complexity is everything else: the framework, the deployment topology, the persistence layer, the test harness, the CI pipeline, the service boundaries drawn for organizational rather than domain reasons. None of this exists in the business. All of it exists to make the essential complexity operational. It is supposed to serve the essential complexity, not the other way around. The moment a framework's conventions, a deployment constraint, or an organizational boundary starts dictating what the domain is allowed to look like, accidental complexity has taken the lead, and the essential complexity is being shaped to fit it rather than the reverse.</p>
<p>This inversion is the likely trajectory of most software projects within a few years of starting — unfalsifiability means it can be neither proven nor disproven with certainty, but a perfect structural alignment between code and business mechanics from day one, sustained without a mechanism to detect and correct drift, requires a precision that makes winning the lottery look achievable. It happens quietly because it produces no visible symptom. The system still works. The tests still pass. The sprints still ship. The essential complexity didn't disappear — it was dissolved into the accidental complexity surrounding it, encoded in service method names and transaction annotations, still doing its job but no longer findable as a thing you can look at, question, or change in one place.</p>
<hr />
<h2>The Cogwheels of the Business</h2>
<p>A mechanical watch is a useful image here. The cogwheels of a watch are its essential complexity made physical — the gear ratios, the relationships between wheels, the escapement that governs the release of energy. These cannot be simplified away without the watch losing the ability to tell time. They are what the watch fundamentally is. The casing, the hands, the crystal, the materials — these are accidental complexity: real, necessary, but entirely in service of the mechanism underneath. A watchmaker looking at a malfunctioning watch does not need to guess where the problem is. The mechanism is visible. Each part has a known responsibility, and a part that has the wrong shape for its position will produce a specific, locatable failure rather than a general degradation of the whole. The casing does not malfunction. The mechanism does — and it can be found.</p>
<p>A rich domain model is the cogwheels of the business — and the automation of it. Not data with operations attached, which is what most systems produce, but responsibilities that carry their own data and enforce their own rules. An account is not a row in a table with balance and status fields. An account is something a customer has, that carries a balance, that knows what it means to deposit and withdraw, that enforces the rules governing when a withdrawal is and isn't permitted. Those rules belong on the account — not because of aesthetic preference, but because that is where they would be found by anyone who thought to look for them by asking what an account is. When those rules live on the account, a wrong rule is a local problem, visible and correctable in one place. When those rules live in a <code>WithdrawalService</code> and a <code>BalanceCheckService</code> and a <code>TransactionValidationService</code> distributed across a microservice boundary, a wrong rule is a distributed problem, findable only by someone who already knows which services encode the assumption and can read them all simultaneously.</p>
<p>The practical difference is not academic. On a system where the essential complexity is explicit, a new developer can read the model, understand what the business is, and contribute meaningfully in days — because the structure of the code reflects the structure of the business, and understanding one is understanding the other. On a system where the essential complexity has been dissolved into accidental layers, a new developer learns how to navigate the framework, traces several services to reconstruct a rule nobody wrote down, makes a change, breaks something else that encoded the same rule differently, and starts the cycle again. The business is still there — it is just no longer visible as the thing organizing the code.</p>
<hr />
<h2>Correctability as the Partial Answer</h2>
<p>This is where the unfalsifiability problem becomes something other than a purely philosophical concern.</p>
<p>Unfalsifiability means the alternative was never built, so no direct comparison is available. It does not mean nothing can be done. The absence of a physical feedback mechanism can be partially compensated for by a structural property: if wrong assumptions live in one legible place, they can be corrected when they turn out to be wrong. Correctability does not prove the original model was wrong — it cannot, because unfalsifiability is still in effect. But it prevents the cost of being wrong from compounding indefinitely, which is the actual damage the problem causes. A wrong assumption that gets corrected in year two costs a week. The same wrong assumption that is only discoverable by reading fourteen service methods, in year five, after three team changes, costs months — and the correction itself has to find and fix every place the assumption was encoded, which are no longer guaranteed to be consistent with each other.</p>
<p>When essential complexity is kept explicit, a misaligned model is a correctable one. When it has been dissolved into accidental layers, a misaligned model is a permanent one — not because it cannot technically be corrected, but because the correction cost grows faster than anyone is willing to pay it, and the system calcifies around the wrong assumptions instead. Every new feature gets built on top of them. Every new developer inherits them. The system is not failing. It is just becoming progressively more expensive to change, in a way nobody can trace back to a cause because the cause is structural and the structure is no longer visible.</p>
<p>Focusing only on whether the system works skips this entirely. Frameworks that deliver working behavior without requiring the essential complexity to be made explicit appear to win the short-term comparison — they are easier to hire for, and they produce green tests by the end of the sprint. But the appearance is produced by measuring the wrong thing.</p>
<p>A sprint review that demonstrates two completed screens and a passing test suite looks productive. The question it doesn't answer is what those two screens cost in accidental complexity — in configuration, wiring, DTO mapping, coverage mandates, and refinement sessions spent on how to make the technology work rather than on what the business actually needs. A team working from an explicit model of the essential complexity spends that time differently: on understanding what the business is, on placing each new piece of behavior where it belongs, on confirming or correcting the model. The visible output per sprint may look similar. The structural cost is not.</p>
<p>This is worth qualifying honestly. As long as a codebase is small enough to fit inside the working context of the team maintaining it, the benefit of explicit essential complexity is real but modest — the system is comprehensible regardless, because it is simply small enough to hold in one mind. The benefit scales with the size and longevity of the system. As the application grows, without explicit essential complexity, the effort required just to maintain an overview grows faster than the team can absorb it. The common response is distribution — splitting the system into smaller, independently manageable pieces. But distribution assumes precise knowledge of where the business can be cleanly separated, and that knowledge is exactly what the unfalsifiability problem prevents anyone from having with confidence. The separation points get drawn on organizational lines, on deployment convenience, on what seemed coherent at the time — and every cross-cutting rule the business later needs, every requirement that turns out to span the boundary that was drawn too early, pays the price of an integration problem that a single coherent model would never have generated.</p>
<p>The cost of dissolving the essential complexity into accidental layers does not begin at year three. It begins at the first sprint, accumulates without attribution, and will eventually be diagnosed as enterprise complexity or changing requirements or the inevitable entropy of large systems — not as the consequence of a structural choice made at the beginning, when the essential complexity was allowed to dissolve rather than being required to remain visible.</p>
<p>The cogwheels of the business are either explicit or they are not. If they are, the system can tell you when it is wrong. If they are not, it cannot — and the bill for that silence will arrive, as it always does, long after the people who made the choice have moved on.</p>
<hr />
<h2>So What</h2>
<p>There are two ways to build enterprise software, and the choice between them is made — usually implicitly, usually early — by whoever decides what success looks like.</p>
<p>The first focuses on what the system does. Tools, frameworks, and delivery velocity are the primary concerns. User stories are work orders. The sprint is the unit of value. A working feature at the end of the iteration is the definition of done. This approach has a ceiling that is invisible until it has already been reached: the essential complexity of the business accumulates inside accidental layers, the system ages with its technology choices, new insights are hard to incorporate because nobody can find where the old ones live, and distribution gets introduced to manage the sprawl — which requires precise knowledge of where the business can be cleanly separated, knowledge that by definition is not yet available. The result is software that works for years while becoming progressively more expensive to change, until the cost of a new feature is indistinguishable from the cost of a small project, and the organisation starts talking about a rewrite.</p>
<p>The second focuses on what the system is. The essential complexity of the business — the actual rules, relationships, and responsibilities — is made explicit, kept in one place, and treated as the primary asset. Technology serves it. Frameworks are evaluated against it. New requirements are treated as information about it. When an assumption turns out to be wrong, it can be found and corrected, because it was never allowed to dissolve into the layers surrounding it. This approach does not age with its technology choices, because the technology was never load-bearing. It does not require distribution to stay manageable, because the model provides the coherence that distribution was trying to restore. New developers find the system comprehensible because the structure of the code reflects the structure of the business, and understanding one is understanding the other.</p>
<p>The first approach feels more immediately productive because it generates visible artefacts — configured services, passing tests, completed screens — while deferring the understanding that would have made those artefacts cheaper to produce. The second does the understanding first, which means the first sprint looks quieter and delivers more.</p>
<p>The cost difference between the two, accumulated over a decade of a business-critical application, is not marginal. It is the kind of number that makes a CFO ask why nobody mentioned this was a choice. The real jewels of enterprise software are not in the framework versions, the language features, or the architectural patterns that fill conference schedules every year. They are in language basics that have not changed in thirty years — an object that knows what it is, owns what belongs to it, and enforces its own rules. Everything else is the casing. The mechanism is what keeps the time.</p>
<hr />
<p><em>This article is part of a series on software engineering craft. Other pieces examine the rich domain model as a discipline, the properties of enterprise software that lasts, why frameworks answer structural questions before the domain gets a chance to, and why the practices that prevent structural decay are the same ones that always prevented it.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Software Bug AI Can't Find]]></title><description><![CDATA[Coding is not the hard part.
Any reasonably skilled developer can implement a described requirement. Given a clear specification, the implementation follows. That has always been true, and AI has made]]></description><link>https://blog.leonpennings.com/the-software-bug-ai-can-t-find</link><guid isPermaLink="true">https://blog.leonpennings.com/the-software-bug-ai-can-t-find</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[AI]]></category><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Fri, 26 Jun 2026 20:39:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/ed70bd85-028d-4cf5-8f72-94029eb2472d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Coding is not the hard part.</p>
<p>Any reasonably skilled developer can implement a described requirement. Given a clear specification, the implementation follows. That has always been true, and AI has made it more true — the mechanical work of turning a description into working code is now faster and cheaper than it has ever been.</p>
<p>The hard part is something different. It is thinking of everything that can go wrong. The data combination nobody anticipated. The sequence of operations that seemed impossible until a user found it. The edge case that only appears when two legitimate business scenarios collide in a way nobody modeled. These are not coding failures. They are imagination failures — and no developer, no team, no AI has ever been immune to them.</p>
<p>The industry learned this lesson once before. Waterfall's central assumption was that requirements could be fully specified before building began — that if you thought hard enough upfront, you could anticipate everything. It couldn't be done. The act of building revealed what nobody knew before building started. Scenarios emerged from real usage that no specification session had surfaced. The industry eventually accepted this and moved on.</p>
<p>The same assumption lives inside distributed architectures, one level down. You cannot anticipate every failure mode before the system meets real data. The question is not how to eliminate that gap — you cannot. The question is: when reality finds the gap, how fast does the system tell you?</p>
<p>There are two possible answers. The system fails loudly — the operation stops, nothing partial is committed, the error is visible, the developer finds it, it gets fixed. Or the system fails silently — the operation appears to succeed, something partial is committed somewhere, the inconsistency enters the data, and nobody knows.</p>
<p>Loud failure is not a side effect of good architecture. It is a feature — the mechanism by which a system corrects its own gaps as reality reveals them. It needs to be deliberately designed in. And a surprising number of the technology choices the industry has normalised over the last decade quietly design it out.</p>
<p>Everything that follows is a consequence of that distinction.</p>
<hr />
<h2>The Transaction Is Not a Technical Detail</h2>
<p>A single atomic transaction is the simplest possible implementation of loud failure.</p>
<p>Something unexpected happens. The transaction fails. Everything inside the consistency boundary rolls back — the order wasn't created, the inventory wasn't reduced, the invoice wasn't generated. The state before the operation is restored exactly. The user sees an error. A developer looks at the error. They find the unconsidered scenario. They fix it. The feedback loop is hours, not months. The system's integrity was never compromised — just its availability, temporarily, for one specific operation.</p>
<p>That is not a bug. That is the system working correctly under unexpected conditions — surfacing a gap in understanding at the cheapest possible moment, before anything was lost and before the inconsistency had a chance to compound.</p>
<p>This is why technology choices for enterprise applications are not preferences. They are engineering decisions with structural consequences. A relational database brings three decades of battle-tested infrastructure for loud failure: non-nullable constraints, unique constraints, foreign key constraints, check constraints. These are not convenience features. They are a validation layer that lives closer to the data than any application code ever will, enforced regardless of which service forgot to set a field, regardless of which event handler failed to fire. The database simply refuses. Loudly. Immediately.</p>
<p>Choosing to move away from a relational database is a legitimate engineering decision in specific circumstances. But it is not a neutral one. Every constraint the database was enforcing either moves into the application — where it is less reliable, harder to find, and maintained by people who may not know why it exists — or it disappears entirely, replaced by the hope that nobody will generate the data combination it was preventing. The validation does not vanish. It relocates, or it becomes invisible. Both outcomes are a step toward silent failure.</p>
<p>Choosing a technology because it is popular, because a large company published a paper about it, because it appeared at a conference — without asking what properties it provides and what properties it removes — is not engineering. It is fashion. And in enterprise software, fashion has structural consequences that surface years later in production data nobody can explain.</p>
<hr />
<h2>What Happens When You Distribute</h2>
<p>Now take the same unexpected scenario and run it through a distributed system.</p>
<p>Service A processes its part and commits. An event fires. Service B receives it and fails — not because of bad code, but because this specific combination of data was never anticipated. Compensation logic can recover consistency, but only for the scenarios it was written to handle. Nobody wrote compensation logic for this combination, because nobody anticipated it. Service A has committed. Service B has not. The state is now inconsistent, and recovery now depends on logic whose correctness must itself be proven — for a scenario that, by definition, nobody saw coming.</p>
<p>The user may not even see an error. The system appears to have worked.</p>
<p>The inconsistency is now in production. Downstream services are making decisions based on it. Reports are being generated from it. Other operations are building on top of it. And nobody knows, because the system did not fail — it partially succeeded, which is the failure mode that distributed architectures are structurally unable to surface cleanly.</p>
<p>Eighteen months later, someone notices the numbers don't add up. Or a customer calls about an order that shows as delivered but was never shipped. Or an audit finds financial records that contradict each other. The forensic work to trace that back to its origin — through eighteen months of events, across service boundaries, through compensation logic written by someone who left a year ago — is enormous. The fix is not a code change. It is a data integrity project, with permanent uncertainty about what the correct state actually was.</p>
<p>The distributed system did not prevent the bug. It prevented the bug from being visible. Which is the worst possible trade — because loud failure is the mechanism the system uses to learn. Remove it and the system stops teaching. It just accumulates.</p>
<hr />
<h2>The Part the "It Works" Argument Misses</h2>
<p>Here is where the reasonable objection comes in. Many distributed systems do work. Microservices applications run in production for years without the failure mode described above ever materialising. If yours is one of them, the argument so far probably seems theoretical.</p>
<p>It is not theoretical. It is probabilistic — and the probability scales directly with the thing you most want to scale.</p>
<p>Small application. Bounded domain. Limited entities, limited relationships, limited users, limited lifespan. The space of possible data combinations is small. The unconsidered scenario may simply never arrive before the system is retired. "It works" is genuinely true, start to finish. There is nothing to argue with.</p>
<p>Now scale the application. More entities. More relationships. More users generating more combinations over more years. The space of possible data combinations grows faster than the team grows. The probability of hitting an unconsidered scenario does not stay constant — it compounds. At sufficient scale, over sufficient time, it stops being a risk and becomes a mathematical certainty.</p>
<p>Which means the architectural choice that feels safe for a small system becomes a liability that scales directly with the size and longevity of the application. The system the organisation most wants to protect — the large, long-running, business-critical application — is exactly the system where silent failure becomes a certainty rather than a possibility.</p>
<p>The developer who says "so what, it works" is describing a small system. They are right. They just don't realise that is what they are describing.</p>
<hr />
<h2>AI Accelerates the Accumulation</h2>
<p>This is where the current moment makes the stakes undeniable.</p>
<p>AI has the same imagination failure every human developer has. It implements what it was asked to implement. It does not anticipate the data combination that wasn't in the prompt. It does not model the collision between two legitimate business scenarios nobody thought to describe. And it generates code at a velocity that outpaces the domain understanding feeding it — accumulating unconsidered scenarios faster than any human team ever could.</p>
<p>There is a subtler problem underneath that one. Writing code for a complex domain is not just implementation. It is how understanding develops. When a requirement does not fit cleanly, when the same logic appears in three places, when a method grows in ways that resist being read — that resistance is signal. The domain is surfacing a gap. The friction is the feedback loop by which an engineer's understanding deepens and the model gets corrected. AI used as an implementer absorbs that resistance. The code gets written. The discomfort never arrives. The lesson was in the discomfort.</p>
<p>This is not a new failure mode. It is the continuation of a trend the industry has been on since framework-dictated development became the norm — where pre-packaged architectural recipes replaced structural reasoning, and engineers learned to fill in templates rather than interrogate structure. AI-as-implementer is the same dynamic, one abstraction level higher, running faster. The gap between working software and understood software was already widening before AI arrived. AI inherited that gap and accelerated it.</p>
<p>In a system with a coherent consistency boundary, this matters less at the architectural level. The unconsidered scenario still fails loudly — AI-generated or not. The transaction fails, the error surfaces, the gap is found and fixed. The system remains self-correcting even when the engineer's understanding was incomplete.</p>
<p>In a distributed system built at AI velocity, the unconsidered scenario fails silently — at a rate no previous generation of development ever achieved. The events queue. The inconsistencies compound. The data drifts. And the diagnosis, when it finally comes, will be what it always was: the domain was complex, the requirements changed, the previous developers were careless.</p>
<p>Not: we built at a speed that outran our understanding, into an architecture that was designed to hide what we didn't know.</p>
<hr />
<h2>Why the Industry Got Here</h2>
<p>Nobody chose this deliberately. That is worth saying plainly before any diagnosis.</p>
<p>Public software discourse is necessarily shaped by practices that can be taught, repeated, and verified at scale. The patterns that dominate conference talks, blog posts, job descriptions, and interview questions are the ones legible enough to transfer reliably between practitioners — not necessarily the ones that produce systems which remain coherent over a decade. That is not a criticism of the people involved. It is how knowledge disseminates in any field where the most consequential outcomes take years to become visible.</p>
<p>And because every system is built once — the alternative approach is never built alongside it, so the cost of the wrong choice is never directly observable — the field cannot easily learn from its own experience. When a system develops data integrity problems, the cause gets attributed to domain complexity or changing requirements. Almost never does anyone conclude that the architecture was the variable, because there is no control group to compare it to. The unfalsifiability problem keeps the signal from reaching the people who most need it.</p>
<p>Microservices, event-driven architecture, NoSQL databases — each originated as a genuine response to a genuine problem at genuine scale. Each got adopted as a default by teams who never encountered the scale problem the pattern was designed to solve, chosen not for their engineering properties but for their cultural visibility. And each, in its own way, does the same thing: it relocates the signal.</p>
<p>The code stops complaining. The architecture absorbs the contradiction without surfacing it. The problem does not go away — it moves to the production data, two years out, in a form that is harder to find, harder to trace, and harder to fix than the loud failure it replaced.</p>
<p>They are not solutions to the underlying problem. They are ways of making the underlying problem less observable. Which, at sufficient scale, is worse.</p>
<hr />
<h2>Engineering for Properties, Not Popularity</h2>
<p>The correction is not a methodology. It cannot be certified. It is a discipline of asking a question that the industry has largely stopped asking: what properties does this technology choice provide, and what properties does it remove?</p>
<p>A relational database provides transactional consistency, referential integrity, and constraint enforcement as structural guarantees — not as features to be implemented, but as properties of the system that exist regardless of what any individual piece of code does. Removing it in favour of a document store or a distributed data layer removes those guarantees. They do not vanish. They become engineering problems to be solved in the application, maintained indefinitely, by teams who may not fully understand why they are there.</p>
<p>A single deployable unit with a coherent consistency boundary provides loud failure for free. Splitting that unit across services and event queues removes it. Sagas and compensation logic can partially recover it — for the scenarios that were anticipated. For the ones that weren't, which is the only class of scenario this article has been about, recovery depends on logic that by definition could not have been written yet.</p>
<p>These are not preferences. They are structural choices with structural consequences, most of which arrive too late to be traced back to the decision that produced them.</p>
<p>The organisation that chooses its database because a large tech company uses it, its architecture because it was the subject of last year's conference circuit, its framework because it is what the available developers already know — is not making engineering decisions. It is making fashion decisions and calling them engineering. The difference between the two is not visible on the day the choice is made. It is visible in the production data, two years later, when the unconsidered scenario finally arrived and the system had no mechanism to surface it.</p>
<p>A coherent contextual center — a domain model that keeps what belongs together in one place, behind a single consistency boundary, enforced by the database that was built to enforce such things — is the structural embodiment of the principle this article has been arguing for. Not because it is elegant. Because it keeps failure loud, keeps the feedback loop intact, and keeps the system capable of correcting itself as reality reveals what nobody knew upfront.</p>
<p>Which it will. It always does.</p>
<hr />
<h2>What to Do With This</h2>
<p>If you are building a small application with a bounded scope and a limited lifespan, the considerations above matter less. The unconsidered scenario may never arrive. The combination space never gets large enough. Disposable software benefits from disposable development, and the current generation of tools makes disposable development faster than it has ever been.</p>
<p>If you are building something large, long-running, and business-critical — the kind of system that needs to remain correct through changing requirements and changing teams over years — the question worth asking before framework selection, before architecture diagrams, before any technology choice is made, is this: what is the failure mode of this decision, and when will it announce itself?</p>
<p>Choose technologies for what they provide, not for what they are associated with. Prefer the ones that keep failure loud and immediate over the ones that keep it quiet and deferred. Accept that no amount of upfront specification eliminates the unconsidered scenario — and build systems that surface it fast, correct it cheaply, and carry the correction forward in a form that survives the next team.</p>
<p>The loudest signal that this discipline is absent will not come from the code. It will come from the data, later than you expect, in a form that is harder to explain than a bug and more expensive to fix than a refactor.</p>
<p>The best time to build for loud failure was at the start of the project. The second best time is before the data starts lying to you.</p>
<hr />
<p><em>This article is part of a series on software engineering craft. Other pieces examine the rich domain model as a discipline, the properties of enterprise software that lasts, and why the practices that prevent structural decay are the same ones that always prevented it.</em></p>
]]></content:encoded></item><item><title><![CDATA[SOLID Principles: Forks to Eat Soup]]></title><description><![CDATA[The Problem With Architecture Debates
Most software architecture debates can't actually be settled. Every system is built once. The alternative approach — the one that wasn't chosen — is never built a]]></description><link>https://blog.leonpennings.com/solid-principles-forks-to-eat-soup</link><guid isPermaLink="true">https://blog.leonpennings.com/solid-principles-forks-to-eat-soup</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[SOLID principles]]></category><category><![CDATA[Java]]></category><category><![CDATA[Object Oriented Programming]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Tue, 23 Jun 2026 07:45:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/8456c2ac-f835-4f61-aa4d-5b161aba4932.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Problem With Architecture Debates</h2>
<p>Most software architecture debates can't actually be settled. Every system is built once. The alternative approach — the one that wasn't chosen — is never built alongside it, under the same conditions, with the same team, against the same market. So when a system works, "it works" gets quietly promoted to "the approach was right," and when a system rots, the rot gets blamed on the domain being inherently complex, or the requirements changing too much, or the previous developers having been careless. Almost never does anyone conclude that the architecture itself was the variable that mattered, because there is no control group to compare it to.</p>
<p>This is the unfalsifiability problem, and it is the reason architecture discussions tend to be so unproductive. Everyone is generalizing from an n of one, or a handful of isolated ones, with team skill, domain difficulty, and plain luck as uncontrolled variables throughout. Two competent engineers can each have ten years of experience, complete confidence in their conclusions, and have learned nothing transferable to each other, because neither has ever seen their belief tested against an alternative.</p>
<p>It is also the reason a particular class of mistake can persist for decades, spread through teams and codebases, get taught in courses and validated in job interviews, and still never be clearly identified as a mistake. The code ships. The system works well enough. The costs are real but diffuse — spread across maintenance cycles, onboarding friction, debugging sessions that take longer than they should. Nobody writes a post-mortem that says "we had too many interfaces." They write one that says "the codebase had become difficult to change," and then someone suggests that what happens is the nature of enterprise software.</p>
<p>It is easy to use the wrong tool for the job. The wrong tool could even lead to a worse outcome than the problem it was intended to solve. SOLID principles are design principles for an object-oriented domain model. Applying them outside one is every bit as effective as eating soup with a fork.</p>
<hr />
<h2>Forks to Eat Soup</h2>
<p>Nobody uses a fork to eat soup. You could. With enough patience and a sufficiently shallow bowl, you would eventually get most of it. If you had only ever eaten soup with a fork, you might genuinely believe that forks are general-purpose eating tools, and that the difficulty of soup simply reflects the nature of liquids rather than the inadequacy of the instrument.</p>
<p>SOLID principles have a specific, valid purpose: structuring a rich object-oriented domain model. Applied there, they produce code that is coherent, maintainable, and expressive. Applied elsewhere — to procedural code, to technical infrastructure, to layered frameworks with no meaningful domain model underneath — they tend to produce indirection without purpose. The complexity that follows gets blamed on the problem domain. The instrument is rarely questioned.</p>
<p>When the domain modelling step is skipped, or reduced to a handful of JPA-annotated data transfer objects that carry field values but no behavior, the problems start. What remains is procedural code and technical plumbing. SOLID principles, taught as universal good practice in object-oriented application, get applied to the codebase that remains — the one where the domain model should have been. The result is the software equivalent of eating soup with a fork: technically possible, enormously effortful, and solving a problem that the right tool would not have created.</p>
<p>To understand why, it helps to look at what each principle actually means — and what it tends to mean in practice instead.</p>
<hr />
<h2>Single Responsibility Principle</h2>
<p><em>A class should have only one reason to change.</em></p>
<p>The intent is to prevent fat objects — objects that accumulate unrelated responsibilities until they become incomprehensible. A <code>Customer</code> object should be responsible for what a customer <em>is and does</em> in the domain. It should not also be responsible for rendering HTML, managing database transactions, and sending emails. Those are different concerns, and mixing them means changes to any one of them risk breaking all the others.</p>
<p>At the modelling level this is a sound and useful principle. The difficulty arises when it gets applied at the code level, where it tends to appear in two distinct patterns.</p>
<h3>Pattern 1: Technology Layering as Responsibility Separation</h3>
<p>A common pattern treats technology boundaries as responsibility boundaries. A <code>Customer</code> domain object gets split into <code>CustomerDTO</code>, <code>CustomerRepository</code>, <code>CustomerService</code>, and <code>CustomerController</code> — one object per architectural layer. The reasoning tends to follow SRP vocabulary: "the domain object shouldn't know about persistence," "the service layer shouldn't know about HTTP."</p>
<p>What this produces, though, is not responsibility separation. It is technology separation. The responsibility of understanding what a customer is and does in the domain has not been clarified — it has been distributed across four classes that must all be read together to recover the meaning that one coherent object would have expressed directly.</p>
<p>The cost is not visible at the time of writing. The developer who built it holds all the context in their head. The cost materializes six months later, when something needs changing, or when a new developer joins the team.</p>
<p>Context is what keeps software maintainable. Distributing it across layers replaces semantic meaning with structural convention. The cognitive load of every subsequent change increases, because understanding what the code does requires mentally reassembling the essential logic from across an accidental structure.</p>
<p>Technology boundaries are not responsibility boundaries. A domain object can contain a database query if that is what its responsibility requires. It can make an HTTP call. Consider <code>email.send()</code> — not a static utility method, but a method on an existing email instance, because sending is part of what an email <em>is</em>. The SMTP detail is essential complexity: it exists because reality requires it. The <code>EmailService</code>, <code>IEmailSender</code>, <code>SmtpEmailSender</code> stack that replaces it is accidental complexity: it exists because architectural doctrine requires it. The Email concept is the only place in the entire application where sending belongs, and it is exactly where a reader would expect to find it.</p>
<h3>Pattern 2: Dual Responsibility Objects in DDD Contexts</h3>
<p>Domain-Driven Design introduces bounded contexts — the idea that the same real-world entity may appear differently in different parts of the system. A customer in a shipping context has different relevant attributes and behaviors than a customer in a billing context. This is correct and useful modelling.</p>
<p>One reading of this is to create multiple <code>Customer</code> variants — a <code>ShippingCustomer</code>, a <code>BillingCustomer</code> — each carrying a subset of customer responsibility plus their context-specific concerns mixed together. The intent is usually good: keep each object focused, avoid a single bloated <code>Customer</code> that knows too much. But what tends to happen is that customer responsibility ends up duplicated and fragmented across multiple objects, none of which is a coherent model of what a customer actually is. The object has not been given a single responsibility — it has been given two, then renamed to disguise the second one.</p>
<p>A more faithful reading is composition rather than decomposition. <code>Customer</code> retains its single responsibility: being a customer. Shipping-specific behavior belongs in a separate object that <em>has</em> a Customer as an attribute and adds its own behavior alongside it — not a Customer subtype, but an add-on. Consider the difference in how objects are constructed:</p>
<pre><code class="language-plaintext">Order order = new Order(customer, shoppingCart);
Payment payment = new Payment(invoice);
Email email = new Email(subject, content);
email.send();
</code></pre>
<p>Each object owns exactly what its concept requires. <code>Customer</code> does not know about <code>Order</code>. <code>Order</code> knows about <code>Customer</code> because an order belongs to someone — that relationship is explicit in the construction, not smuggled in through inheritance or layer-crossing services. The domain model is the spoon. The principles apply cleanly when there is something to apply them to.</p>
<p>What bounded contexts produce, when modelled this way, is not multiple versions of the same object — it is context-specific objects that <em>use</em> domain objects without absorbing them. The core object stays coherent because it is never asked to be something it isn't.</p>
<hr />
<h2>Open/Closed Principle</h2>
<p><em>Software entities should be open for extension but closed for modification.</em></p>
<p>The principle addresses inheritance. A well-designed superclass establishes invariants and behavior that subclasses can extend — adding new behavior — without modifying what the superclass already guarantees. The superclass contract remains stable. Subclasses enrich it.</p>
<p>The classic illustration of where this gets complicated is the circle and ellipse problem. Should <code>Circle</code> extend <code>Ellipse</code>, or <code>Ellipse</code> extend <code>Circle</code>? The intuition that one is a special case of the other seems to invite inheritance. But the behavioral contracts pull in different directions: a circle maintains the invariant that all radii are equal, which an ellipse cannot guarantee. Inheriting one from the other tends to force the subclass to either suppress an inherited invariant or override inherited behavior — which is precisely the situation OCP is designed to avoid. The more natural model is that both are shapes. What they share belongs in a common abstraction. What makes them distinct belongs in separate implementations.</p>
<p>A constructor exists to bring an object to a correct, fully initialized state — to establish its invariants before anything else acts on it. A subclass that introduces behavior dependent on superclass state before that initialization is complete is extending something that does not yet fully exist. When this pattern becomes necessary, it is often worth asking whether the inheritance relationship itself is the right one. The language permitting something does not make it structurally sound.</p>
<hr />
<h2>Liskov Substitution Principle</h2>
<p><em>Objects of a subclass should be substitutable for objects of the superclass without altering the correctness of the program.</em></p>
<p>Liskov Substitution follows naturally from Open/Closed. If a subclass only extends the superclass — adding behavior without overriding or suppressing it — then substitutability tends to follow. The situations where LSP breaks down are usually the same situations where OCP has already broken down: a subclass that modifies rather than extends.</p>
<p>The substitutability question is not about whether two things look similar in some states. An empty bank account and a non-existent bank account may show the same balance. They are not substitutable — one has an owner, a history, a legal existence, and obligations that the other does not. A broken watch displays the correct time twice each day, by coincidence rather than function. Apparent equivalence in certain states is not a subtype relationship. The contract needs to hold across all states and all behaviors, not just the ones that happen to align at a given moment.</p>
<p>The practical guide to avoiding these violations is simpler than it might appear. Inheritance is the right relationship when something genuinely <em>is a</em> more specific version of something else — when the full behavioral contract of the parent holds for the child without suppression or override. Composition is the right relationship when something <em>has a</em> reference to something else and adds its own behavior alongside it. The domain tells you which one applies.</p>
<p>A useful test is to say the relationship out loud. "A ShippingCustomer <em>is a</em> Customer" — but is it? A ShippingCustomer is not a more specific kind of Customer. It is shipping-related information that belongs <em>to</em> a Customer. Saying it out loud already suggests the right structure: it <em>has a</em> Customer, it is not one. Inheritance chosen for convenience rather than conceptual accuracy is where both OCP and LSP tend to break down — not because the principles are hard to understand, but because the modelling question was never asked in the first place.</p>
<hr />
<h2>Interface Segregation Principle</h2>
<p><em>Clients should not be forced to depend on interfaces they do not use.</em></p>
<p>ISP is to interfaces what SRP is to objects. A fat interface — one that bundles more methods than any single client will ever use — puts implementors in an awkward position. Every class that implements it must account for methods it has no use for, either by leaving them empty, throwing exceptions, or providing stub implementations that do nothing useful. The interface has become a convenience bundle rather than a coherent behavioral contract.</p>
<p>The Java <code>Servlet</code> interface is a well-known example of this. It defines methods for handling every HTTP verb, managing initialization and destruction, accessing configuration, and more. A developer implementing a simple endpoint must engage with the full surface of this interface regardless of how little of it they need. The interface grew to represent everything that might ever be useful to something servlet-like, rather than any single coherent concept. When an interface has accumulated that many methods, it is usually worth asking whether it is modelling one thing or several — and whether splitting it into focused contracts might serve implementors better.</p>
<hr />
<h2>Dependency Inversion Principle</h2>
<p><em>High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details.</em></p>
<p>DIP is arguably the most consequential of the five principles in terms of how it has shaped contemporary codebases — and also the one where the distance between the original intent and common practice is widest.</p>
<p>The principle addresses conceptual dependencies. In a rich domain model, a high-level concept should not be coupled to the concrete technical mechanism that implements a lower-level concern. The dependency should point toward an abstraction that is meaningful in the domain — one that a domain expert would recognize — rather than toward a specific technical artifact.</p>
<p>This is a modelling principle. It says that domain concepts should be defined in terms of what they need behaviorally, not in terms of the specific technology that happens to fulfill that need today.</p>
<p>In contemporary practice, DIP is frequently invoked to justify dependency injection — the pattern of passing dependencies into a class from the outside rather than having the class create them directly. DI frameworks have made this the architectural default: everything should be injectable, which means everything needs an interface, which means interface creation becomes a routine act disconnected from any modelling decision.</p>
<p>The reasoning tends to be circular. DI is presented as an application of DIP. But the abstraction that DI creates — a production datasource and a mock datasource sitting behind a common interface — is not necessarily a domain concept. It may exist primarily because the test harness requires something swappable, and the production codebase has been structured to accommodate that requirement.</p>
<p>This points to a tension that is worth naming directly: a codebase optimized for production and a codebase optimized for testing tend to pull in different directions. A production-optimized codebase is direct and expressive. The model is clear. The surface area of failure is small. Fewer tests are needed because the code is understandable and the concepts are coherent. A test-optimized codebase introduces interfaces, injection seams, and mock implementations. The production behavior becomes something inferred through a scaffolding of substitutes. More tests tend to be needed partly because the indirection introduced for testing creates new failure modes that themselves require coverage.</p>
<p>There is a certain irony in this: a test-optimized codebase can end up requiring more tests to manage the complexity that was introduced in order to make testing easier.</p>
<p>The analogy to aspect-oriented programming is worth considering here. AOP was introduced as a way to separate concerns. The practical difficulty was that behavior was happening in the codebase that could not be found by reading the code. Large dependency injection containers can have the same quality. The actual wiring of the application is not directly readable — it is inferred from annotations and container configuration at runtime. Debugging means navigating proxy layers and generated code rather than following what is explicitly written. The indirection that was meant to simplify things for the original author can make things considerably harder for everyone who comes after.</p>
<p>The question DIP is actually asking at the modelling level is different: does this dependency reflect a real conceptual relationship, or is it an artifact of how the code happens to be built? If a domain concept depends on a behavioral abstraction that belongs in the domain — something a domain expert would name and recognize — then DIP is doing its intended work. If the abstraction exists only because a framework requires it, or because a test needs something swappable, then DIP's vocabulary is being borrowed to justify an infrastructure decision. The principle has not been applied. It has been appropriated.</p>
<hr />
<h2>What The Principles Actually Share</h2>
<p>Looking across all five, the same underlying concern appears in each one: conceptual integrity at the model level.</p>
<ul>
<li><p><strong>SRP</strong>: one coherent concept per object</p>
</li>
<li><p><strong>OCP</strong>: inheritance respects the concept's contract</p>
</li>
<li><p><strong>LSP</strong>: substitutability follows from genuine subtype relationships</p>
</li>
<li><p><strong>ISP</strong>: interfaces reflect actual behavioral contracts, not convenience bundles</p>
</li>
<li><p><strong>DIP</strong>: dependencies follow conceptual relationships, not technical ones</p>
</li>
</ul>
<p>When these principles are applied to a rich domain model, they reinforce each other. When they are applied to layers, frameworks, and testing infrastructure — in the absence of a model — they tend to produce fragmentation instead of coherence, indirection instead of clarity, and more complexity in the name of managing complexity.</p>
<p>The principles were designed for a specific activity: building a rich object-oriented model of a domain. When that activity is skipped — when the model is reduced to annotated data containers and the real work happens in procedural service classes — the principles have no natural target. What gets built in their name may look structured, but the structure serves the principles rather than the domain. The soup gets eaten. It just takes considerably longer than it should.</p>
<hr />
<h2>The Practical Case for Getting This Right</h2>
<p>A rich domain model and the principles that support it can look like a theoretical exercise — an indulgence for architects with time to spare, impractical against real deadlines. This perception is worth examining because the costs and benefits are often misunderstood.</p>
<p>A domain model does not require weeks of upfront design. Even a first pass — an hour or two of thinking through what the concepts are, what they own, what they do — produces something to code from. Not instructions to implement, but domain knowledge to express. That difference in orientation matters more than it might sound. Procedural development requires scaffolding: repositories to call, services to extend, state to pass between layers, behavior to locate across a structure that exists for technical rather than conceptual reasons. A domain model reduces that scaffolding substantially. The concepts know what they are. The behavior lives where it belongs. Adding a capability often means adding a method to an existing object rather than extending a service, adding a repository call, and wiring the result through a chain of layers.</p>
<p>The total amount of code a rich domain model requires is less — often considerably less — than its procedural equivalent. This is not a long-term payoff that arrives after years of maintenance. It is present from the beginning, in the clarity of direction the model provides and the scaffolding it makes unnecessary.</p>
<p>The maintenance advantage compounds from there. Procedural systems with fat services and an anemic model distribute context across layers. Each change requires reassembling that context. Each new developer inherits a cognitive load that grows with the codebase. The tests added to manage the complexity require maintenance of their own. A rich domain model stays navigable because the concepts remain coherent and the code remains an expression of them.</p>
<p>The other thing that happens with practice is harder to quantify but worth naming. Thinking in responsibilities — asking what a concept <em>is</em>, what it <em>does</em>, what it should <em>know</em> — becomes second nature. The modelling step stops feeling like overhead and starts feeling like the work itself. Code becomes an expression of that model rather than a template-filling exercise. The principles stop being rules to apply and start being observations about whether the model is coherent.</p>
<p>That is what SOLID principles are for. Not for services, not for layers, not for test infrastructure. For the model. And a good model, it turns out, is not a theoretical luxury. It is the most practical thing a codebase can have.</p>
<p>Forks are excellent tools. So are SOLID principles. The question worth asking is not whether you are applying them, but whether you are applying them to something they were designed for.</p>
]]></content:encoded></item><item><title><![CDATA[What is the reason for using a rich domain model in the age of AI?]]></title><description><![CDATA[Most software architecture debates can't actually be settled. Every system is built once. The alternative approach — the one that wasn't chosen — is never built alongside it, under the same conditions]]></description><link>https://blog.leonpennings.com/what-is-the-reason-for-using-a-rich-domain-model-in-the-age-of-ai</link><guid isPermaLink="true">https://blog.leonpennings.com/what-is-the-reason-for-using-a-rich-domain-model-in-the-age-of-ai</guid><category><![CDATA[software development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[AI]]></category><category><![CDATA[Java]]></category><category><![CDATA[Software Engineering]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Fri, 19 Jun 2026 11:37:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/824f1942-d130-43db-a5b7-075bfebae0aa.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most software architecture debates can't actually be settled. Every system is built once. The alternative approach — the one that wasn't chosen — is never built alongside it, under the same conditions, with the same team, against the same market. So when a system works, "it works" gets quietly promoted to "the approach was right," and when a system rots, the rot gets blamed on the domain being inherently complex, or the requirements changing too much, or the previous developers having been careless. Almost never does anyone conclude that the architecture itself was the variable that mattered, because there is no control group to compare it to.</p>
<p>This is the unfalsifiability problem, and it is the reason architecture discussions tend to be so unproductive. Everyone is generalizing from an n of one, or a handful of isolated ones, with team skill, domain difficulty, and plain luck as uncontrolled variables throughout. Two competent engineers can each have ten years of experience, complete confidence in their conclusions, and have learned nothing transferable to each other, because neither has ever seen their belief tested against an alternative.</p>
<p>If we can't run the controlled experiment, we need a substitute. Fred Brooks gave us most of one, decades ago, by separating essential complexity — the difficulty that comes from what the problem actually is — from accidental complexity, the difficulty we introduce ourselves through our tools, our process, our representations. Brooks' point was that a lot of suffering in software is self-inflicted, layered on top of a problem that wasn't that hard to begin with.</p>
<p>What Brooks didn't give us is an operational test — a question you can ask in the middle of an actual design decision to tell which kind of complexity you're looking at. That's the test this article is trying to supply: was this decision forced by a genuine, current understanding of the domain, or was it forced by a constraint that existed before that understanding did? Essential complexity should always be the thing leading. Accidental complexity should always be downstream of it, serving it. The moment that order inverts — the moment a technology choice, a deployment topology, or a process gate starts dictating what the domain is allowed to look like — you have accidental complexity in charge, and the system will eventually make you pay for it.</p>
<p>This problem is more urgent now than it has been at any point before, for a reason this article will come back to at the end: AI has made implementation — the actual writing of code — nearly free. Free implementation removes exactly the kind of friction that used to nudge developers toward a correct model almost by accident, whether or not they could ever have named what they were doing. What's left, once that friction is gone, is only the question of whether anyone is still asking it on purpose.</p>
<h2>A tool, and what it takes for the tool to work</h2>
<p>A rich domain model is, I'll argue, a tool — not a style preference, not an aesthetic about classes versus functions, but a tool built for three specific jobs. It's how you learn what a domain actually is, since the act of trying to give a concept a clean shape is what exposes whether you understood it in the first place. It's how you define a domain precisely enough that "what to build" stops being a matter of taste or memory. And it's how you document a domain in a form that has to keep working — unlike a diagram or a wiki page, which can drift quietly out of date for years with nobody noticing, a domain model that's wrong tends to say so. It is essential complexity made tangible — something you can actually point at — and testable — something that tells you when it's wrong, rather than something you have to take on faith.</p>
<p>That's the claim the rest of this article is going to spend its length defending. It comes with a condition attached, because a tool only does its job under specific circumstances, and most of the software industry's familiar habits — fat service layers, splitting early into bounded contexts or microservices, treating a new user story as a work order instead of as evidence — violate that condition constantly, usually without anyone noticing they've done it.</p>
<p>The condition has three parts. Essential complexity has to stay whole — in one place, reachable by one mind at a time, not dispersed across two hundred services inside a single codebase, and not dispersed across a boundary drawn between teams or deployments. The model has to give feedback when your understanding of it turns out to be incomplete — a behavior with no natural home, a compile error at every site that assumed an old shape, a constraint violated at the exact moment an assumption proves wrong. And folding new insight into the model, once you have it, has to stay cheap — paid once, in one place, rather than hunted for across however many places happened to encode the old understanding. Lose any one of these three and the tool stops being a tool. The code may still run. There may even be a model on a slide somewhere. But the thing that was supposed to be doing this work isn't doing it anymore — only its appearance survives.</p>
<p>Take the first condition first, because it's the one most quietly violated, usually with the best of intentions. When essential complexity lives inside one core model, you can look at the model and see the business. When it doesn't — when it's spread across fat services, buried in repositories, scattered across distributed components, or split along departmental lines that felt obvious at the time — the legibility is the first casualty, and with it goes the ability to even ask the question this article opened with: is the application serving the domain, or has the domain quietly started serving the accidental complexity that was supposed to be in service of it? Once essential complexity stops having one visible, coherent home, that observation can no longer be made by anyone, because there's no longer a single place left to look. What follows is, in effect, an extended demonstration of what it costs to lose each of these three conditions, one at a time — and of how rarely losing them announces itself as a mistake while it's happening.</p>
<h2>A model is something you learn through, not something you draw once</h2>
<p>Take a deliberately simple example: a library that lends things out. Old and familiar on purpose, so the reasoning is the point, not the subject matter.</p>
<p>The first conversation with the domain expert goes predictably. The library wants to lend books. They want to know where each book is — on a shelf, or on loan to someone, from when until when.</p>
<p>The path of least resistance puts the loan dates directly on <code>Book</code>. The book knows where it is; if it's out, it knows to whom and until when. It seems natural enough that most developers wouldn't pause on it.</p>
<p>But pause on it anyway, because this is the decision that quietly constrains everything downstream of it. Ask a plain domain question: is knowing when a book was borrowed, and by whom, part of what a book <em>is</em>? A book is a title, an author, a physical object. A loan is an event — an agreement between the library and a person, at a point in time, about that book. These are different things, stitched together for convenience, the same category of error as storing someone's employment history inside their passport.</p>
<p>There's a structural problem hiding behind the conceptual one, too. A book gets borrowed many times, by different people, at different points in time. A single set of loan fields on <code>Book</code> can't represent that history without overwriting it on every new loan. This isn't a style complaint — the model is structurally incapable of answering questions the business will eventually ask.</p>
<p>So a <code>Loan</code> entity gets introduced. It points to a book and a borrower, and carries its own data: start date, end date, return date. <code>Book</code> goes back to being just a book. Each concept is responsible for what it actually is.</p>
<p>Nobody asked for this refinement. The user story was "we want to lend out books," not "please separate the concept of a loan from the concept of a book." But the story was never a specification — it was a piece of information about the domain, and the job was to ask what it revealed, not to type it directly into a <code>Book</code> class and close the ticket.</p>
<p>Once <code>Loan</code> exists as its own thing, something becomes visible that nobody requested: how many times a book has been borrowed this year, whether it's going out back-to-back often enough to justify a second copy, which loans are overdue right now, which borrower has the most items out. None of this required touching the model again. It fell out of having put the responsibility in the right place the first time. A correct abstraction doesn't just solve the stated problem — it stops resisting the next ten questions nobody has asked yet.</p>
<h3>The second correction</h3>
<p>A new requirement arrives: the library wants to lend DVDs too.</p>
<p>The path of least resistance here is just as easy to predict: add a <code>DVD</code> entity. Title, director, runtime. Close the ticket. And this is exactly the failure this whole article is about, in miniature — the request "we also want to lend DVDs" got treated as an instruction to add a <code>DVD</code> class, instead of as new information about a domain that had just revealed something about itself.</p>
<p>The actual question isn't "how do we add DVD." It's: was <code>Book</code> ever the right concept for this domain in the first place? The lending system doesn't care that a book has pages or a DVD has a runtime. It cares that both are things that can be borrowed, tracked, and returned. Model <code>Book</code> and <code>DVD</code> as siblings and the next story brings magazines, then tools, then something that breaks the pattern outright, and four parallel entity types are now duplicating service logic and complicating every report.</p>
<p>The concept the domain actually needed, it turns out, was never <code>Book</code>. It was <code>LendableItem</code> — something that can be lent, regardless of what it physically is. <code>Book</code> becomes <code>LendableItem</code>; what kind of item it is becomes data (<code>ItemType</code>), not a class; the attributes specific to a type (ISBN and author for a book, runtime and director for a DVD) live in a small typed collection shaped by that <code>ItemType</code>. A new lendable thing can be defined through configuration, without a release.</p>
<p>This isn't abstraction for its own sake — starting with <code>Book</code> was the right call when only books existed; naming a concept after its only known instance is reasonable, not naive. The point is that when the second instance arrived, it was <em>evidence</em>, and the model was obligated to respond to evidence rather than absorb it as a special case bolted onto the side of the original guess.</p>
<p>Here is the part worth sitting with: <strong>in both corrections, the cost of being wrong was paid exactly once, at exactly one place, and the compiler told you everywhere else that needed to change.</strong> Turning <code>Book</code> into <code>LendableItem</code> produces a wave of compile errors at every call site that assumed a <code>Book</code> — every one of them a worked checklist, not a hunt. There is no step where you have to remember which of fourteen services touched the old assumption. The type system already knows.</p>
<p>Picture the alternative: a codebase with two hundred service methods, accumulated over years, several of them written by people who've since left. Some of those services read a book's loan status off a flag on <code>Book</code>. Some duplicate the "is this thing currently out" check inline. Some call into a shared <code>BookService</code> that does it correctly, and some call into an older one that doesn't quite. When the DVD requirement lands, <em>finding</em> every place that encoded an assumption about books is now a research project, conducted from memory and grep, with no tool confirming you found all of them — and if two different developers wrote two of those services, they may have encoded two subtly different mental models of what a book even is, neither of which was ever forced to reconcile with the other, because nothing in the architecture ever made them collide.</p>
<p>That's two of this article's three conditions doing their work at once: the model gave feedback the moment a concept had no natural home to be wrong in, and folding that correction back in cost one change, enforced by a tool, rather than a hunt across however many places had quietly encoded the old assumption. That's the actual argument for a domain model, stated as plainly as I can: it is the cheapest known way to be wrong, because being wrong gets caught in one place, by a tool, instead of being wrong silently in fourteen places, caught eventually by a domain expert noticing the software does something they never agreed to.</p>
<h2>Fat is not a size problem</h2>
<p>The library example shows feedback and cheap correction working together, inside a single concept. The first condition — that essential complexity stays whole — fails differently, and far more commonly, and it's worth seeing exactly how, because the failure is almost always mistaken for a different problem than it is.</p>
<p>Take <code>Customer</code>. Almost every enterprise system has one, and almost every one of them eventually starts absorbing things that don't belong to it: a <code>preferredCarrier</code> field set because shipping needed it, a <code>creditLimit</code> because billing needed it, an <code>slaTier</code> because support needed it. Years of this, and <code>Customer</code> is enormous — hundreds of fields, half of them nullable, conditional logic scattered through anything that touches it, and nobody able to describe what <code>Customer</code> actually means anymore, because it means five different things depending on who's asking.</p>
<p>This is a real failure, and the diagnosis matters, because two very different responses are available, and only one of them fixes anything.</p>
<p>The popular response is to split. Give shipping its own <code>ShippingCustomer</code>, billing its own <code>BillingCustomer</code>, support its own <code>SupportCustomer</code> — separate models, separate teams, separate services if you go all the way, joined by some kind of translation layer that maps one context's idea of a customer onto another's. This is the bounded-context move, and on paper it sounds disciplined: each context gets a clean, focused model instead of one bloated shared one.</p>
<p>Look closer and notice what actually happened: <code>ShippingCustomer</code> is not a different concept from the bloated <code>Customer</code>. It's the same god object, just with the bloat partitioned by department instead of concentrated in one file. The information that crept into <code>Customer</code> because nobody asked "whose responsibility is this" hasn't been resolved — it's been relocated, and the relocation comes with a new bill attached. Where before, a change to how loyalty tier affects shipping could be seen and verified in one place, by one compiler, it now has to travel: <code>Billing</code>'s context has to publish something, <code>Shipping</code>'s context has to subscribe to it, maintain its own copy, and recompute its own derived state asynchronously, hoping the event arrives, hoping the definitions of "loyalty tier" haven't quietly diverged between the two contexts that were specifically built not to share one. The coupling between billing and shipping didn't go away because they're now in different rooms. It just stopped being visible to anyone reading either room on its own — and a dependency you can't see is not a dependency you've solved, it's a dependency that will surface later, in production, as an "integration issue" nobody can trace back to its origin.</p>
<p>This is the same shape as the god object, except distributed. Splitting the pain across contexts is, at best, splitting the pain — not preventing it.</p>
<h3>What the fix actually looks like</h3>
<p>The right response to a fat <code>Customer</code> is the same response that turned <code>Book</code> into <code>Loan</code> and <code>LendableItem</code>: ask what responsibility doesn't belong here, and extract it — into a new, named concept, still inside the same model, still reachable by an ordinary reference, still subject to the same compiler.</p>
<p>But extract it carefully, because there's a trap one level down that looks like a fix and isn't. The instinct might be to give <code>Customer</code> a <code>List&lt;ShippingPreference&gt;</code> directly — replace the flat <code>preferredCarrier</code> field with a small polymorphic hierarchy of rules, ranked by precedence. That's progress over the flag, but it's still the same mistake in a thinner disguise: <code>Customer</code> has no business knowing that shipping preferences exist as a concept at all. A <code>ShippingPreference</code> living directly on <code>Customer</code> is <code>Customer</code> quietly absorbing knowledge of how it's consumed downstream — the exact failure that produced <code>ShippingCustomer</code> in the first place, just wearing an interface instead of a flag.</p>
<p>The responsibility that's missing a home isn't "the customer's shipping rules." It's "how this customer relates to shipping" — and that relationship is its own concept, with its own name: <code>CustomerShipping</code>. It holds a reference to the <code>Customer</code> it concerns, and a list of <code>CustomerShippingPreference</code> instances — a default, a tier-based upgrade, an explicit override — each one only meaningful inside the context of shipping, which is exactly where they now live.</p>
<pre><code class="language-java">interface CustomerShippingPreference {
    int precedence();
    CarrierChoice resolve(Order order);
}

class DefaultShipping implements CustomerShippingPreference {
    public int precedence() { return 1; }
    public CarrierChoice resolve(Order order) {
        return new CarrierChoice("UPS", Duration.ofDays(3));
    }
}

class GoldTierShipping implements CustomerShippingPreference {
    public int precedence() { return 2; }
    public CarrierChoice resolve(Order order) {
        return new CarrierChoice("DHL", Duration.ofDays(0));
    }
}

class ExplicitDateOverride implements CustomerShippingPreference {
    public int precedence() { return 3; }
    public CarrierChoice resolve(Order order) {
        return new CarrierChoice(order.requestedCarrier(), order.requestedDate());
    }
}

class CustomerShipping {
    private final Customer customer;
    private final List&lt;CustomerShippingPreference&gt; preferences;

    CarrierChoice shippingMethodFor(Order order) {
        return preferences.stream()
            .max(Comparator.comparingInt(CustomerShippingPreference::precedence))
            .map(p -&gt; p.resolve(order))
            .orElseThrow();
    }
}
</code></pre>
<p><code>Customer</code> itself never branches on tier, never checks for an override, never holds a single field related to shipping — it doesn't even know <code>CustomerShipping</code> exists. <code>Shipment</code>, when it needs a carrier, doesn't ask <code>Customer</code> anything directly. It takes an <code>Order</code>, reads the <code>Customer</code> off it, looks up or builds the <code>CustomerShipping</code> for that customer, and asks <em>that</em> object for the shipping method given the order:</p>
<pre><code class="language-java">class Shipment {
    Shipment(Order order, CustomerShippingRepository shippingLookup) {
        Customer customer = order.customer();
        CustomerShipping shipping = shippingLookup.forCustomer(customer);
        CarrierChoice carrier = shipping.shippingMethodFor(order);
        // ...
    }
}
</code></pre>
<p>A new rule — a holiday rush exception, a regional carrier restriction, a future platinum tier — is a new class implementing <code>CustomerShippingPreference</code>, added to <code>CustomerShipping</code>'s list, never touching <code>Customer</code> at all. The arbitration logic — <em>given several applicable rules, which one wins</em> — has exactly one home, and <code>Customer</code> stays exactly as ignorant of shipping as <code>Book</code> stayed ignorant of loans.</p>
<p>This is a handful of small, plainly readable classes. It is not impressive-looking code. And it resolves more correctly, with less effort, than either the original flag-on-Customer design or the bounded-context split would have, because it correctly identifies what was actually going on twice over: not "customer is too big," but "shipping's view of a customer had no home, so it got jammed either into a field on <code>Customer</code> or into a separate <code>ShippingCustomer</code> clone — when what it actually needed was its own name, sitting between the two, owning exactly the relationship it represents and nothing else."</p>
<p>Now try to build the same arbitration across three separate services — a shipping-preference service, a loyalty-tier service, an order-override service, however the bounded contexts happened to get drawn. The precedence rule doesn't belong to any one of them; it belongs to the relationship <em>between</em> them — which is precisely the responsibility <code>CustomerShipping</code> exists to hold — and that relationship has nowhere to live except in glue code outside all three contexts once it's been split that way: code nobody will consider part of "the domain," code that has to either make three synchronous calls and recompute the ranking itself, or maintain a denormalized, eventually-stale copy of all three rule types just to compare them locally. Either way, the actual essential complexity here — how privilege and explicit intent interact — has become homeless, in a system specifically designed to give every concept a clean home. Good luck.</p>
<p>There's a second, less obvious benefit to <code>CustomerShipping</code> worth naming, because it points at something larger than this one example. Notice what this design actually is: an add-on. It attaches a new concern to <code>Customer</code> after the fact, without modifying <code>Customer</code>, without <code>Customer</code> ever being aware it exists. That's normally the property bounded contexts and microservices claim for themselves — loosely coupled, independently addable — except here it's achieved without any of the cost usually attached to it, because the looseness came from correct responsibility assignment, not from physical separation. It's glue, without the pain that usually comes with glue.</p>
<h2>The boundary as a bet you can't yet price</h2>
<p>Here is the order of moves so far, made explicit, because the second move only works after the first one has landed. First: most of what bounded contexts are reached for to fix — a bloated <code>Customer</code>, a god object, departments fighting over one shared model — is solved more simply and more cheaply by keeping the first condition intact inside a single codebase: ask what responsibility doesn't belong, extract it into its own named object, connect it by reference. <code>CustomerShipping</code> is the proof. The usual justification for splitting evaporates once the extraction is done properly, because the thing the split was trying to relieve never had to exist in the first place.</p>
<p>Second, and this is the sharper claim: even where a boundary still looks justified on the day it's drawn — even if the team did genuine, careful event-storming, even if the language really does diverge between two parts of the business — the boundary is a bet, and it's a bet placed with incomplete information, because <strong>you cannot know today every cross-cutting rule the business will need tomorrow.</strong> This is the same condition failing on a different axis. Inside a codebase, the failure mode was a name with no responsibility. Across a network, it's a boundary that looked justified on the day it was drawn, and wasn't, because the thing that would have falsified it hadn't happened yet. A boundary drawn between Customer-handling and Shipping-handling is implicitly a claim that nothing will ever need to act on both sides of that line atomically. That claim is being made before the business has finished telling you what it needs — and it never finishes, the same way the library's understanding of what a lendable thing was never finished after one conversation.</p>
<p>The rule that eventually crosses the boundary doesn't have to be a compliance requirement. It's tempting to reach for GDPR's right to erasure as the example, because it's vivid and has a regulator attached — and it is a real instance of this, worth walking through on its own merits. A customer asks to be forgotten, and <code>Customer</code> needs to be deleted, fully and verifiably. In a single database, behind a single transaction, this is mostly handled by the database itself: if <code>CustomerShipping</code> references <code>Customer</code> and nobody wrote code to remove it first, the foreign key constraint refuses the delete, loudly, immediately, pointing exactly at what's still attached — the same constraint that should also <em>prevent</em> erasure when an order is still open or a complaint unresolved, again without anyone having to remember to write that check by hand. That failure is itself a small instance of the same learning loop the rest of this article has been describing: a <code>ConstraintViolationException</code> at the moment of deletion is the system telling you, synchronously and for free, that your understanding of "what does removing a customer actually require" was incomplete — caught at the cheapest possible moment, before anything was lost. Spread <code>CustomerShipping</code>'s data across an independently owned datastore in a separate service, and that guarantee disappears with it: there's no foreign key spanning two databases, so erasure becomes a saga of calls with compensating logic if any step fails, and the entire guarantee now depends on someone having remembered, months earlier, to wire <code>CustomerShipping</code> into that flow. Forget one service and nothing breaks loudly. The data that should have been gone simply continues to exist, discovered eventually by an audit, if it's discovered at all.</p>
<p>But making GDPR the centerpiece would be a mistake, because it hands every team without a regulator standing over them a clean exit: <em>we're not compliance-heavy, so this doesn't apply to us.</em> It applies to them too, because the same shape of rule shows up constantly with no compliance angle at all. A loyalty program launches eighteen months in, and upgrading a customer mid-month needs to retroactively adjust the shipping terms on every order still in transit — Customer, Order, and <code>CustomerShipping</code>, read and changed together. A fraud signal fires, and every open order and pending shipment for that customer needs to freeze atomically, in one step, not as three separate notifications hoping three separate systems all apply the freeze correctly and in time. An account gets closed, but anything already in transit is contractually entitled to still ship — one rule, reading across three concepts at once, treating each differently based on the others' current state. None of this is compliance. All of it is just Selling, understood a little more completely than it was on day one, the same way Loan and LendableItem were Lending, understood a little more completely than Book ever was.</p>
<p>The price of having split early isn't paid on the day of the split. It's paid the day one of these rules arrives, and what would have been a few small domain objects — a class, a method, a foreign key — turns out instead to require an application integration effort: a saga, a compensating-transaction design, a new piece of cross-service observability just so anyone can tell, after the fact, whether the rule actually applied everywhere it needed to. That price was never on the table when the boundary was drawn, because the rule that triggers it didn't exist yet. The boundary wasn't wrong because the modeling was sloppy. It was wrong because it was a permanent commitment made against a domain that was still, and always will be, in the process of being discovered — and discovery doesn't pause for the convenience of an architecture diagram that's already been agreed on.</p>
<p>There is a name for the assumption that a system can be correctly specified before the work of building it reveals what you didn't know. Waterfall made that assumption about requirements. Bounded contexts make the same assumption one level down — about domain boundaries. The parallel is precise: in both cases, a commitment is made at the moment of least knowledge, the commitment hardens as work accumulates on top of it, and the cost of the thing you didn't know becomes visible only after the commitment is too expensive to revise cheaply. The difference is that waterfall's failure eventually became undeniable enough that the industry moved on from it. The bounded-context version of the same mistake is currently being actively marketed.</p>
<h2>The boundary that doesn't justify itself</h2>
<p>It's worth being explicit about why this keeps happening, because the architectural move — bounded contexts, services drawn along them — is usually defended with a real and legitimate-sounding observation: the same word genuinely means different things in different parts of a large business. A "policy" to an underwriter is not what a "policy" means to someone handling a claim. A "trade" looks different to the front office than to settlement.</p>
<p>That observation is correct. The conclusion usually drawn from it — therefore, model it five times, once per context, and translate between the copies — is not the only available response, and I'd argue it's rarely the right one. When a single word is doing genuinely different jobs in different parts of the business, that is usually evidence that it was never one concept to begin with. It's evidence of exactly the same mistake <code>Book</code> made before <code>Loan</code> was extracted from it — a name covering more than one responsibility — except at a larger scale, and instead of doing the extraction (naming the actual underlying concepts: a contract, a claim case, a reserve calculation — each with its own identity, its own lifecycle, connected by ordinary references, the same way <code>Order</code>, <code>Invoice</code>, and <code>Shipment</code> are three objects rather than three departments' versions of one), the bounded-context move keeps the original, overloaded name in every room and adds a translation layer at each door. That's not respecting the business's multiple truths. It's declining to find out what the business's multiple truths are actually called.</p>
<p>This is worth stating plainly, because it's easy to mistake for a concession it isn't: the deeper the semantic divergence, the <em>more</em> extraction work is implied, not less — and the more reason to do it inside one model, where the newly-named concepts can still reference each other directly, rather than across a boundary that forces every relationship between them through an anti-corruption layer. A reinsurance contract and the claim filed against it are obviously different things with different lifecycles; that's an argument for <code>ReinsuranceContract</code> and <code>ClaimCase</code> as two well-named, related objects, not for two disconnected "Policy" models maintained by two teams who've agreed never to look directly at each other's data. Genuine semantic depth is the strongest case <em>for</em> doing the modeling work, not the exception that excuses skipping it.</p>
<p>None of this is an argument that physical distribution is always wrong. There are real, legitimate reasons to run things as separate deployable units: independent failure isolation that actually matters operationally, genuinely independent scaling needs, regulatory requirements that mandate separation for audit or compliance reasons unrelated to modeling at all. The test for whether a split like that is healthy is simple, and it's the same test from the start of this article: <strong>does the domain model have to change shape to accommodate the split?</strong> If the answer is no — if the same concepts, the same responsibilities, the same rules hold, and only the mechanism for reaching across them changes from a method call to a network call — then the split is a free, reversible decision about deployment, made after the model earned the right to be trusted, and accidental complexity is correctly staying downstream of essential complexity. If the model <em>does</em> have to change shape — if concepts get duplicated, renamed per-context, or translated through an anti-corruption layer to paper over a divergence nobody actually investigated — then the split came first, and the modeling work that should have preceded it never happened. The boundary became a substitute for understanding, not a consequence of it.</p>
<h2>Measuring the wrong thing very precisely</h2>
<p>A reasonable objection at this point: surely modern engineering practice catches this. Code review, static analysis, test coverage gates, architecture review boards — surely all of this machinery exists to prevent exactly the kind of drift described above.</p>
<p>It doesn't, and it's worth being precise about why, because the machinery is not useless — it's aimed at a different target entirely. A static analyzer can tell you a method is too long, that a class has too many dependencies, that cyclomatic complexity has crossed a threshold. None of that is a domain question. SonarQube has no opinion on whether <code>Customer</code> should hold a <code>preferredCarrier</code> field directly or delegate that entirely to a <code>CustomerShipping</code> object that doesn't exist on <code>Customer</code> at all, because that isn't a code-smell question, it's a question about whether the model corresponds to how the business actually works — and no tool that operates on syntax has any way to check a fact that only exists in a domain expert's head.</p>
<p>So an organization can run an elaborate, expensive process — fully pipelined microservices, every commit reviewed, every merge gated on a green static analysis run, deployment fully automated — and produce, at the end of all of it, a system whose model is confidently, fluently, rigorously wrong. Every visible signal says the engineering is going well, because every visible signal is measuring implementation hygiene, and implementation hygiene and model correctness are different axes that happen to get conflated constantly, because rigor <em>feels</em> like one thing.</p>
<p>This connects back to where the article started. Nothing in the standard toolkit is built to catch a violation of any of the three conditions this article has been tracing — none of them are code-smell questions, and no linter has an opinion on whether essential complexity stayed whole, gave feedback, or remained cheap to correct. The absence of a controlled alternative means a team can run this kind of theater for years, ship working software the whole time, and never learn that a few small, ordinary classes — built around the right concepts instead of the existing process — would have outperformed all of it. A well-designed model with mediocre implementation has a much higher ceiling than a brilliantly implemented wrong one, because the brilliance in the second case is mostly being spent compensating for the model — defensive checks for cases that shouldn't exist, synchronization between copies of state that never needed to be duplicated, translation layers between contexts that never needed separating — and all of that compensating effort gets thrown away the moment someone finally corrects the model underneath it. Effort spent on a correct model compounds. Effort spent on an incorrect one partially evaporates, no matter how rigorously it was reviewed on the way in.</p>
<p>Good engineering practice, by this account, is not the pipeline. It's the discipline of being able to say, clearly, what the model is and why — the implementation afterward is the easy part, and it has always been the easy part. The pipeline measures the easy part very thoroughly.</p>
<h2>Why this gets more urgent, not less, with AI</h2>
<p>Here is the part that didn't apply five years ago in quite the same way.</p>
<p>Implementation has historically had a floor of friction underneath it that nudged people toward structure almost by accident. Hacking procedural code together against a complex domain became unmanageable quickly enough — the special cases piled up, the conditionals nested, the same logic got copy-pasted into three places — that developers were pushed toward extracting structure out of self-preservation, even teams who'd never read a line of object-oriented theory. The friction wasn't a deliberate teacher, but it taught something, by making the wrong path visibly painful to keep walking.</p>
<p>AI-assisted coding removes a great deal of that friction — and it's worth being precise about what that means, because "AI breaks the feedback loop" is a slightly different and less accurate claim than what's actually happening. AI doesn't break the loop. It removes the pressure that used to force the loop into existence in the first place, often for teams who never deliberately chose it and couldn't have named it if asked. Take that pressure away and the loop doesn't vanish — it just stops being automatic. From here on, keeping it is a deliberate choice, the same as any discipline that doesn't enforce itself.</p>
<p>But there's a second, subtler effect that goes beyond friction removal, and it maps directly onto all three conditions this article has been tracing. Consider what happens when a library system needs to lend DVDs. A human developer who adds <code>DVD</code> as a sibling of <code>Book</code>, then adds <code>Vinyl</code> six months later, then writes an increasingly complex query to aggregate loan counts across three separate entity types — that developer <em>feels</em> something. Not necessarily consciously, and probably not articulately. But the query is harder to write than it should be. The next story that touches lending takes longer than expected. Something resists. That resistance is a weak signal, easily ignored and often misattributed to "the domain is just complex," but it exists. Occasionally it prompts a conversation, a refactor, or a senior developer asking why this feels harder than it should. It is, in a loose and informal way, the model giving feedback through the second condition.</p>
<p>AI generates the three-way join with exactly the same fluency as the one-way query. It doesn't experience resistance. The code is clean, the tests pass, the feature ships. Nobody in the process felt anything. The signal that a wrong shape generates — growing complexity, queries that accumulate joins, stories that quietly take longer than they should — exists nowhere in the experience of either the AI or the prompter, who is working at a level of abstraction that sees "does this feature work," not "is this implementation getting harder than it ought to be." The feedback that used to live in development, however weakly, has moved entirely to production: corrupt data, incoherent transactions, a simple-sounding feature that turns out to require three months because nobody can find a clean place to put it in a model nobody shaped for it. That's the most expensive place for a feedback loop to live, and it's where AI pushes everything — not just for procedural code or bounded contexts, but for any approach that wasn't built around a model designed to give feedback structurally rather than through the developer's pain.</p>
<p>That question gets answered in a refinement session, by watching a domain expert's reaction to a model that doesn't quite match what's in their head, by treating a new user story as evidence rather than as an instruction. AI has no access to that room. It can implement what it's told with great fluency, but it has no mechanism for discovering that what it was told was an incomplete or slightly wrong description of the domain, because discovering that requires exactly the adversarial, repeated checking against reality that this entire article has been describing as the actual function of a domain model. A model built without that checking is not a faster way to get to a correct system. It's a faster way to arrive, confidently and with clean code, at the same unfalsifiable mistake the rest of the industry has been making for decades — just produced at a speed that makes it considerably harder to notice before the cost compounds.</p>
<p>The bottleneck in software quality was never really implementation, even before AI; it only looked that way because implementation was the part that consumed the most visible hours. Collapse the cost of those hours toward zero, and what's left, undisguised, is the question that was always the only one that mattered: did anyone actually understand what they were building, or did they just build the first plausible shape it was described as, and call it done.</p>
<h2>Essential complexity, made tangible and testable</h2>
<p>A rich domain model is a tool. A tool to learn what a domain actually is, a tool to define it precisely enough that implementation stops being a guess, a tool to document it in a form that has to keep working, because unlike a wiki page, it can't silently drift out of date without a compiler, or a database constraint, saying so. It is essential complexity made tangible — something you can point at — and testable — something that tells you, specifically and immediately, the moment it's wrong.</p>
<p>Everything in this article has really been one long demonstration of what happens when the three conditions that tool depends on get broken, one at a time. Split a domain along functional lines that made sense given what was known at the time, and every scenario you already knew about still works — but the cross-cutting rule that arrives later, whether it's a compliance deadline or an ordinary business decision nobody had thought of yet, now costs an integration project instead of a few small classes, because the domain that should have stayed whole was cut before anyone could know what would eventually need to reach across the cut. Disperse the logic into fat services and repositories and DTOs instead, and the model stops giving feedback at all, because there's no longer one place for a wrong assumption to collide with itself and be caught. Hand the implementation to something that writes fluent code without ever asking whether the shape it was given was the right one, and the loop that used to force discovery — slowly, expensively, but eventually — stops being forced. It doesn't disappear. It just stops happening unless someone chooses, deliberately, to make it happen.</p>
<p>Which is where this circles back to where it started. Software architecture is unfalsifiable — no control group, no alternative built alongside the one that shipped, every conclusion drawn from an experience of one. That problem isn't going away. But a rich domain model is the closest substitute available for the experiment nobody gets to run: not proof that a decision was right, but a running, continuous test of whether it still is — for as long as the essential complexity stays whole enough to look at, gives feedback when it's wrong, and stays cheap enough to correct that correcting it remains something a team will actually do, rather than something they agree, in principle, they should.</p>
<p>None of this requires architects and engineers to want it to be true. That's the uncomfortable part, and it's worth ending on. The costs of drifting away from it — the fat service, the boundary drawn early, the AI-fluent implementation of a shape nobody examined — are deferred, distributed across people who didn't make the original decision, and individually invisible at the moment each one gets made. Nobody sets out to make software hard to change. They choose a service split that solves this quarter's problem, a pattern from a conference talk, a completion that passes the tests in front of them. The same unfalsifiability that opened this article is exactly why none of those choices announce themselves as mistakes at the time — there's no control group showing what the alternative would have looked like. A rich domain model doesn't argue anyone out of making those choices. It just makes the cost of having made them visible while the bill is still small enough to pay.</p>
<p>The purpose of a rich domain model is not to be right. It is to make being wrong visible while the cost of correction remains small.</p>
]]></content:encoded></item><item><title><![CDATA[AntiPatterns Never Left, We Just Stopped Calling Them by Name]]></title><description><![CDATA[In 1998, a book called AntiPatterns did something unusual: instead of cataloguing good solutions to recurring problems, it catalogued bad ones — the recognizable, recurring ways software projects go w]]></description><link>https://blog.leonpennings.com/antipatterns-never-left-we-just-stopped-calling-them-by-name</link><guid isPermaLink="true">https://blog.leonpennings.com/antipatterns-never-left-we-just-stopped-calling-them-by-name</guid><category><![CDATA[Java]]></category><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[design patterns]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Mon, 15 Jun 2026 12:08:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/46206070-331d-408b-ac6b-981a50ef69d2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In 1998, a book called <em>AntiPatterns</em> did something unusual: instead of cataloguing good solutions to recurring problems, it catalogued <em>bad</em> ones — the recognizable, recurring ways software projects go wrong. The Blob, Spaghetti Code, Stovepipe Enterprise, Mushroom Management. Each one came with a name, a description of the symptom, and a refactored path out.</p>
<p>Patterns and AntiPatterns are two sides of the same coin. A pattern says: here's a known problem, and here's a solution that tends to work. An AntiPattern is not simply "a bad solution" — it's a description of a recurring <em>failure mode</em>, or of something that actively blocks or resists effective development, even when (especially when) it doesn't look like a mistake at the time.</p>
<p>What makes an AntiPattern dangerous isn't that it's obviously wrong. It's that the failure mode it describes tends to be <strong>invisible while it's happening</strong>. This is the <strong>unfalsifiability problem</strong>: if a system works, meaning it runs, it ships and does what it should do, the choice that produced it gets read as validated. The counterfactual (what if we'd done it differently?) is invisible. Nobody runs that experiment. So the failure mode doesn't get diagnosed; it gets repeated, often by other teams, often with conviction, often dressed up as best practice.</p>
<p>Patterns have an entire consulting industry built around teaching them. AntiPatterns, as far as we can tell, mostly don't — there's no equivalent industry whose job is to walk into a project and say "this is Stovepipe Enterprise, and here's what it'll cost you in three years." So the old catalogue — genuinely old now, pre-dating microservices, Kubernetes, Spring Boot, Scrum-as-religion, and the entire modern cloud-native stack — quietly fell out of view. Not because the failure modes it described went away, but because nobody was selling the diagnosis.</p>
<p>Going back to that old catalogue, the question is simple: which of these still apply, and to what, today? The answer, overwhelmingly, was: almost all of them, just wearing different clothes. What follows is sourced from the originals, regrouped into four themes, each of which is really just a different <em>altitude</em> at which the unfalsifiability problem operates — from the codebase, to the organization, to the industry at large.</p>
<hr />
<h2>Chapter 1: Technical Axis vs. Domain Axis</h2>
<p><em>The shape of unfalsifiability here: a working system hides which axis its structure is organized around — until the domain changes, and you discover the boundaries were drawn for the compiler's convenience, not the business's.</em></p>
<p>Software has at least two legitimate ways to be sliced. One is by <em>what the business cares about</em> — a Risk Summary, a Customer Account, an Order. The other is by <em>technical concern</em> — repositories, services, controllers, DTOs, event handlers. Both are real. The trouble starts when the technical axis becomes the organizing principle and the domain concepts get fragmented across it, because nobody's job is to keep the <em>domain</em> concept coherent anymore — everybody's job is to keep their <em>layer</em> coherent.</p>
<h3>Jumble → "Accidental Layering"</h3>
<p>The original Jumble AntiPattern describes what happens when horizontal layers (presentation, business logic, data access) and vertical domain slices get intermixed without discipline, producing an architecture that's neither cleanly layered nor cleanly domain-partitioned.</p>
<p>The modern, much more common version of this is subtler and looks like <em>good practice</em>: "put all your queries in a repository layer." On the surface this is just separation of concerns. In practice, it means a concept like "risk total for this category" — which is meaningful only in the context of a Risk Summary — gets implemented as a generic, context-free query method sitting in a repository, available to be called from anywhere, by anything, with no memory of what it's <em>for</em>.</p>
<p>The usual defense is reuse: "if the query lives in the repository, every part of the system that needs a risk total can call the same method." This sounds reasonable until you notice what's actually being reused. <strong>You never reuse a query — you reuse what it represents.</strong> A query is just an implementation detail; "risk total for this category" is the <em>concept</em> that needs to stay consistent. Reusing the query method gives you textual reuse of some SQL. Reusing the Risk Summary object — calling its <code>riskTotal()</code> — gives you reuse of the <em>context</em>: the rules about what counts, what's excluded, how categories nest, all of it living in one place that knows what "risk total" means.</p>
<p>The failure mode this produces is depressingly specific and common: need A comes along, and the existing query in the repository is <em>almost</em> right but not quite — so rather than fix the shared query (and risk breaking need B, which also calls it), whoever's implementing A copies the query and tweaks it to fit. Now there are two queries called "risk total," subtly different, and nothing in the codebase says they're supposed to mean the same thing — or that they don't anymore.</p>
<p>The opposite also happens, and it's arguably worse. Need A is <em>slightly</em> different from need B, but whoever's working on A assumes they're the same — same name, same shape, looks like the same query — and edits the shared one in place to fit A's requirements. The unit test for B never anticipated this scenario, because nobody writing it imagined "someone will later assume this is also A's query and change it accordingly." So B doesn't break across the board; it breaks in <em>some</em> scenarios — the ones where A's and B's actual requirements diverge — which is exactly the kind of bug that surfaces in production, intermittently, long after the change, and gets debugged as "weird edge case" rather than traced back to a shared query that two different concepts were silently sharing.</p>
<p>Both directions — forking a query that should've stayed shared, and editing a shared query that should've stayed forked — have the same root cause: there's no explicit object whose job it is to <em>own</em> the concept and represent the boundary between what A needs and what B needs. A unit test won't catch either, and this is where it loops back to unfalsifiability directly: a test only encodes what was known <em>at the time it was written</em>. The missing context — that A and B are both expressions of the same domain concept, and a change to one is a change to the meaning of the other, <em>or</em> that they aren't and a change to one must not touch the other — is exactly the thing a context-free query can't carry and a test can't recover after the fact. Keep the query on the Risk Summary, as a method on the aggregate that owns the concept, and both classes of bug become structurally harder to write — not because anyone's more careful, but because there's only one place "risk total" can live, and changing it visibly changes everything that depends on it.</p>
<p>This is <strong>accidental layering</strong>: structure that exists to organize the technology — how do we talk to the database — at the cost of fragmenting the domain concepts that the technology is supposed to be serving. The repository version <em>works</em>. It compiles, it returns data, the tests pass. The cost only shows up later, when "risk total" quietly stops meaning one thing.</p>
<h3>Functional Decomposition + Poltergeists → The Anemic Domain Model</h3>
<p>The original Functional Decomposition AntiPattern describes experienced procedural developers writing object-oriented code that's secretly still procedural — classes exist, but they're really just namespaces for functions, operating on data that lives elsewhere. Poltergeists, in the same vein, are short-lived classes whose only job is to kick off a process for some other object and then disappear.</p>
<p>Put these two together and you get a near-perfect description of the "fat service, thin object" shape that shows up across most layered architectures, regardless of language or framework: <code>Service</code> classes full of methods that orchestrate behavior, operating on <code>Entity</code> objects and DTOs that are really just data bags with getters and setters. The class structure is object-oriented. The <em>behavior</em> is procedural — it's Pascal with annotations, or Pascal with decorators, or Pascal with whatever the local ceremony happens to be. The "objects" don't do anything; the services do everything <em>to</em> the objects.</p>
<p>Layered on top of this, the Poltergeists are everywhere: mapper classes that convert entities to DTOs and back, one-shot orchestrator classes, <code>*Factory</code> and <code>*Builder</code> and <code>*Handler</code> classes whose entire lifecycle is "get instantiated, shuttle control from the controller to the service to the repository, disappear." None of these classes <em>know</em> anything. They just move data and call the next thing.</p>
<p>This is the architecture that "put the logic in the service, keep the data in the DTO" produces by default — not because any particular framework forces it, but because it's the path of least resistance once logic and data have been separated by convention, and the path of least resistance is what most codebases end up looking like at scale. The object-oriented vocabulary (classes, methods, "services") is all there. What's missing is anything that resembles an <em>object</em> in the original sense — something that owns both its data and the rules about what that data means, the way the Risk Summary above owns <code>riskTotal()</code>.</p>
<h3>Lava Flow → Dead Artifacts in Event-Driven Architecture</h3>
<p>The original Lava Flow AntiPattern is about dead code and forgotten design decisions that get frozen into an ever-changing codebase — like hardened rock in a lava field, nobody quite remembers how it got there, and nobody's confident enough to remove it.</p>
<p>Event-driven architecture and CQRS can become extremely effective Lava Flow generators. The mechanism is specific: when a state change happens, it gets translated into an event, published, and then <em>handled</em> — possibly by several consumers, possibly asynchronously, possibly with retries, possibly written to an outbox first. Each of those steps is a place where a "rock" can harden: a handler that nobody triggers anymore because the upstream condition that used to fire it was refactored away, a published event type that three downstream services still subscribe to "just in case," a saga step that's technically unreachable but nobody's sure enough to delete it.</p>
<p>The original Lava Flow's solution was a configuration management process that actively hunts down and eliminates dead code. The EDA version of dead code is much harder to hunt, because it isn't sitting in one file you can search for — it's a <em>subscription</em>, a <em>topic</em>, a <em>handler registration</em>, possibly in a different repository than the thing that used to trigger it. The debris isn't dead code in the traditional sense; it's dead <em>connections</em> — and because messaging is fundamentally fire-and-forget, those dead connections don't even fail loudly. A handler nobody needs anymore doesn't throw; it just keeps running, on schedule, consuming compute and network for events that no longer mean anything to anyone. In a monolith, dead code is at least <em>inert</em> — it sits there, unused, until someone deletes it. In EDA, dead code can be <em>active</em>: a ghost process, still executing, still costing money, with no stack trace and no error to tell you it's a ghost.</p>
<p>But there's a cost that shows up even while everything is alive and healthy, which is arguably the more important one: the <em>dependency itself</em> becomes invisible. When A's state change causes B's state change in the same transaction, that causality is right there in the code — a call, a method, something you can read and step through. When A publishes an event and B (eventually, somewhere) handles it, that same causality still exists — B still depends on A having happened — but it no longer exists <em>anywhere in the code</em>. It exists only as a runtime fact: a subscription, a topic name, a piece of configuration. Debugging "why did B happen" stops being a matter of reading code and becomes a matter of reconstructing a causal chain after the fact, across services, via logs, correlation IDs, and timestamps.</p>
<p>That reconstruction can be done — distributed tracing, log aggregation, and correlation IDs all exist precisely to make it possible — but it's worth being honest about what those tools <em>are</em>: compensating machinery, built to recover something a single transaction would have given you for free. A lot of what gets called "modern observability" is, functionally, the cost of paying back the contextualization that decoupling spent. Keeping things that should happen together actually <em>together</em> — same domain object, same transaction, even across multiple methods — doesn't require any of that machinery, and is a lot less likely to generate a Lava Flow in the first place, because there's nothing to subscribe to, lose track of, or reconstruct.</p>
<p>It's also worth separating two things that get conflated under "we need EDA/microservices to scale": scaling and splitting are not the same operation. A monolith can scale horizontally — more instances behind a load balancer — without anything being split apart at all. Splitting a system into services that communicate via events solves a <em>coordination</em> problem (independent deployability, team ownership, different parts needing different resource shapes) — it doesn't, by itself, make anything handle more load. When "we need to scale" is used to justify "therefore we need to split," a capacity problem is being answered with an architecture decision that's actually about organizational boundaries — which may be the right call, but is a different call, justified by different reasons, with the debugging and Lava Flow costs described above as part of its price.</p>
<p>When a state change happens close to the domain core — same object, same transaction, even if not the same method — it's visible. When it happens by firing an event across a transactional boundary, you've traded visibility for decoupling, and the Lava Flow is the price of that trade, paid later, by someone else, in a form that doesn't even announce itself as a cost.</p>
<h3>Vendor Lock-In → Framework Lock-In Without a Vendor</h3>
<p>The original Vendor Lock-In AntiPattern describes systems that become highly dependent on a proprietary architecture, to the point where switching away becomes prohibitively expensive — historically, think IBM mainframes, or any single-vendor enterprise stack.</p>
<p>The interesting modern twist is that lock-in no longer requires a <em>vendor</em> in the old sense — and then, almost as if to prove the original AntiPattern's point all over again, the vendor relationship quietly grows back. Spring is open source, but the company behind it now sells exactly the kind of commercial support arrangement Vendor Lock-In originally warned about: OSS minor releases get a guaranteed support window of just over a year, after which the application keeps running on the last published artifact, but any newly discovered vulnerabilities have no upstream fix — you're on your own unless you pay for extended coverage.</p>
<p>So the choice, every year or so, per major dependency line, is: pay for enterprise support, or pay in engineering time to upgrade. And "pay in engineering time" is real money with a real number attached — a couple of contractors spending a chunk of their month on framework version bumps, dependency conflict resolution, and re-testing everything that touches the upgraded pieces, adds up to a bill that's directly comparable to a support contract, except it's hidden inside "maintenance" rather than itemized as "vendor cost." Either way, you're paying <em>someone</em> to keep the substrate underneath you current — which is the textbook definition of dependency, just relabeled.</p>
<p>On top of that: a sufficiently "Spring-native" codebase — laced with <code>@Autowired</code>, <code>@Transactional</code>, <code>@Service</code>, component scanning, and the conventions that make all of that work — is <em>enormously</em> expensive to extract from regardless of who you're paying. Not because anyone's charging you to leave, but because your domain logic and the framework's lifecycle have become structurally entangled. The framework isn't a dependency you call; it's the substrate your code lives inside.</p>
<p>This matters because none of it <em>feels</em> like the lock-in the original AntiPattern described. There's a contract now — but it's framed as "support," not as the price of staying put. It feels like "just using a popular, well-supported framework, with optional extras" — which is exactly what makes it durable. The unfalsifiability problem here is almost total: there is no single event that tells you "you are now locked in." You just slowly become unable to imagine the alternative, the renewal invoice (or the upgrade sprint) arrives on schedule, and the system keeps working — so the question of whether this is actually cheaper than the alternative never gets asked, let alone answered.</p>
<hr />
<h2>Chapter 2: Adoption Without Evaluation</h2>
<p><em>The shape of unfalsifiability here: at the industry scale, popularity itself becomes the evidence. The road not taken is invisible, so "widely adopted" quietly substitutes for "evaluated and found correct for this context."</em></p>
<h3>Continuous Obsolescence → Dialect Drift Inside "the Same Language"</h3>
<p>The original Continuous Obsolescence AntiPattern is about ecosystem churn: technology moves fast enough that finding compatible versions of things that actually interoperate becomes its own ongoing project, and developers spend real effort just keeping the floor from shifting under them.</p>
<p>There's a related but distinct failure that the original framing doesn't quite capture, and Scala is the clearest historical example of it. Scala's problem was never really "too many releases" — it was that the language gave every team enough expressive power (implicits, operator overloading, macros, DSL-building features) to define its own dialect. Walking into a new Scala codebase often meant <em>learning that codebase's Scala</em> before you could be productive in it, on top of learning Scala itself. The language was technically one language; in practice it was as many languages as there were teams willing to use its more expressive corners.</p>
<p>Java spent a long time being the opposite of this on purpose — verbose, explicit, "English-like," deliberately leaving little to the imagination, precisely so that a Java codebase from one team looked recognizably like a Java codebase from another. But the last decade of Java releases — lambdas, streams, <code>var</code>, records, sealed types, pattern matching, and the steady cultural push toward "boilerplate reduction" — has been adding exactly the kind of expressive, compact, <em>idiomatic</em> features that Scala had from day one. None of these features are bad in isolation. But each one raises the floor of what "reading Java" requires, and — just like Scala — different codebases adopt different subsets of them, idiomatically or not, without anyone deciding this as policy. A codebase built around streams-of-records-with-pattern-matching reads nothing like one that's still mostly loops and getters, even though both compile as "just Java 21." The dialect fragmentation Scala had in the open, Java is quietly acquiring feature-by-feature, each addition individually justified as "less boilerplate," with nobody tracking the cumulative effect on how many distinct <em>styles</em> of Java a developer now needs to be fluent in before "knowing Java" actually means being productive.</p>
<p>This is Continuous Obsolescence at the level of <em>readability</em> rather than <em>dependency versions</em> — the floor for entry-level legibility keeps rising, a release at a time, and because each individual feature is small and well-intentioned, there's never a single moment where anyone evaluates whether the codebase as a whole still meets its own bar for "anyone on the team can read this."</p>
<h3>Golden Hammer</h3>
<p>The original Golden Hammer is the most literal of the bunch and barely needs updating: a familiar technology or concept, applied obsessively to problems it doesn't fit, because it's the tool the team knows. The original's prescribed fix — expand developers' knowledge through education, training, and book study groups, so they have <em>alternatives</em> to reach for — is, charmingly, still the prescribed fix in 2026, and still mostly doesn't happen.</p>
<p>What's changed is the scale of the hammer. In 1998 a Golden Hammer might be one design pattern, applied everywhere. Today it's an entire <em>platform</em> — Kubernetes for a five-person team's internal tool, Kafka because the last company used Kafka, a service mesh for an application with three services. The hammer got bigger, but the mechanism — familiarity substituting for fit — is identical.</p>
<p>Continuous Obsolescence and Golden Hammer are, in a sense, mirror images. Golden Hammer is under-using a toolkit's diversity — one familiar tool, applied everywhere, regardless of fit. Dialect drift is over-diversifying a <em>language's</em> feature usage until the codebase itself becomes a toolkit nobody fully knows — every corner adopted because it was available and looked like an improvement, with nobody asking whether the codebase, as a whole, was better off with a smaller, more uniform set of idioms. Same lack of deliberateness, opposite direction.</p>
<h3>Architecture by Implication → Survivorship Bias as Architecture</h3>
<p>The original AntiPattern describes overconfidence carried forward from past successes: a general approach that worked once gets applied to the next system, without anyone checking whether the new system's risks and requirements are actually similar.</p>
<p>The deeper version of this: every system is, in practice, only ever built <em>once</em>. The cheaper, simpler alternative was never actually built, so there's no comparison to make. If the system that <em>was</em> built works, that gets read as success — full stop. Nobody can point to the parallel universe where the team built the boring monolith instead of the microservices, or skipped CQRS, or didn't introduce the event bus, and ask whether <em>that</em> version would have shipped faster, cost less, and been easier to change.</p>
<p>This is why patterns like EDA, CQRS, and microservices — which have entirely legitimate origin contexts (genuinely high scale, genuinely independent teams, genuinely eventual-consistency-tolerant domains) — end up applied far outside those contexts. The pattern <em>worked</em> somewhere, visibly, loudly, in a conference talk. The boring alternative never got a conference talk, because it was boring, because nothing went wrong, because there was nothing to present. "It shipped and the company didn't die" gets read as validation of the <em>pattern</em>, when it's really just validation that the constraints were tolerable — which tells you nothing about whether the pattern was <em>necessary</em>.</p>
<h3>Intellectual Violence → Complexity as Social Leverage</h3>
<p>The original AntiPattern describes someone who understands a theory, technology, or buzzword using that knowledge to intimidate others in a meeting — winning the argument not on merits, but by making disagreement look like ignorance.</p>
<p>The modern version doesn't even require an intimidator. CQRS, Dependency Injection, Event-Driven Architecture — these are genuinely complex enough that <em>disagreeing</em> with their use requires demonstrating you understand them well enough to critique them specifically. "I don't think we need this" sounds, to a room that's already nodding, indistinguishable from "I don't understand this." So the safer move — for almost everyone in the room — is to nod too. The complexity itself does the intimidating; nobody has to play the difficult one in the room.</p>
<p>This connects directly to the rest of the chapter. Continuous Obsolescence and Golden Hammer explain <em>what</em> gets adopted — features and tools chosen for familiarity or availability rather than fit. Architecture by Implication explains why nobody's <em>checking</em> whether the adoption was the right call (no visible counterfactual). Intellectual Violence explains why, even when someone privately <em>suspects</em> it wasn't the right call, they don't say so out loud. Different mechanisms, same outcome: complexity that nobody individually chose, but everyone collectively rubber-stamped.</p>
<hr />
<h2>Chapter 3: The Cure Regrows the Disease</h2>
<p><em>The shape of unfalsifiability here: at the organizational scale, a structural fix is judged purely by whether the system still works afterward — not by whether the underlying disease actually left, or just moved to an organ nobody's looking at.</em></p>
<h3>Spaghetti Code → Distributed Spaghetti</h3>
<p>The original Spaghetti Code AntiPattern is the classic: ad hoc structure, no clear flow, difficult to extend or optimize, fixable mainly through disciplined, ongoing refactoring.</p>
<p>Microservices are frequently sold as the cure for this — break the tangled monolith into small, independent, individually-comprehensible services. And at the scale of a single service, that's often true: a small service <em>can</em> be spaghetti-free in a way a 100-object monolith struggles to be.</p>
<p>But the failure mode doesn't require any single service to be tangled. It requires the <em>system</em> to be tangled — and distributing the tangle across network boundaries doesn't untangle it, it just makes each individual strand harder to see and far more expensive to follow. Spaghetti Code was always survivable, in part, <em>because the compiler caught some of it</em> — a method signature change that breaks twelve callers is a build failure, immediately, locally, before anything ships. Distributed spaghetti loses that safety net entirely: the equivalent change is a contract change between services, the breakage is a runtime error in production, possibly in a service owned by a different team, possibly days later.</p>
<p>The decomposition didn't remove the failure mode. It changed its blast radius — and traded a problem you could <em>see</em> (a big tangled codebase, sitting right there, clearly someone's problem) for one you mostly can't (a tangle of contracts and assumptions spread across services and teams, nobody's full-time job to track).</p>
<h3>Stovepipe Enterprise → Microservices and the Ossified Boundary</h3>
<p>The original Stovepipe Enterprise describes a <em>lack</em> of coordination and planning across systems — each one solving its own slice in isolation, duplicating effort, creating integration headaches with everyone else.</p>
<p>Microservices done at the boundary level produce something that looks like the opposite problem but shares the same root cause. The boundaries get drawn carefully, thoughtfully, with real coordination — at one point in time, based on the team's <em>current</em> understanding of how the business works. And then the business — its processes, its terminology, its rules about what belongs together — keeps changing, because that's what businesses do. The <em>code</em> structure ossifies around a snapshot of domain understanding that the domain itself has already moved past.</p>
<p>The original Stovepipe Enterprise is mostly about <em>waste and duplication from never coordinating</em>. The microservices version is almost the inverse symptom from the same underlying mistake: treating the system as a sum of independently-evolvable parts, when the thing that actually needs to evolve — the shared understanding of the domain — doesn't respect the part boundaries at all. It's easy to split a system along today's understanding. It's extremely hard to <em>un</em>-split it when that understanding changes, which is exactly when you'd need to.</p>
<p>None of this means microservices are categorically wrong. It means microservices trade a maintenance cost you can <em>see</em> — a large, tangled codebase, sitting there, undeniably someone's problem — for one that's much harder to see: accidental complexity that didn't go away, it moved to the seams between services and got a network hop attached to it.</p>
<h3>Throw It Over the Wall → Platform Teams</h3>
<p>The original AntiPattern describes object-oriented guidelines and implementation plans — meant as flexible suggestions — getting treated as rigid mandates by the time they reach downstream developers, accumulating false authority as they pass through approval processes.</p>
<p>The modern instance is almost a perfect mirror, but at the team level rather than the document level. DevOps, as a movement, was explicitly framed as the antidote to exactly this kind of wall-throwing: "you build it, you run it" — collapse the separation between the people who write software and the people who operate it, so nobody can throw anything over a wall because there's no wall.</p>
<p>What actually happened, often, is that the tooling required to "build it and run it" — CI/CD pipelines, Kubernetes, observability stacks — became sophisticated enough to need its own dedicated team. That team chooses the platform (often the popular thing, see Chapter 2), builds the pipelines, and now sits between product teams and production — a new wall, one level removed, staffed by people who weren't there when the original wall was being torn down and likely don't think of themselves as a wall at all.</p>
<p>This is the clearest example of a pattern that recurs across this entire article: <strong>the antidote regrows the disease in a different organ.</strong> Mushroom Management (Chapter 4) gets "solved" by Scrum's Product Owner role, which recreates the intermediary. Throw It Over the Wall gets "solved" by DevOps, which recreates the department. In both cases, the system afterward <em>works</em> — which is exactly why nobody notices the disease came back. It just moved.</p>
<hr />
<h2>Chapter 4: Who Feels the Pain Doesn't Decide</h2>
<p><em>The shape of unfalsifiability here: at the individual/role scale, the person making a structural decision is organizationally insulated from its consequences — so they never receive the feedback signal that would tell them the decision was wrong.</em></p>
<h3>Mushroom Management → Scrum's Intermediary, Reborn</h3>
<p>The original Mushroom Management AntiPattern describes a deliberate policy of keeping developers isolated from end users — requirements arrive second-hand, filtered through architects, managers, or analysts, who stand between the people building the thing and the people who'll use it.</p>
<p>Modern Scrum was, in part, supposed to fix this — user stories as <em>conversations</em>, the whole point being a direct, ongoing dialogue between the people who need something and the people building it, with the story as a prompt for discussion rather than a finished spec.</p>
<p>In practice, the story very often becomes the instruction rather than the conversation-starter, and the Product Owner becomes the very intermediary the process was meant to remove — now institutionalized as a defined role, with its own ceremonies, sitting precisely where the "mushroom" used to sit. It's almost recursive: an AntiPattern from 1998 describing a problem, and a 2001-era process explicitly designed to address it, regrowing the same shape inside the cure.</p>
<h3>Design by Committee + Grand Old Duke of York → Chickens, Pigs, and Flattened Roles</h3>
<p>The original Design by Committee AntiPattern is the classic standards-body failure: overly complex architecture, lacking coherence, because too many people with too little shared context (and too little personal stake) are making decisions by committee. Grand Old Duke of York, separately, observes that programming skill doesn't equate to skill in defining <em>abstractions</em> — there are, in practice, two different skill sets (call them abstractionists and implementationists), and ignoring that distinction hurts projects.</p>
<p>The "chickens and pigs" framing from agile folklore captures both at once: chickens have opinions about the farm but don't lay the eggs; pigs provide the bacon and feel the consequences. Design by Committee is chickens designing for pigs. Grand Old Duke of York, reframed through modern Scrum, is something slightly different and arguably worse: Scrum often <em>flattens</em> the abstractionist/implementationist distinction entirely — in principle, anyone on the team can take the architectural decision for a given sprint, regardless of whether they have the abstraction-defining skill the original AntiPattern says is rare and distinct.</p>
<p>The 1998 framing at least <em>acknowledged</em> the skill gap and tried to address it through process — get the right people defining abstractions. The flattened-role version sometimes pretends the gap doesn't exist at all. Both versions, old and new, share the same underlying mechanism with Mushroom Management and Throw It Over the Wall: the people who'll live with the architectural decision, day to day, are not reliably the people making it — and the system <em>working</em> afterward doesn't tell you whether it was the <em>right</em> decision, only that it wasn't an immediately <em>fatal</em> one.</p>
<h3>Corncob</h3>
<p>The original AntiPattern is blunt: a Corncob is a difficult person who obstructs and diverts the development process, typically dealt with through tactical, operational, or strategic organizational maneuvering rather than direct confrontation.</p>
<p>What's worth naming explicitly is <em>why</em> this works as well as it does, and for as long as it does. A Corncob's obstruction is rarely framed as obstruction — it's framed as caution, rigor, "just asking questions," or insisting on a process step that conveniently never quite finishes. None of that is free: every round of relitigating a decision, every extra review gate, every "let's circle back" has a cost, paid by the people waiting on the decision. But that cost lands on <em>other</em> people's timelines, not the Corncob's — which means the Corncob never receives the feedback signal that would tell them the obstruction has a price. It's the same mechanism as Design by Committee and Mushroom Management, just personalized: the person creating the friction is structurally insulated from feeling it, so the friction has no reason to stop.</p>
<hr />
<h2>Closing: Reinventing the Wheel, On Purpose</h2>
<p>There's one more entry from the original list worth ending on, because it inverts the usual direction of travel: <strong>Reinvent the Wheel</strong>. In 1998, this was unambiguously a problem — a pervasive lack of technology transfer between projects meant teams kept rebuilding things that already existed elsewhere, at real cost in time, money, and risk.</p>
<p>In 2026, with the sheer density of convenience frameworks available — frameworks that, as several of the chapters above describe, tend to arrive with their own lifecycle, their own conventions, their own lock-in, and their own accidental layering — "reinventing the wheel" sometimes means writing fifty lines of code that do exactly what you need, instead of pulling in a dependency that does that <em>and</em> a hundred things you don't, each of which is now a thing your codebase is entangled with.</p>
<p>There's a second, less obvious payoff. A wheel you build yourself — a rich domain model, built from first principles around your actual concepts (Risk Summaries and all the rest) rather than around a framework's idioms — tends to <em>survive</em>. It can be carried forward across framework versions, across migrations, sometimes across entire platform changes, because it was never coupled to any of those things in the first place. The framework-shaped wheel, by contrast, often has to be substantially rebuilt with every major version bump, every "the framework now does this differently" release — which is the upgrade-treadmill cost from Chapter 1's Vendor Lock-In section, paid again and again. "Reinvent the wheel, once, properly" can be cheaper over a decade than "rent someone else's wheel, and rebuild your dependence on it every year or two."</p>
<p>This isn't a blanket argument against frameworks, any more than the rest of this article is a blanket argument against microservices, CQRS, or Scrum. It's the same observation, one more time, from a different angle: every one of these "cures" was a legitimate answer to a real problem, in some context. The AntiPatterns above aren't lists of things to never do. They're the original, largely-forgotten warning labels — written before any of today's specific technologies existed, describing the <em>shapes</em> of failure with enough precision that, almost thirty years later, you can hold the old description up against today's stack and watch it line up, name for name, almost too well.</p>
<p>Nobody's selling the antidote. But the list was always right there.</p>
]]></content:encoded></item><item><title><![CDATA[How To Prevent Contradicting AI Prompts]]></title><description><![CDATA[You've Either Seen This Already, Or You Will
You're building with AI. It's going well. Features appear quickly, the code is clean, the application works. You describe what you need, the AI implements ]]></description><link>https://blog.leonpennings.com/how-to-prevent-contradicting-ai-prompts</link><guid isPermaLink="true">https://blog.leonpennings.com/how-to-prevent-contradicting-ai-prompts</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[AI]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Wed, 10 Jun 2026 06:58:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/112d628a-c563-4921-970b-82c592ea5253.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3>You've Either Seen This Already, Or You Will</h3>
<p>You're building with AI. It's going well. Features appear quickly, the code is clean, the application works. You describe what you need, the AI implements it, you move on.</p>
<p>Fifty prompts in, maybe a hundred, maybe two hundred — something breaks. Not dramatically. A behaviour that should be consistent isn't. A rule that was established early is being violated somewhere downstream. A customer finds an edge case that produces an answer that contradicts another part of the system.</p>
<p>You dig in. The code at each location looks reasonable. Both implementations made sense when they were written. But they cannot both be right. Somewhere, somehow, the application has developed two incompatible beliefs about how something works.</p>
<p>The immediate instinct is to fix the prompt. Be more explicit next time. More structured. More careful about context. Give the AI better instructions and this won't happen again.</p>
<p>That instinct is wrong. And acting on it — more careful prompting, stricter templates, longer context windows — will delay the next contradiction but will not prevent it. Because the contradiction did not come from the prompting. It came from somewhere the prompting cannot reach.</p>
<p>This article is about where it actually comes from. And about a solution that is older than AI, older than the frameworks that preceded it, and consistently buried by an industry that keeps rediscovering the same problem and forgetting the same answer.</p>
<hr />
<h3>The Prompt Isn't The Problem</h3>
<p>Here is what the contradiction actually looks like.</p>
<p>A B2B sales platform. Early in the build, prompt 75 establishes what an Order is: it belongs to a single customer, ships to a single delivery address, and is invoiced to a single billing contact. Clean, simple, the AI implements it correctly. Every subsequent prompt that touches Orders — discount calculation, delivery estimation, invoice generation, fulfilment tracking, customer notifications — is written on that assumption. None of those prompts are wrong. They are all consistent with the terrain as it was understood at the time.</p>
<p>Eight months later, a different developer picks up a new requirement. Corporate customers need to split a single order across multiple departments, each with their own delivery address and cost centre. Prompt 235 asks for multi-address order support.</p>
<p>The AI implements it correctly. Locally it is reasonable. But it has just redefined what an Order is — from a thing that belongs to one address to a thing that can belong to many. The terrain underneath has shifted. Every prompt written between 75 and 235 that touched delivery address, invoice recipient, or customer identity was built on ground that no longer exists.</p>
<p>The developer writing prompt 235 does not know this. They were not there for prompt 75. Eight months is long enough for team composition to change, long enough for the original assumption to exist only in the memory of someone who may no longer be on the project. There is no artifact they could have consulted. The assumption was never written down. It was the water everyone was swimming in — until it wasn't.</p>
<p>So where do you look? The AI wrote both implementations correctly. The prompts were both reasonable. There was no mistake at the point of instruction. The contradiction exists in the space between the prompts — in the overall model of what an Order actually is, which was assumed but never defined.</p>
<p>And the cascade is not just these two prompts. It is every prompt in between. Reporting, discounting, fulfilment, notifications — all of it was written on the assumption of a single address. None of it is obviously broken. All of it is now wrong in ways that will only surface when a corporate customer places their first multi-department order.</p>
<p>Better prompting cannot fix this. You cannot write a prompt that corrects a contradiction you do not know exists. You cannot ask the AI to be consistent with a model that was never articulated. The problem is not the quality of the instructions. <strong>The problem is the absence of something the instructions could be consistent with.</strong></p>
<hr />
<h3>Why Rebuilding Doesn't Work</h3>
<p>The rebuild instinct is understandable. The application is a mess. The logic is scattered. Nobody knows where anything lives. Start over, do it right this time.</p>
<p>But doing it right this time requires understanding the domain correctly this time. And the domain was not understood correctly before — not because the team was incompetent, but because understanding a domain correctly requires implementing it, adjusting it, hitting the contradictions, resolving them with the people who own the domain, and implementing again. That process takes time. It cannot be replaced by more careful planning.</p>
<p>A rebuild without that process reconstructs the same misunderstandings into a cleaner codebase. The new system starts with higher accidental complexity — the lessons of the previous system encoded as defensive patterns — and the fundamental contradiction is still there, now buried deeper.</p>
<p>This is not a failure of AI. This is the predictable result of building without a map. The AI is doing exactly what it is told. The problem is that what it is told has no center — no single coherent explicit model of the domain that all instructions must be consistent with. Without that center, contradictions are not just possible. They are inevitable. And no amount of rebuilding or re-prompting creates that center retroactively.</p>
<p>The center has to come first.</p>
<hr />
<h3>What Fred Brooks Knew</h3>
<p>The center has to come first. Fred Brooks identified why, sixty years ago, and the industry has spent most of that time ignoring him.</p>
<p>Brooks distinguished between two kinds of complexity in software. <strong>Essential complexity</strong> is the complexity intrinsic to the problem itself — the business rules, the domain constraints, the lifecycle of an Order, the eligibility rules for a customer. It cannot be removed. It does not care what tools you use or what architecture you choose. The business is as complex as it is, and that complexity must be represented somewhere.</p>
<p><strong>Accidental complexity</strong> is everything else. The frameworks, the indirections, the patterns applied without cause, the services that exist because nobody decided where the behaviour actually belonged. Accidental complexity is not intrinsic to the problem. It was introduced by the approach. And unlike essential complexity, it can be reduced — or avoided entirely.</p>
<p>The distinction matters because it defines what is permanent and what is replaceable. The essential complexity of an application — correctly modelled — should outlast every framework it ever runs on, every infrastructure decision ever made about it, every team that ever works on it. It is the permanent part. Everything around it is the replaceable part.</p>
<p>The problem the industry keeps having — with frameworks, with outsourcing, with AI — is that accidental complexity accumulates invisibly while essential complexity remains unmapped. The scaffolding grows. The domain shrinks. You end up with systems that are enormously complicated but that nobody truly understands, because the complication is in the support structure, not in the problem the system was built to solve.</p>
<hr />
<h3>Rivers and Terrain</h3>
<p>Requirements describe motion. A user does something, something happens, something else is notified. User stories are motion. Process diagrams are motion. Even event-driven architecture — at its conceptual heart — is motion wearing a technical hat. The entire tradition of software specification is built around describing flows.</p>
<p>Flows are rivers. And rivers follow terrain.</p>
<p>The river is not the landscape. It is what happens when water finds the landscape and takes the path of least resistance. Change the landscape and the river moves. The river is a consequence, not a cause. Model only the river and you have captured something real — but something that will change every time the underlying landscape shifts.</p>
<p>Terrain is what things <em>are</em>. A watershed. A valley. A ridge that separates two drainage systems. These don't change when the season changes or when a new road gets built nearby. They predate the rivers and they will outlast them.</p>
<p>In software, the terrain is the domain. What an Order actually is. What it means for a customer to be eligible. What obligations a contract creates and what events discharge them. These things don't change because a new payment provider came along or because the fulfilment process got reorganised. The terrain outlasts the rivers by years — often by decades.</p>
<p>Prompt 75 was a river. Prompt 235 was a river. Both made sense as rivers. They contradicted each other because there was no terrain underneath them — no shared model of what an Order actually is that both rivers had to flow through. Without the terrain, each river gets its own private geography. Eventually they meet and the water goes somewhere it was never supposed to go.</p>
<p>The missing center is the terrain. The fix is to build the map before you build the rivers.</p>
<hr />
<h3>The Domain Expert's River</h3>
<p>The natural response is: talk to the domain experts. Capture the requirements thoroughly. Understand the business before building. Let them define the terrain.</p>
<p>This is right in intent and consistently wrong in execution — for a reason that matters enormously.</p>
<p>Domain experts know their domain the way someone knows a city they grew up in. They can navigate it perfectly without being able to draw the map. They know what they do. They know how they do it. They have decades of accumulated practice and judgment. But they know it as motion — as rivers — because motion is how work presents itself. Nobody experiences their job as terrain. They experience it as things they do.</p>
<p>There is a deeper problem. The domain expert's current implementation is already shaped by their tools. The spreadsheet that manages the process, the manual step that exists because the old system could not handle the edge case, the workaround that became standard practice so long ago that nobody remembers it was a workaround — these are all rivers. Rivers shaped by the banks that the tools imposed.</p>
<p>When a business moves from spreadsheets to an application, the naive approach is to reproduce the spreadsheet process in code. The rivers are clearly visible, the domain expert can describe them precisely, the implementation matches. It works. And the technical limitations of the spreadsheet have been permanently encoded into software that has no such limitations.</p>
<p>The constraint that created the workaround is gone. The workaround remains. Now it is load-bearing.</p>
<p>The right conversation with a domain expert is not "how do you do this." It is "why does this need to happen." Not the process — the obligation. Not the river — the terrain feature the river is flowing around.</p>
<p>That question is uncomfortable. It implies the current process might be unnecessary, or suboptimal, or a historical accident. Domain experts have professional identity invested in how they work. The why question asks them to step outside that identity and examine the ground beneath it. Many have never been asked to do that. Some discover, when asked, that the why is murkier than they expected — that two people on the same team have different answers, that the original reason for a rule was forgotten decades ago, that what seemed like policy is actually habit.</p>
<p>The developer who can ask why — and persist through the discomfort until the terrain becomes visible — is doing the hardest and most valuable work in software development. It is not a technical skill. It is closer to archaeology.</p>
<hr />
<h3>The Contextual Center</h3>
<p>When the terrain is mapped — when the domain is understood at the level of what things <em>are</em> rather than what they <em>do</em> — it becomes possible to build a contextual center.</p>
<p>The contextual center is the domain model. Not a database schema. Not a service layer. Not a collection of DTOs. The living, honest encoding of what the domain actually is — its entities, their invariants, their obligations, their lifecycles — expressed in code that a domain expert could read and recognise.</p>
<p>When an Order knows what it means to be cancelled — not as a service method called from somewhere, but as behaviour that belongs to Order because cancellation is something that happens to Orders — the contextual center is doing its job. The logic is findable. It is in one place. A new developer can locate it. A domain expert can verify it. A compliance requirement can be checked against it.</p>
<p>And contradictions become immediately visible. If prompt 235 contradicts prompt 75, the contradiction surfaces the moment you try to encode both in the same place. The Order cannot simultaneously honour two incompatible rules about what it is. The terrain model forces the question that the river implementations never asked.</p>
<p>This is the fix for the contradicting prompt problem. Not better AI. Not more careful prompting. Not an agent that scans for logical inconsistencies. A contextual center that makes contradictions structurally impossible to hide.</p>
<p>The contextual center also provides the simplicity test. If the domain model is honest — if it correctly reflects the terrain — then implementing a new river should be simple. The new requirement finds its place in something that already exists, or reveals through the friction of not fitting that the model needs to grow. Either outcome deepens understanding. Either outcome improves the system.</p>
<p>If the implementation is getting complicated, the terrain is wrong. The complexity is not a problem to be solved with more framework or more abstraction. It is a signal. The domain is pushing back. Something in the model does not match something in reality, and the code is showing you where.</p>
<p><strong>Complexity is the symptom. Simplicity is the proof.</strong></p>
<hr />
<h3>The Scale Problem</h3>
<p>Here is where the industry is currently making its most expensive mistake.</p>
<p>AI works. On small applications, on prototypes, on systems with a limited number of domain objects and a shallow set of business rules, AI-assisted development is genuinely fast and the results are genuinely clean. A developer can build a working application in two days that would have taken two weeks before. That is real. It is not marketing.</p>
<p>The problem is that this success is being treated as proof that the approach scales.</p>
<p>It does not. And the reason it does not is precisely the terrain problem.</p>
<p>On a sufficiently small system, a skilled developer can hold the entire terrain in their head informally. No explicit model is needed because the model exists as intuition. The contradictions surface quickly because the whole system is visible at once. The developer notices when prompt 235 conflicts with prompt 75 because they remember prompt 75. The cognitive map is small enough to carry.</p>
<p>Past the point where that informal map breaks down, everything changes. The developer can no longer hold all of it. The contradictions stop surfacing naturally and start accumulating silently. Each new feature lands in a system that is slightly less understood than it was before. The AI keeps implementing faithfully. The terrain keeps drifting from the model nobody wrote down.</p>
<p>This is the same reason waterfall worked on small projects and failed on large ones. Small projects could be designed upfront because the designer could hold the full domain in their head. Large projects could not because the domain was too complex to fully understand before implementation began. The implementation friction — the discovery process — was not optional on large systems. It was the mechanism by which the design became correct.</p>
<p>The scale threshold is also closer than most teams expect — and AI makes it arrive faster. A real business domain hits the limits of informal terrain mapping sooner than it appears, and AI compresses that timeline further. What took months of traditional development now takes weeks of AI-assisted development. The cognitive collapse happens before anyone realises they are out of their depth. The prototype that took two days felt manageable. The enterprise system that grew from it in two months does not.</p>
<p>A prototype that works is not proof that the architecture scales. It is proof that the architecture works at prototype scale. These are different things, and confusing them is one of the most consistent and expensive mistakes in software development.</p>
<hr />
<h3>Why The Feedback Loop Cannot Be Outsourced</h3>
<p>If the terrain needs to be mapped, and domain experts know the terrain, why not map it thoroughly upfront and then implement? Design the domain model first, hand it to AI, let AI build the rivers.</p>
<p>This is waterfall. And the industry already learned — expensively — why it does not work on complex domains.</p>
<p>Waterfall failed not because the process was badly designed. It failed because its founding assumption was wrong. You cannot fully know a complex domain before you implement it. The implementation is part of how you come to know it.</p>
<p>Code is the only medium that does not permit vagueness. A conversation can agree on a concept while each participant imagines something different. A document can describe a process while leaving its edge cases undefined. Code cannot. When you try to implement something ambiguous, the ambiguity surfaces. The implementation forces the question. That forcing is not a bug in the process. It is the mechanism by which the terrain gets mapped.</p>
<p>Agile's real insight — the one that got buried under standups and story points and velocity metrics — was never about delivery speed. It was about shortening the feedback loop between building and learning. The two-week sprint is not valuable because it ships faster. It is valuable because it forces a confrontation with reality every two weeks. Assumptions get tested. Misunderstandings surface. The terrain model gets corrected before it drifts too far from the domain.</p>
<p>Agile slowed down to learn faster. Each sprint is a correction cycle. The terrain is never assumed to be known — it is continuously refined through the friction of implementation.</p>
<p>Now "AI makes waterfall possible again" is being said as though it is a good thing. As though the problem with waterfall was implementation speed. It was not. The problem was the learning gap — the distance between assumption and correction. AI does not close that gap. It widens it. You design upfront, AI implements the full design in days, and the contradictions are baked in at scale before a single domain expert has seen the system running.</p>
<p>The implementation friction is not waste. It is the curriculum. Remove it and you have output without comprehension. Rivers without terrain. Working software that nobody truly understands, built at a speed that makes the misunderstanding very expensive to correct.</p>
<hr />
<h3>The Outsourcing Lesson</h3>
<p>This specific mistake — removing the implementation friction in pursuit of cheaper, faster output — has been made before. Recently enough that people who lived through it are still working.</p>
<p>In the first outsourcing boom, the promise was cheaper implementation. Move the development work to lower-cost locations. The rivers would still get built. The application would still ship. Why pay more for the same output?</p>
<p>It worked — in the same way that building rivers without terrain works. The applications shipped. The initial costs were lower. And then the invisible invoice arrived.</p>
<p>Because the friction disappeared. The developer working from a specification document in a different building, in a different timezone, had no access to the terrain discovery process. They implemented what was written. What was written was a river. The why never made the journey — not because anyone was careless, but because the why was not in the document. It was in the conversation, in the hallway, in the moment a developer overhears a domain expert explaining something to a colleague and realises the mental model in the code is wrong.</p>
<p>The industry learned — expensively — that proximity was not a preference. It was the mechanism. The daily friction of shared space and shared context, being present when the domain expert says something offhand that rewrites your understanding of the terrain, cannot be async. It cannot be documented. It cannot be specified in a ticket.</p>
<p>The correction was to bring development back. Not for cultural reasons. Not for communication style. To keep the learning loop intact.</p>
<p>The lesson was learned. Then it was forgotten. Because it was never written down as a principle. It was attributed to communication problems, to cultural differences, to time zone friction. The real cause — that implementation is a learning process and learning cannot be outsourced — was never stated clearly enough to survive as institutional knowledge.</p>
<p>"Get onboard with AI or get left behind" is the same sentence as "outsource or get left behind." Same promise. Same mechanism. Same blind spot. Same invoice, on its way.</p>
<hr />
<h3>Unfalsifiability, Again</h3>
<p>Why does this keep happening?</p>
<p>Because working software is unfalsifiable as a measure of quality. The application that shipped — built with rivers and no terrain — is always beating the hypothetical application built with a domain model first. The delivered system always beats the unbuilt better one. There is no comparison. The invisible invoice has no line items. The cost shows up as enterprise complexity, as technical debt, as that is just how large systems work — and it is never traced back to the decision to build rivers without mapping the terrain.</p>
<p>This is how the outsourcing lesson got forgotten. The costs arrived years after the decisions. By then the teams had changed. The attribution was impossible.</p>
<p>This is how frameworks became permanent. Spring, CQRS, microservices, event-driven architecture — each one took a real problem and encoded a solution into a methodology. Each introduced accidental complexity that was invisible against the essential complexity it was supposed to manage. Each generated costs that arrived too late and too diffusely to be attributed. Each got adopted more widely because it was working — at the moment of evaluation, the only moment that counted. The pattern became the answer. The practice it was meant to serve got lost inside it.</p>
<p>Domain-Driven Design followed the same path. Its early emphasis on shared language and rich domain models — the genuinely useful insight — gradually became overshadowed by discussions about bounded contexts, repositories, service layers, and event-driven decomposition. The vocabulary survived. The underlying purpose largely did not. Teams learned to say domain model while building something that looked like a domain model from the outside and functioned as a collection of data structures with behaviour scattered across service classes. The industry did to DDD what it does to everything else: turned a way of understanding reality into a collection of implementation patterns.</p>
<p>And this is how AI will follow the same path. The small application works. The prototype is clean. The approach is validated — at the scale where informal terrain maps are sufficient, at the scale where the developer can hold it all in their head. The success is real. And it proves nothing about what happens at the scale where it matters.</p>
<p>Unfalsifiability will do the rest.</p>
<hr />
<h3>The Career Ceiling Nobody Discusses</h3>
<p>Junior developers learn rivers. That is where everyone starts, and it is the right place to start. Rivers are visible, implementable, testable. You can see when they work.</p>
<p>Medior developers begin to notice that rivers have shapes — that some implementations feel natural and others feel like fighting the problem. This is the first intimation of terrain. The friction is trying to teach something.</p>
<p>Senior developers think in terrain first. They talk to domain experts and hear why rather than how. They implement rivers to test terrain hypotheses and adjust when the implementation pushes back. They read complexity as a diagnostic signal rather than a problem to be solved with more pattern.</p>
<p>The step from medior to senior is the step from river-thinking to terrain-thinking. And it is a step that frameworks and patterns have systematically prevented — not because the developers using them lack capability, but because the tools never forced the question. The framework absorbed the friction that would have taught it. The accidental complexity had somewhere to hide. The essential complexity stayed unmapped. The developer got faster at applying patterns, not better at questioning them. The work never demanded more, so more was never developed.</p>
<p>This is not an indictment. It is a description of a system that produced exactly what it was designed to produce. The market said learn the framework, get the job. The framework said here is the structure, fill it in. The application shipped. Unfalsifiability validated everything. The question of whether there was terrain underneath never arose because it never had to.</p>
<p>A significant proportion of working developers entered the field through routes — bootcamps, self-teaching, career changes — that are entirely oriented around framework fluency because that is what gets you hired quickly. That is a rational response to market incentives, not a character flaw. But it means the dominant population of working developers has been optimised for exactly the skill AI is now making unnecessary.</p>
<p>AI does not eliminate these developers. It transforms them into AI operators. The framework templates get replaced by prompts. The pattern application gets replaced by merge request reviews. The output looks similar. The speed increases. And the bar lowers further, because prompting requires even less structural understanding than filling in a framework template did.</p>
<p>What does not change is the invoice. The AI operator builds the same rivers faster, accumulates the same terrain debt faster, and hits the same ceiling faster. The application is cheaper to start and more expensive to maintain — the same curve as always, now compressed. And unfalsifiability protects the transition just as it protected everything before it. The framework developer becomes the AI operator and nothing looks different until the cascade arrives.</p>
<hr />
<h3>How AI Should Actually Be Used</h3>
<p>For small applications, AI as primary implementor is fine. The scale section explains why — the terrain is shallow enough to hold informally, the contradictions surface quickly, the cognitive map fits in one head. There is no problem to solve at that scale that AI creates.</p>
<p>The problem starts when the application grows, or when the development team grows. Past the point where informal terrain maps break down, AI as primary implementor becomes the mechanism by which contradictions accumulate invisibly. Not because AI is the wrong tool — because the approach that worked at small scale does not transfer. Something has to change.</p>
<p>What changes is how AI is used.</p>
<p>AI is a pattern matcher with a vast, structured lexicon — and crucially, with understanding of what that lexicon contains. It has processed everything written about software, technology, architecture, and domains. That is not nothing. That is a remarkable instrument, if you use it for what it actually is.</p>
<p>What it cannot do is discover terrain. A domain expert's specific business, with its specific history and specific constraints and specific why — that terrain has never been written down anywhere AI was trained on. It exists in conversation, in friction, in implementation. AI has no access to it. The developer is the only instrument that can pick it up.</p>
<p>Which means AI and the developer are genuinely complementary. AI works on the known. The developer works on the specific. They operate on completely different material.</p>
<p>As a discussion partner AI is genuinely useful — thinking out loud, testing an argument, asking what happens if a particular assumption is wrong. Not as a modeller, not as a designer. The conversation is the value. The understanding stays with the developer.</p>
<p>As a technology consultant it earns its place completely. How does this technology work? What are the tradeoffs? How is this done in Java? These are questions AI answers well precisely because they are pattern questions — answered from a lexicon of everything written on the subject. The developer takes that knowledge and decides what it means for the domain model. That decision is never delegated.</p>
<p>The code is written by the developer. Always. Because the act of writing it is the act of learning. The friction of making something work is how the terrain model gets validated. Outsource that friction and you outsource the understanding.</p>
<p>Used this way, AI does not prevent learning. It removes the noise that would otherwise slow it down. The technology questions that used to cost an afternoon now cost ten minutes. Those minutes go back into the terrain work. The friction that was just overhead is gone. The friction that actually teaches something is preserved. The learning does not stop — it accelerates.</p>
<hr />
<h3>Two Approaches, Two Invoices</h3>
<p>AI does not level the playing field between the terrain approach and the river approach. It widens the gap between them.</p>
<p>The AI operator — prompting rivers into existence without a contextual center — builds faster than a framework developer ever could. The initial output is impressive. The application ships quickly. But the terrain debt accumulates at the same rate as always, now compressed into a shorter timeline. The contradictions arrive sooner. The cascade of invalidated assumptions hits harder. The ceiling is the same ceiling. The invoice is the same invoice. It just arrives faster, with more confidence on the way there.</p>
<p>The terrain mapper uses AI differently. Not as a primary implementor but as a mirror, a feedback loop, and a technology consultant. The discovery process still happens. The domain expert conversations still happen. The why questions still get asked. The contextual center still gets built. But the iteration cycles are faster, the edge case surfacing is faster, the technology decisions are faster. AI compresses the learning without bypassing it.</p>
<p>This means the cost curve that was already cheaper in the long run gets cheaper in the short run too. The terrain mapper moves faster than before without accumulating the debt that was previously the price of moving fast.</p>
<p>From the outside, at month two, the two approaches look identical. Both are shipping quickly. Both are producing working software. Unfalsifiability does its work. Nobody sees the difference until the contradictions start surfacing — by which point the AI operator is already describing it as enterprise complexity and looking for a pattern to absorb it.</p>
<p>The industry is measuring AI's value in speed. Speed is real. But speed applied to the wrong approach does not reduce cost. It compresses the timeline to the invoice. The question was never how fast you can build rivers. It was always whether the terrain underneath them is honest.</p>
<p>AI makes the right approach faster. It makes the wrong approach faster too. The difference is what you are left with when the speed runs out.</p>
<hr />
<h3>The Solution Is Thirty Years Old</h3>
<p>There is no new methodology needed here. The problem is real and urgent and the answer has been available for decades — practised long before it acquired a name, and largely buried since it did.</p>
<p>Build a domain model. Not a framework-prescribed structure, not a pattern applied because the textbook recommends it — an honest, simple encoding of what the domain actually is. Make it the contextual center of the application. Keep it simple enough that a domain expert can read it and recognise it. Keep it simple enough that complexity registers as a signal when it appears.</p>
<p>Talk to domain experts about why, not how. Push through the river they offer you to the terrain underneath. Distinguish what the business requires from what the spreadsheet required. Implement rivers one at a time, learning the terrain as you go. Adjust the model as understanding deepens — because understanding will deepen, because it never stops deepening, and because that is the point.</p>
<p>Build the shared vocabulary between the development team and the domain experts so the words in the code mean the same thing as the words in the business. Not because naming is important for aesthetic reasons, but because shared language is how you know you are mapping the same terrain. When a developer and a domain expert use the same word and mean different things, the terrain model is wrong. The language makes that visible before the code does.</p>
<p>Accept that the first map is wrong. It will be. That is not a failure of the approach — it is the approach working. The map gets corrected through implementation. Each river teaches you something. Each correction makes the next river easier. The terrain model should get more true over time, not more obscure. That is the measure of whether the process is working.</p>
<p>The contradiction between prompt 75 and prompt 235 is the same contradiction that lived in the fat service class, in the three microservices with incompatible Order logic, in the spreadsheet workaround encoded into the application. Different tools, different eras, same missing center.</p>
<p>The center was always the answer. It still is.</p>
<p>Build the map before you build the river. The rivers will be faster for it, and they will still be running in fifteen years.</p>
<hr />
<p><em>This article is a follow-up to The Invisible Invoice: The Cost of Building Software Without Understanding It.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Invisible Invoice: The Cost of Building Software Without Understanding It]]></title><description><![CDATA[The Wrong Measure
Software doesn't fail when it stops working. It fails when the cost of keeping it working exceeds what anyone is willing to pay.
That distinction sounds simple. Its consequences are ]]></description><link>https://blog.leonpennings.com/the-invisible-invoice-the-cost-of-building-software-without-understanding-it</link><guid isPermaLink="true">https://blog.leonpennings.com/the-invisible-invoice-the-cost-of-building-software-without-understanding-it</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[Rich Domain Model]]></category><category><![CDATA[Design]]></category><category><![CDATA[ROI (Return on Investment)]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Sun, 07 Jun 2026 18:12:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/51c1799b-8e5b-444e-bcbb-2724512648fa.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The Wrong Measure</h2>
<p>Software doesn't fail when it stops working. It fails when the cost of keeping it working exceeds what anyone is willing to pay.</p>
<p>That distinction sounds simple. Its consequences are not.</p>
<p>The industry measures software by whether it works. Delivered on time, passes the tests, satisfies the requirements — success. The team moves on. The architecture gets praised, the approach gets repeated, the pattern gets adopted elsewhere. Nobody measures the cost of keeping it working six months later, three years later, after two team changes and four rounds of new requirements. That cost exists. It is often large. It is almost never attributed to the decisions that caused it.</p>
<p>This is the central problem of software development, and it has a name: unfalsifiability. There is no comparable version of the same application, built differently, to measure against. The messy system that shipped is always beating the elegant system that wasn't built. The working application is always beating the hypothetical better one. You cannot prove a different approach would have been cheaper, because that approach was never taken.</p>
<p>If it works, it's a success. And that is where all the trouble starts.</p>
<h2>You Don't Pay for Complexity When You Build It</h2>
<p>You pay for it every day afterward — and the invoice arrives without a line-item explanation.</p>
<p>A framework that saves three weeks of initial development might cost three weeks a year in version upgrades, security patches, and breaking changes. Spread across five years, across a team of six, that initial saving is long gone. But nobody connects those upgrade sprints to the original decision to adopt the framework. The cost is real. The attribution is absent.</p>
<p>This is what unfalsifiability does to cost. It doesn't make costs disappear — it makes them untraceable. The expense shows up as "enterprise complexity," as "technical debt," as "that's just how large systems work." It is rarely traced back to an architectural decision made three years ago by people who are no longer on the team.</p>
<p>Consider what is actually being counted when someone says "we chose this framework to move faster." They are counting the lines of code they wrote. They are not counting the lines of code they are now responsible for — the framework itself. Those lines execute. They have bugs. They have CVEs. They have opinions about how your application should be structured, encoded in defaults and conventions that were answered before you understood your own domain.</p>
<p>A domain-focused implementation without a heavyweight framework is typically smaller in raw lines of code than the template and configuration code required to set that framework up. Before a single line of business logic is written. Add the framework's own codebase — the code being executed on every request — and the surface area for bugs, security vulnerabilities, and maintenance burden has expanded by an order of magnitude. For what? For the privilege of not writing code yourself.</p>
<p>A Ferrari is faster than a tractor on every measurable dimension. It is also completely useless in a field. And it costs more to buy, more to insure, more to service, and requires specialists to repair. Every dimension of cost is higher, for a vehicle that performs worse at the actual job. The sophistication is not the problem. The mismatch is the problem. And unfalsifiability means you never have to confront the mismatch directly — the Ferrari is technically moving across the mud, but you can't see how much damage it's doing to the soil, or how much you're spending on replacement clutches.</p>
<h2>Know What You Are Doing</h2>
<p>Fred Brooks gave the industry a precise vocabulary for this problem fifty years ago, and the industry has largely ignored it.</p>
<p>Essential complexity is the complexity intrinsic to the problem itself. It cannot be removed. It is the business rules, the domain constraints, the lifecycle of an order, the eligibility rules for a customer, the regulatory requirements of a financial product. This complexity exists whether you model it or not. The business is as complex as it is.</p>
<p>Accidental complexity is everything else. The frameworks, the indirections, the patterns applied without cause, the services that exist because nobody decided where the behavior actually belonged. Accidental complexity is not intrinsic to the problem. It was introduced by the approach.</p>
<p>The critical implication is this: you can only minimise what you can see. And in most software built today, the essential complexity is invisible — scattered across service classes, duplicated across microservices, buried under framework conventions — while the accidental complexity is everywhere and growing. The map is the problem, not the territory.</p>
<p>Getting this right requires knowing what the application must do. Not what it does — what it must do. The mandatory behavior, the non-negotiable rules, the core of what this system exists to perform. Everything else is optional. Everything else has a cost. And that cost should be justified, explicitly, before it is paid.</p>
<h2>The Tractor on the Field</h2>
<p>Simplicity is not an aesthetic preference. It is the engineering discipline of not paying for things you don't need.</p>
<p>The simplest solution that honestly expresses what the application must do is not the lazy solution. It is the hardest solution to find, because it requires actually understanding the problem before reaching for the tools. It is also the solution that survives. Not because simple things are inherently more durable, but because simple things are easier to understand, easier to change, and easier to replace when understanding deepens.</p>
<p>Implementation is a learning process. You do not know the domain fully when you begin. You discover it through building, through conversation with the people who own it, through the friction of encoding rules that turn out to be more nuanced than they first appeared. The application you build in month one is not the application the business needs in month eighteen. The question is whether you built something that can become that application, or something that has to be replaced by it.</p>
<p>A minimalist approach — not sparse, not incomplete, but precisely sufficient — is the tractor on the field. Unglamorous. Fit for purpose. Still running in fifteen years. Serviceable by someone who wasn't there when it was built. Modifiable without calling a specialist. Cheap to operate on the days nothing goes wrong, and cheap to fix on the days something does.</p>
<p>The Ferrari has its place. That place is not most software. And unfalsifiability means the Ferrari stays in the field long after it's clear it isn't working, because there's no other field to compare it to.</p>
<h2>Make the Essential Complexity Visible</h2>
<p>The domain model is not a goal. It is not a purity exercise. It is not an architectural pattern to be applied because the textbook recommends it.</p>
<p>It is a tool for making the essential complexity of the application visible, centralized, and honest.</p>
<p>When the business logic of an Order lives in the Order — when an Order knows what it means to be cancelled, what it means to be fulfilled, what state it must be in before shipment can proceed — that logic is findable. It is in one place. A new developer can locate it. A domain expert can read it and recognize it. A compliance auditor can verify it. When that same logic is scattered across service classes, duplicated in three microservices, and partially encoded in database triggers, it is effectively invisible. It exists. It executes. Nobody knows exactly where it is or whether the three copies agree with each other.</p>
<p>The legibility bar matters here, and it should be set higher than most developers expect. A domain expert who is not a developer should be able to read the core domain objects and understand what the application is doing and why. Not the implementation details — the behavior. What is an Order? What can it do? What does the business enforce at that level? If the answer to those questions requires navigating framework annotations, service orchestrators, and DTO mappings, the essential complexity is not legible. It is hidden. And hidden complexity is expensive complexity, because it has to be rediscovered every time it needs to change.</p>
<p>This is also where consistency becomes structural rather than aspirational. If everything Order-related happens in Order, then contradicting logic between two parts of the system is immediately obvious — because there is only one place to look. In a system where order logic lives in seventeen service methods across four microservices, contradiction is not just possible, it is inevitable. And nobody will notice until a customer finds it.</p>
<p>The domain model is the centralized, codified, documented expression of what the business is. As long as the business continues in the same domain, that model should not have to be rewritten. The framework it runs on can be replaced. The delivery mechanism can change. The infrastructure can evolve. The essential complexity, correctly encoded, is the permanent part. Everything around it is the replaceable part. Getting that boundary right is the engineering challenge. Getting it wrong is what generates the invisible invoice.</p>
<h2>Keep Domain Experts Close</h2>
<p>You cannot model what you do not understand. And you cannot understand a business domain from requirements documents, user stories, and ticket descriptions alone.</p>
<p>Requirements describe motion through a system — a user does something, something happens. They teach you the rivers. A domain model teaches you the terrain. Without understanding the terrain, you are always following the water, never knowing where you are.</p>
<p>Domain experts — the people who actually own the business processes, who know why the rules are the rules, who feel it when the software gets something wrong — are not stakeholders to be consulted at sprint reviews. They are the source of the essential complexity. The conversation with them is not a requirements-gathering exercise. It is the modeling work itself.</p>
<p>The UI plays a specific role here. Not the polished end-user interface, but an early working interface that makes the domain model visible to the domain expert in a form they can evaluate directly. Two people can use the same word and mean different things. They can agree on a description and disagree entirely on what it describes. That misalignment is invisible in conversation. It is undeniable on a screen. Building something the domain expert can navigate is the fastest way to find out whether the model is honest.</p>
<p>Implementation is a learning process. The model you have at the end of month one is not the model you will have at the end of year one. What you are building is not just software — it is accumulated understanding of what the business actually is. That understanding should be encoded in the model. The model should get more true over time, not more obscure.</p>
<h2>Beware of the Hidden Costs</h2>
<p>Tools should be selected by one criterion: do they serve the essential complexity, or do they obscure it?</p>
<p>A framework that handles persistence, wiring, or HTTP without imposing opinions about where behavior should live is earning its place. A framework that answers structural questions before you have understood your own domain — that substitutes a recipe for architectural thinking — is introducing accidental complexity from day one. You are paying for its opinions whether you wanted them or not.</p>
<p>The distribution question deserves particular directness. Event-driven architecture, CQRS, and microservices each originated as responses to real problems at genuine scale. Each carries a significant and permanent cost: distributed tracing, eventual consistency management, versioned service contracts, deployment orchestration, network failure handling. And the loss of one guarantee that a single well-modeled application provides for free — transactional consistency. Once operations are distributed across services and event queues, rollback is no longer a database primitive. It is an engineering problem, solved with compensation logic and saga patterns, maintained indefinitely.</p>
<p>There is also a structural cost that rarely gets discussed: distribution freezes your context boundaries. A monolith with a clear domain model can redraw its internal boundaries as understanding of the domain deepens — because learning continues, and the model should move as you learn. Once you have cut service boundaries and built contracts and deployment pipelines around them, that learning is frozen. Every misunderstanding about the domain that was encoded in a service boundary is now a permanent architectural feature. You pay for it in coordination overhead, in contract negotiation, in the impossibility of the refactoring that would have taken an afternoon in a monolith.</p>
<p>These are expensive tradeoffs. They are justified at genuine scale — when one part of the system genuinely needs to scale independently, when teams are large enough that shared deployment is a bottleneck, when the operational investment is proportionate to the problem. For the vast majority of software, they are not justified. And unfalsifiability means they persist anyway, because the cheaper alternative was never built.</p>
<p>The economic threshold is real. Distributed architectures make sense when you have hundreds of millions of rows of hot data, hundreds of thousands of concurrent users, and extreme load skew requiring parts of the system to scale independently by orders of magnitude. Most software never reaches that threshold. Most software pays the distribution tax anyway, and calls it modern.</p>
<h2>Longevity Is the Return on Investment</h2>
<p>Every argument in this article points to the same place, and the direction of travel is not what most people expect.</p>
<p>When essential complexity is managed — made visible, centralized, and honest — ongoing costs drop. The model is the documentation, so documentation cannot go stale. The logic is in one place, so contradictions cannot accumulate quietly. New requirements find their place in something that already exists, or reveal through the friction of not fitting that the model needs to grow. Either outcome deepens understanding. Either outcome improves the system. Understanding compounds. The software gets easier to work with as it matures, not harder.</p>
<p>When essential complexity is not managed, costs compound. Each new requirement lands on top of whatever was there before, in whatever shape it happened to be in. The essential complexity becomes harder to find, harder to verify, harder to change without touching something else. The team grows but delivery does not improve. The diagnosis is always the same: enterprise complexity, accumulated technical debt, that's just how large systems work. It is rarely diagnosed as what it actually is — the predictable consequence of building without a map.</p>
<p>That is the ROI argument for managing essential complexity, and it is already strong. But there is a third level that the industry almost never discusses, because it inverts the assumption that rigor costs more upfront.</p>
<p>When you understand the essential complexity of a system before you build it, initial development costs drop too.</p>
<p>Not because the work becomes easier. Because you stop doing work that was never necessary. You select the tools the problem requires rather than the tools you already know. You do not adopt a framework whose opinions you will spend years working around, because you can see that those opinions do not fit your domain. You do not distribute a system that did not need to be distributed, because you can see that the transactional consistency you are about to give up is load-bearing. You do not build the service, the saga, the compensation logic, the versioned contract, the deployment pipeline — because you can see that the problem those things solve is a problem you created, not a problem you had.</p>
<p>This produces two diverging cost curves that never cross — because the essential complexity approach was never the more expensive one. It only appeared that way because the costs of the alternative were invisible.</p>
<p>The essential complexity approach starts lower — no unnecessary tooling, no framework opinions to work around, no infrastructure for problems you do not have. Each new function point finds its place in a model that already understands the domain. A new business rule is a method on the object that owns it. The cost per function point decreases over time as understanding compounds and the model absorbs requirements rather than accumulating them.</p>
<p>The non-essential complexity approach starts higher — the framework, the boilerplate, the services, the distribution tax paid before a line of business logic exists. Each new function point adds new services, new mappings, new contracts. Logic that should live in one place gets duplicated across three. The cost per function point increases over time, because every addition lands in a codebase that is slightly harder to understand than it was before. The curve climbs until it hits a ceiling — the point where replacement is cheaper than continued maintenance or extension. At which point the system gets rebuilt. Without the domain model, the rebuild reconstructs the same misunderstandings into the new version, faster and with more confidence. The new system starts higher than the original did, climbs faster, and hits the ceiling sooner.</p>
<p>The Ferrari does not just cost more to run. It costs more to buy. Understanding the domain first means you arrive at the dealership knowing you need a tractor — and you leave without the Ferrari, without its finance agreement, and without the specialist on retainer for the day it breaks down.</p>
<p>Working software is not the asset. The understanding encoded in the software is the asset. A working application without that understanding is a disposable item — it worked when it left the factory, and it was not designed to be serviced.</p>
<p>The tractor is still in the field in year fifteen. The Ferrari is in the shop, waiting for a specialist who knows the model. The tractor cost less on day one, costs less every year, and is still doing the job it was bought to do.</p>
<p>The industry treats rigor as the expensive path. It is the only path that gets cheaper as you walk it.</p>
<p>The invisible invoice arrives eventually. The only question is whether you chose a vehicle designed for the field — or whether someone is still trying to explain why the clutch keeps burning out.</p>
<hr />
<h2>A Note From My Personal Experience</h2>
<p>The two cost curves described in this article are not theoretical. I have worked on and maintained two large systems over fifteen and seven years respectively. In both cases the core domain model has never needed to be rewritten. On the second system, the UI was replaced entirely — a complete rebuild — without the domain module being opened. The permanent part stayed permanent. The replaceable part was replaced. Exactly as intended.</p>
<p>I have also introduced this approach into stalled projects at large organizations — systems where delivery had slowed, complexity had accumulated, and nobody could confidently explain where the business logic lived. In each case, making the essential complexity visible and centralized was what unstalled them. Not a new framework. Not a new architecture. Understanding what the system was actually for, encoded in a place everyone could find.</p>
<p>The approach works. It has worked for fifteen years on one system, seven on another, and across multiple recoveries of projects that had lost their way. The cost curve is real. The only question is which one you want to be on.</p>
]]></content:encoded></item><item><title><![CDATA[AI and Enterprise Software Development]]></title><description><![CDATA[AI is the most significant shift in software development since the internet. Not because it changes what software can do — but because it makes a distinction visible that the industry has been treatin]]></description><link>https://blog.leonpennings.com/ai-and-enterprise-software-development</link><guid isPermaLink="true">https://blog.leonpennings.com/ai-and-enterprise-software-development</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai software development]]></category><category><![CDATA[Java]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Wed, 03 Jun 2026 08:01:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/54f52242-c8a8-42e3-b21a-1cf645f6630e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>AI is the most significant shift in software development since the internet. Not because it changes what software can do — but because it makes a distinction visible that the industry has been treating as a preference for thirty years.</p>
<p>Some software needs to work today. Some software needs to keep working — correctly, maintainably, through changing requirements and changing teams — for ten or fifteen years. These are not the same engineering problem. They never were. The tools and practices that serve the first actively undermine the second. And AI, by doing the first faster and better than any human developer, has made it impossible to keep pretending the distinction doesn't matter.</p>
<hr />
<h2>What Enterprise Software Actually Is</h2>
<p>Enterprise software is not defined by its size, its industry, or its technology stack. It is defined by its relationship with time.</p>
<p>An enterprise application must be correct today. It must remain correct as the business domain evolves around it. It must survive the teams that built it. It must adapt to requirements that nobody could fully predict when it was written. Implementation is not the primary challenge. Understanding — correct, durable, continuously updated understanding of the business domain — is the primary challenge. Implementation follows from that, and is the smaller part of the work.</p>
<p>This is why the domain model is more important than the working application.</p>
<p>That statement will make most developers uncomfortable, and it should. The working application is the visible artifact — the thing that gets demonstrated, delivered, and measured. But a working application without a domain model is a disposable item. It works when it leaves the factory. It was not designed to be serviced. When the business changes around it — and it will — the economics of repair exceed the economics of replacement. Except you cannot simply replace it, because without the model, you rebuild the same misunderstandings into the new version, faster, with more confidence.</p>
<p>A domain model without a working application, on the other hand, is a foundation. Getting it working from that foundation is the smaller problem. It will stay working because the mechanism that keeps it correct is still present, still legible, and still honest.</p>
<p>This distinction — between software built for today and software built to remain correct over time — was always there. It was always a structural choice with structural consequences. It was just treated as a preference, because the consequences were invisible. There was never a comparable version of the same application, built differently, to measure against. The cost of not modeling was permanently hidden.</p>
<p>AI makes that hiding impossible.</p>
<hr />
<h2>The Invisible Breakdown</h2>
<p>Enterprise software has a failure mode that almost nobody correctly diagnoses, for a simple reason: diagnosing it requires a reference that doesn't exist.</p>
<p>When an enterprise system becomes difficult to maintain — when features take longer than they should, when bugs touch more than they should, when the team grows but delivery doesn't improve — the diagnosis is almost always the same: this is what enterprise development looks like. Complex domain. Large codebase. Accumulated technical debt. The solution offered is more developers, more process, more tooling.</p>
<p>The real diagnosis requires asking: what would this system look like if it had been built around a rich domain model from the start, maintained over the same period? That version was never built. The comparison is not available. So the decay gets attributed to enterprise complexity rather than to the absence of the structure that would have prevented it.</p>
<p>What the industry misread as the natural difficulty of enterprise development is in most cases the consequence of a broken PDCA cycle. Plan, do, check, act. In enterprise software, the Check step requires being able to find what was encoded, verify it against current understanding, and update it. That requires the essential complexity of the system to be visible, owned, and in one place.</p>
<p>Procedural development does not slow this cycle down. It breaks it. Each piece of logic that lives somewhere convenient rather than somewhere correct, each duplicated rule, each behavior scattered across service classes rather than owned by the concept it belongs to — each one removes a piece of the map the Check step needs. Eventually the map is gone. New requirements get added on top of existing ones without anyone being confident what the existing ones actually do. Contradictions accumulate. The system becomes a record of everything that was ever asked for, in chronological order, with no coherent structure underneath.</p>
<p>This is not enterprise complexity. It is the consequence of building for today without encoding understanding in a form that survives tomorrow. And it was always going to happen — because procedural code has no mechanism for keeping the Check step alive.</p>
<hr />
<h2>Two Practices That Keep the Cycle Running</h2>
<p>There are two practices that prevent this breakdown. They are not a methodology. They cannot be certified. They are disciplines — each with a precise job, sequential and mutually dependent.</p>
<h3>1. The UI: Verifying the Ubiquitous Language</h3>
<p>The first practice is building a UI in the first month of any project — not for end users, not for customers, but as the primary instrument for verifying that the developer and the domain expert are actually talking about the same thing.</p>
<p>This requires immediate clarification. The instinct on projects without obvious end-user interfaces — data pipelines, processing engines, integration layers — is to defer or skip the UI entirely. That instinct is wrong in a specific and consequential way. The UI is not a deliverable. It is a yardstick for the ubiquitous language — the shared vocabulary between developer and domain expert that the entire system depends on being correct.</p>
<p>In twenty-five years of building software with business owners and functional application managers, the same sentence appears at nearly every meaningful discussion: <em>"It sounds correct, but I need to see it working."</em> This is not a failure of imagination. It is an honest statement about the limits of language as a medium for domain transfer. Two people can use the same word and mean different things. They can agree on a description and disagree entirely on what it describes. That misalignment is invisible in conversation. It is undeniable on a screen.</p>
<p>The UI forces concepts into a form the domain expert can evaluate directly. The concept the developer calls an <em>order</em> and the business expert calls an <em>order</em> either map to the same thing or they don't — and the screen is where you find out. The flow the developer modeled as a linear sequence and the business expert understands as a set of parallel states either match or they don't — and the screen is where you find out. No whiteboard session, no requirements document, no sprint review produces this verification with the same precision and immediacy as a working interface the domain expert can navigate directly.</p>
<p>Conceptual thinking is genuinely scarce in software development. Developers are trained to implement described behaviour, not to reconstruct the mental models that produced the description. The UI compensates for this structurally. It makes the domain model visible and therefore falsifiable — which is the only condition under which a domain expert can tell you whether you understood them.</p>
<p>It does not need to be polished. It needs to work, built in semantic HTML that will survive the project's lifetime without becoming a maintenance liability of its own. Its purpose is not presentation. It is verification.</p>
<p><strong>The UI is how you learn the domain correctly. It is the input to everything that follows.</strong></p>
<h3>2. The Domain Model: Encoding That Learning Durably</h3>
<p>The second practice is encoding what you learned in a rich domain model — and this is where the oldest lesson in software development applies in its most consequential form.</p>
<p>Keep what belongs together in the same place, so nobody has to explain where anything is.</p>
<p>That is the difference between a thousand-piece puzzle and a twenty-five-piece puzzle. How orders are treated in the system can be found in the Order domain object. One place. Non-duplicated logic. Non-contradictory logic. A new developer, a new requirement, a compliance audit — all of them go to the same place and find the same answer.</p>
<p>The domain model is a set of objects, each playing a defined role in the business domain, each owning the responsibility that role entails. Not data structures with methods bolted on. Objects that know what they are responsible for, enforce their own rules, and carry their own behavior. An Order that knows what it means to be cancelled. An Interaction that owns the transactional boundary — carrying the current user, the active roles, the deferred consequences that execute at its close. A KYC entity that owns the rules governing its own assessment.</p>
<p>This is what keeps the PDCA cycle alive. The Check step can still reach what was Done — ten years later, after three teams, through changing requirements. The domain model is the map. As long as the map is honest and current, the cycle runs. New understanding finds its place. The model grows more true over time rather than more obscure.</p>
<p>A consequence of this structure that is rarely discussed is what it provides for free. When the domain model owns its behavior and an Interaction owns the transactional boundary, a failed operation rolls back completely — the database change, the email that hadn't been sent, the downstream consequence that hadn't fired. JDBC transaction rollback is a primitive. Consistency is structural. There is no compensation logic to write, no saga pattern to implement, no consistency verification to run after the fact. The guarantee emerges from the model.</p>
<p><strong>The domain model is how you keep doing the correct thing, indefinitely. It is not documentation about the system. It is the system, in its most honest and durable form.</strong></p>
<hr />
<h2>The Relationship Between the Two</h2>
<p>These two practices are sequential and mutually dependent in a precise way.</p>
<p>The UI without the domain model produces correct understanding encoded incoherently. The domain expert confirmed the language. The developer understood the domain. And then scattered it across service classes in a way nobody can find or follow three years later. The understanding was correct and it decays anyway — into the codebase, across layers, through framework conventions — until the next developer cannot reconstruct it.</p>
<p>The domain model without the UI produces a cohesive model of something that may be wrong. Elegant, traceable, internally consistent, and externally misaligned. The developer's interpretation was never verified against the person who actually knows.</p>
<p>Together they form a self-correcting cycle. The UI surfaces what the domain expert actually means. The domain model encodes that meaning durably. The UI surfaces the encoded meaning back to the domain expert for verification. The cycle is self-sustaining — not just at the start but throughout the life of the application.</p>
<p>This is also why you can survive on the domain model alone — a slightly wrong model is still fixable, because the wrongness is visible and locatable, and the PDCA cycle is still running — but you cannot survive on the UI alone. Correct understanding that was never durably encoded dies with the people who held it.</p>
<p>One practice optimizes doing the correct thing and discovering what the correct thing is.</p>
<p>The other documents that discovery in the most undistortable form possible — for today's team, for tomorrow's maintainers, for the requirements nobody has thought of yet.</p>
<hr />
<h2>What the Industry Built Instead</h2>
<p>Without these two practices, enterprise software does not fail silently. Teams feel the friction. The PDCA cycle breaks. And the industry, characteristically, built architectures to manage the symptoms.</p>
<p>Before reaching for those architectures, one question is worth asking honestly: does this system genuinely require the same scale as the organizations that invented these patterns — or is the complexity being solved a complexity that was created by code written without a domain model?</p>
<p>CQRS, event-driven architecture, microservices — each originated as a response to real problems at genuine scale. Each carries a significant integration tax: distributed tracing, eventual consistency management, versioned service contracts, deployment orchestration, network failure handling, and the permanent loss of the one guarantee a single domain model provides for free — transactional consistency. Once operations are distributed across services and event queues, rollback is no longer a database primitive. It becomes an engineering problem, solved with compensation logic and saga patterns, maintained indefinitely, on top of the original modeling problem that the architecture was never asked to fix.</p>
<p>A rich domain model makes most of that complexity structurally unnecessary. Not by being clever — by keeping what belongs together in one place, so the system never generates the problems these architectures were designed to manage.</p>
<hr />
<h2>Frameworks and the Question They Answer Too Early</h2>
<p>The same logic applies to the framework ecosystem, with one distinction worth making precisely.</p>
<p>Frameworks like Spring exist to provide implementation convenience for developers who should not need to understand the underlying mechanisms. That is a blunt description, but it is an accurate one. Spring wires things together so you don't have to understand the wiring. It provides a transaction model so you don't have to manage transactions. The value proposition is working software without deep understanding of what produces it.</p>
<p>That value proposition has a structural cost. Spring doesn't just provide convenience — it answers structural questions before you've understood the domain. The controller-service-repository recipe is not a neutral scaffold. It is an answer to where behavior should live, given before the domain had a chance to answer that question itself. Engineers who learned Spring as their foundation did not learn to reason about structure — they learned to apply a structure that was handed to them. When the recipe always fits, the judgment to know when it doesn't is never developed.</p>
<p>The relevant question for any framework is: does removing it force you to split essential complexity? If yes, the framework earns its place. If no, it is providing implementation convenience that increasingly AI can provide — without the framework's architectural opinions, without its version upgrade cycle, and without the recipe it substitutes for structural thinking.</p>
<p>Hibernate passes that test. Without it, the domain object and its persistence representation become two separate things — a DTO, a populator, a translation layer that has to be maintained in sync with the domain object when it changes. Hibernate collapses that into the domain object itself. The annotations are honest declarations of what the object requires from persistence. The object loads as itself. The domain model remains the single source of truth. Hibernate serves the model's integrity rather than substituting for structural thinking.</p>
<p>Spring fails that test. Removing Spring does not split essential complexity. It removes the recipe that was preventing essential complexity from being properly owned. AI can now provide the implementation capability Spring was providing — without the recipe.</p>
<hr />
<h2>AI: The Same Mistake, At Greater Speed</h2>
<p>This is where the two threads of this article converge — because AI is not a separate story from the domain modeling story. It is the aspect of it that finally makes the stakes undeniable.</p>
<p>AI makes the same mistakes procedural programmers make. It builds what is required today without preparing for tomorrow. It fills in the template, makes it work, ships the feature. The code is correct for the prompt. Whether it is correct for the system — whether it is consistent with what was built six months ago, whether it contradicts a rule established in a different part of the domain, whether it is encoding understanding that will survive the next requirement — these are questions AI cannot ask, because asking them requires a domain model to check against, and AI builds no such model.</p>
<p>For a large category of software, this does not matter. Small applications, internal tools, prototypes, systems with bounded scope and limited lifespans — here, building for today is the correct approach. AI is close to the complete solution. The template gets filled. The application works. The contradictions are manageable because the scope is small enough to hold in working memory. There is no ten-year maintenance horizon. Disposable software benefits from disposable development, and AI is the best disposable development tool ever built.</p>
<p>The schism appears at enterprise software — and it is the same schism that always existed between procedural development and domain-modeled development, now made visible by the speed at which AI can accumulate the consequences.</p>
<p>A procedural programmer building a large system makes their implementation decisions in isolation. Each feature is added to whatever was there before, in whatever shape it happened to be in. Over time the contradictions accumulate. A business rule exists in three places and was updated in two. A concept that should be unified has drifted into five different representations. The PDCA cycle broke quietly, feature by feature, and the system became a record of everything that was ever asked for rather than a model of what the business actually is.</p>
<p>AI does the same thing, at a velocity no human team could match. The contradiction between prompt 1 and prompt 78 is invisible in working software and produces no error, no warning, no friction. The code works. The Check step has no map to navigate. The cycle was broken before it started.</p>
<p>The critical point — and it is worth stating precisely because the counter-argument will come — is that this is not a memory problem. Expanding context windows do not resolve it. An AI holding millions of tokens of procedural code in its context window can still generate a patch that introduces a subtle business contradiction, because the limitation is not how much the AI remembers. It is that the AI has no model of what the system <em>should be</em> — no canonical truth to check against, no domain concept that owns the rule being contradicted, no structure that would make the inconsistency visible before it becomes a bug.</p>
<p>Rebuilding does not help. Without a domain model as the canonical reference, the rebuild reconstructs from the same conversations, the same scattered understanding, the same implicit contradictions — and produces the same contradicting system, faster, with more confidence.</p>
<p>Where AI genuinely excels in enterprise development is as a technology consultant. How do I stream documents to an HTTP multipart post. Can I do this in Java, and if so, how. What is the correct behavior of this Hibernate mapping in this edge case. These are questions with objectively correct answers. AI finds them instantly. The framework knowledge a developer spent years accumulating is now available on demand, to anyone who can ask the right question. That is a real democratization of technical expertise.</p>
<p>What AI cannot do is find the correct domain concept, assign it its responsibility, and place it honestly in the model. That requires understanding the domain — which requires the UI verification, the whiteboard session, and critically, the friction of implementation. The resistance a new requirement produces against an existing structure is not an inconvenience. It is the domain teaching you something. It is where the modeling judgment gets built. A developer who prompts their way through that resistance never receives the lesson. The code works. The understanding was never deepened. The model drifts from the domain silently, until the drift becomes structural and the consequences become expensive.</p>
<p><strong>AI is the perfect tool for building software for today. Enterprise software requires building for tomorrow. That has always been the distinction. AI simply makes it impossible to ignore.</strong></p>
<hr />
<h2>What This Means</h2>
<p>The working application is not the asset. The domain model is the asset.</p>
<p>For software with limited lifespans, this distinction is irrelevant. Build fast, use AI, ship it, replace it when it stops serving its purpose. The disposable approach is correct for disposable software, and AI makes it better than it has ever been.</p>
<p>For enterprise software — applications that must remain correct through changing requirements, changing teams, and changing understanding, over years and decades — the domain model is the mechanism that keeps the PDCA cycle alive. The UI is the mechanism that keeps the domain model honest. Together they produce something AI cannot: software that gets easier to understand as it matures, because the understanding encoded in it compounds rather than decays.</p>
<p>The developer who builds that foundation and uses AI for implementation is extraordinarily powerful. The patterns, the boilerplate, the technology questions, the adapter code surrounding a well-modeled domain — AI handles all of it, precisely and quickly, in service of a structure the developer owns and understands. That combination is more capable than anything the industry has previously had available.</p>
<p>The developer who uses AI to avoid building that foundation is producing disposable software at enterprise scale — and will discover, somewhere around prompt 78, that working software and correct software are not the same thing, and that the gap between them compounds with every prompt that had no model to check against.</p>
<p>The distinction between procedural development and domain-modeled development was never a preference. It was always a structural choice with structural consequences. The measurement problem that kept those consequences invisible — the absence of the comparable application built the other way — is being resolved in real time, at speed, by teams discovering that the working application they built in six months is already contradicting itself, with no map to navigate back to coherence.</p>
<p>AI did not create this problem. It inherited it from procedural development, and it runs it faster than any human team ever could.</p>
<p>The practices that prevent it are the same ones that always prevented it. They are just, finally, undeniably necessary.</p>
<hr />
<p><em>This article is part of a series on software engineering craft. Previous pieces examine the rich domain model as a discipline, the properties of enterprise software that lasts, how the software industry mistook its tools for its craft, and why Scrum works only when the people making decisions feel the outcomes.</em></p>
]]></content:encoded></item><item><title><![CDATA[Scrum Works — But Only When the People Making Decisions Feel the Outcomes]]></title><description><![CDATA[There is a version of Scrum that serves the product. There is another version that serves the agile transformation process. They use the same vocabulary, run the same ceremonies, and produce very diff]]></description><link>https://blog.leonpennings.com/scrum-works-but-only-when-the-people-making-decisions-feel-the-outcomes</link><guid isPermaLink="true">https://blog.leonpennings.com/scrum-works-but-only-when-the-people-making-decisions-feel-the-outcomes</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[Scrum]]></category><category><![CDATA[agile]]></category><category><![CDATA[software development]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Mon, 01 Jun 2026 05:16:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/b2f65b78-a313-42be-af41-9f3ac7e54f8a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a version of Scrum that serves the product. There is another version that serves the agile transformation process. They use the same vocabulary, run the same ceremonies, and produce very different outcomes.</p>
<p>The first treats the sprint as a learning unit — a cycle of building, showing, and understanding, with the product as the permanent reference point and the business owner as a continuous presence in the work. The second treats the sprint as a reporting unit — a cycle of planning, delivering, and demonstrating, with velocity as the measure of success and the business owner as an end-of-sprint audience.</p>
<p>Most organisations believe they are running the first. Most are running the second. The difference is not methodology. It is consequence. And once you see it, you cannot unsee it.</p>
<hr />
<h2>Where Scrum Actually Came From</h2>
<p>In 1986, Hirotaka Takeuchi and Ikujiro Nonaka published a paper in the Harvard Business Review studying how companies like Honda, Canon, and Fuji-Xerox built complex products faster and better than their competitors. They weren't studying software. They were studying what happened when you gave a cross-functional team a difficult goal, genuine autonomy, and full accountability for the outcome.</p>
<p>What they found was not a process. It was a consequence structure. The engineers at Honda were not following a framework. They were people who could not afford to be wrong — whose careers, reputations, and sense of craft were inseparable from whether the thing they built actually worked. The overlapping development phases, the self-organising teams, the continuous learning — these weren't designed. They were the natural behaviour of committed people given a hard problem and the freedom to solve it.</p>
<p>Takeuchi and Nonaka called one of their six key characteristics "multilearning" — the idea that learning had to happen continuously, at every level, through direct contact with the problem. Not through documentation. Not through handoffs. Through people who understood the domain working alongside people who understood the craft, close enough that ignorance was immediately visible and immediately corrected.</p>
<p>Jeff Sutherland and Ken Schwaber read that paper and recognised something important: software teams were failing catastrophically because they were running relay races when they should have been playing rugby. Waterfall's sequential handoffs — requirements to design to development to testing to deployment — introduced months of lag between a decision and its consequences. By the time you discovered the requirements were wrong, you had built on top of them for a year.</p>
<p>Their insight was correct. Tight feedback loops beat long planning cycles. Short iterations beat big bang releases. Direct domain contact beats document-mediated specification. The Agile Manifesto that followed made the priority order explicit: individuals and interactions over processes and tools, working software over comprehensive documentation, customer collaboration over contract negotiation, responding to change over following a plan.</p>
<p>The right side of those statements had value. It was just less important than the left — and the Manifesto said so explicitly.</p>
<p>That priority order has since been completely inverted — not because Scrum is flawed, but because of what was added to it.</p>
<hr />
<h2>The Pig and the Chicken</h2>
<p>Early Scrum folklore told a story about a pig and a chicken who decided to open a restaurant together. The chicken suggested calling it "Ham and Eggs." The pig declined. "For you," the pig said, "that's a contribution. For me, it's a commitment."</p>
<p>The story was eventually removed from the official Scrum literature — perhaps it seemed uncharitable. But the principle it pointed at was exactly right, and its removal is itself a symptom of what went wrong.</p>
<p>Scrum works when the people doing the work are pigs. Fully committed. Consequentially exposed. When the product is wrong, they feel it. When the process slows the team down, they feel it. When the business owner's problem goes unsolved, they feel it. Their skin is in the game and the game's feedback reaches their skin.</p>
<p>Scrum degrades when the chickens accumulate.</p>
<p>A chicken is not a bad person. A chicken is a structurally consequence-free participant — someone who carries authority over how the work happens without bearing the outcome of that authority. They contributed something. They cannot be committed, because the structure doesn't allow it. They will move to the next engagement, the next team, the next organisation. The team will live with the consequences of their recommendations.</p>
<p>The best chickens know this about themselves. The strongest consultant scrum masters and agile coaches actively work to reduce their own authority — pushing consequence back onto the team, making themselves progressively less necessary, effectively working toward their own redundancy. That is a mark of genuine craft in a consequence-free role. But it is a character trait, and you cannot scale character. You cannot hire for it reliably across an organisation. You cannot depend on it as a structural guarantee.</p>
<p>This distinction matters more than any ceremony, any role definition, or any version of the Scrum Guide. Because a team of pigs running imperfect Scrum will self-correct — the feedback is immediate, the incentive to fix problems is intrinsic, and the process will evolve toward what actually serves the work. A team with too many chickens running perfect Scrum will drift toward process performance — because the people with the most authority over the process are the ones least exposed to whether it serves the product.</p>
<hr />
<h2>What Happened to Scrum</h2>
<p>Sutherland and Schwaber encoded a pig observation into a framework. That was always going to be difficult — you cannot certify skin in the game. But the framework pointed at the right things. Self-organising teams. Direct customer contact. The scrum master conceived as someone embedded in the team's work, responsible for removing impediments that blocked delivery — not as an external observer of the team's process.</p>
<p>Then the industry arrived.</p>
<p>Not maliciously. Structurally. Organisations running Scrum at scale needed coordination mechanisms. The coordination mechanisms needed owners. The owners needed titles. The titles became roles. The roles became certifications. The certifications became hiring criteria. And at each step, the distance between process authority and process consequence grew a little wider.</p>
<p>The scrum master became a process coach. External to the team. Often shared across multiple teams. Measured on ceremony quality, team satisfaction scores, and adherence to the framework. Not on whether the product served the business. The sentence that captures the failure mode perfectly is one you will recognise if you have heard it: <em>"I'll fix it next week — I have two other teams to coach."</em> That sentence is structurally impossible if the scrum master is inside the team's consequence. It is inevitable if they are outside it.</p>
<p>The product owner — originally a role requiring genuine domain authority and business accountability — became a proxy. A translator sitting between the team and the real decision-maker, filtering business knowledge through the medium of user stories, insulating engineers from the domain rather than connecting them to it.</p>
<p>The infrastructure team — the one Scrum was partly designed to dissolve into the cross-functional whole — re-emerged as the CI/CD team, the platform team, the DevOps function. Different name badge. Same dashboard. Same structural distance from whether the product actually worked for the people who needed it. The dashboard stays green. The pipeline runs. And then there is still plenty of time to spend on minesweeper.</p>
<p>And with each new chicken added, consequence density fell. The feedback loops that should have corrected mistakes grew longer and more attenuated. The process filled the gap — providing the appearance of rigour in the absence of the reality it was substituting for.</p>
<p>Scrum was a revolt against exactly this. It became exactly this.</p>
<hr />
<h2>The Definition of Done Is a Symptom</h2>
<p>Nothing illustrates the problem more precisely than what happened to the Definition of Done.</p>
<p>Most DoDs are social contracts: cucumber tests passing, peer review complete, product owner sign-off received. And those boxes answer a different question entirely — not "is this good?" but "whose fault is it if this is wrong?"</p>
<p>If all those boxes are checked, the engineer cannot be blamed if the functionality turns out wrong. They followed the process. The responsibility for whether it was the right thing to build is distributed so thinly across roles and sign-offs that it evaporates entirely. Nobody failed. The process succeeded. The business owner got something they didn't need, delivered on time.</p>
<p>The DoD is not a quality gate. It is a consequence substitute. It exists precisely because the people doing the work cannot feel directly whether it is right — because the business owner is not in the conversation, and because the consequence of being wrong has been spread so thinly across roles that nobody feels it acutely enough without a checklist to reach for.</p>
<p>Now consider the alternative. The business owner is genuinely part of the team — not as a stakeholder who attends the demo, but as a continuous presence in the work. The engineer who hits something that doesn't fit the model talks to them that day. Not next sprint. Not at the demo. That day. The working software is shown informally, mid-sprint, as a thinking tool — not as a deliverable but as a question: is this what you meant? The answer shapes the next two days of work, not the next sprint's backlog.</p>
<p>In that environment, what does the Definition of Done do? It documents what both parties already know, through a checklist that neither party needed to reach the answer. The DoD didn't produce the quality. The conversation did.</p>
<p>This is the cleanest diagnostic available for whether Scrum is serving your product or substituting for it: how prescriptive does your Definition of Done need to be? The more you need it, the further the business owner is from the work. A heavily checkbox-driven DoD is not evidence of good process hygiene. It is a measure of the consequence gap — the distance between the people who build and the people who know whether what was built is right.</p>
<hr />
<h2>Stories Are Not Tickets</h2>
<p>The consequence gap shows up everywhere once you know to look for it, but nowhere more clearly than in how user stories are written and treated.</p>
<p>Alistair Cockburn, one of the authors of the Agile Manifesto, described the story card as a token for a conversation — a placeholder that represented a discussion yet to happen, not a specification already agreed. That is a precise and important idea. The card was never meant to replace the conversation. It was meant to prompt it.</p>
<p>What happened instead is that the card became the deliverable. The story became the ticket. And the conversation — the one that would have revealed what the domain actually needed — never happened, because the ticket already contained the answer.</p>
<p>A story written as a ticket describes how. "As an invoice clerk I want to export the invoice to PDF and email it to the customer." That is not a business need. That is a current business process, translated into acceptance criteria, handed to an engineer as a specification. The how has been decided before the what was understood.</p>
<p>The invoice clerk doesn't think of it as a how. For them, that is simply how invoicing works — how it has always worked, how they were trained to think about it. The mental model of the domain and the mental model of the current implementation have merged into one thing. When you ask them what they need, they describe what they do. This is not a failure of articulation. It is the natural epistemology of someone who lives inside a domain.</p>
<p>The engineer who receives "export to PDF and email" as a ticket implements export to PDF and email. The box is ticked. The DoD is met. And the actual business need — that the customer receives timely, accurate confirmation of what they owe — remains unexamined. Maybe PDF email is the right answer. Maybe the customer's system should pull it via API. Maybe the concept of "sending an invoice" is a legacy artefact of a paper-based process that software doesn't need to replicate at all. Nobody asked, because the story was a ticket, not a question.</p>
<p>Treat the story as a discussion item instead — as new information about a domain the team is trying to understand, not a task to be executed — and the entire dynamic changes. The engineer's job becomes domain archaeology: stripping the legacy how from the domain owner's description to find the what underneath. What problem are you actually solving? What would good look like if you had no constraints from the way you currently do it? What would disappear from your working day if this worked perfectly?</p>
<p>Those questions are uncomfortable. They require the domain owner to separate themselves from their own practice. They require the engineer to be genuinely curious about a domain they don't live in. But that discomfort is where the model gets built — and the model is what the software should reflect.</p>
<p>This is also why two or three weeks without domain contact is so dangerous. Every day the engineer works from the story-as-ticket, they make decisions based on their current understanding of the domain. Each decision becomes the foundation for the next. By the end of the sprint, the assumptions are load-bearing. Changing them isn't a story revision. It is structural rework. And the DoD, dutifully signed off, certifies a structure built on assumptions nobody tested.</p>
<hr />
<h2>The Sprint Is a Learning Unit</h2>
<p>The most persistent misunderstanding in Scrum practice is what a sprint is for.</p>
<p>A sprint is not a delivery unit. It is a learning unit. The question it should answer is not "what did we complete?" but "what do we understand now that we didn't understand before, and does the software reflect that understanding?"</p>
<p>This distinction changes everything about how the sprint runs, how the review is conducted, and what the next sprint is for.</p>
<p>If the sprint is a delivery unit, the review is a closing ceremony. Stories are demonstrated. Sign-offs are gathered. The board is cleared. Planning begins for the next batch. The product at the end of the sprint is the output. The backlog is the input for the next cycle.</p>
<p>If the sprint is a learning unit, the review is an opening conversation. The product at the end of the sprint is not the output — it is the new starting point. The most important question in the room is not "did we build what we planned?" but "given what we've built and what we've learned, where does this point next?"</p>
<p>This is why the business owner seeing the product for the first time at the demo is a signal that something has gone wrong — not right. By the time the demo happens, they should already know what's there, because they have been part of the conversation as it developed. The demo is a show and tell for the wider organisation — stakeholders, interested parties, people who benefit from visibility into progress. It is valuable for that. It is not the primary feedback mechanism. The primary feedback mechanism is the continuous conversation between engineer and business owner that has been happening all sprint, informally, around working software that is always current enough to think with.</p>
<p>The backlog, in this model, is not a queue of pre-specified work. It is a set of open questions — hypotheses about what the product needs to become, held loosely and revised continuously as understanding grows. The long-term planning the product owner holds is directional, not prescriptive: functional areas, broad horizons, strategic intent. The specific shape of each sprint emerges from where the product currently stands and what the team has most recently learned.</p>
<p>That is what Takeuchi and Nonaka called multilearning. It was not a process characteristic. It was what inevitably happened when people with full commitment to the outcome worked in direct contact with the domain. The learning was continuous because the consequence of not learning was immediate and personal.</p>
<hr />
<h2>Limiting the Chickens</h2>
<p>None of this is an argument against scrum masters, agile coaches, or specialised platform teams. There are excellent people in all of those roles — people who compensate for the structural absence of skin in the game through personal commitment, genuine craft, and deep care about outcomes they will never formally be held accountable for.</p>
<p>But you cannot build a reliable system on character traits. You can admire them. You cannot depend on them at scale. And you cannot ignore what the accumulation of consequence-free authority does to the system around those individuals — however excellent they are.</p>
<p>The question to ask about every role on or around a Scrum team is not "is this person good at their job?" It is two questions: does this person feel it when the product fails to serve the business? Does this person feel it when the process slows the team down?</p>
<p>Two yes answers: keep them close, give them authority, trust their judgment. One yes: useful, but watch the ratio. Two no answers: may be excellent. Cannot be the majority. Should not hold process authority over people who answered yes.</p>
<p>This is not about eliminating external expertise. It is about understanding what external expertise can and cannot provide. A good consultant scrum master brings experience, pattern recognition, and perspective that an internal team member might lack. What they cannot bring is consequence. And when consequence-free authority accumulates — scrum master, agile coach, platform team, architecture review board, all operating with authority over how the work happens but none bearing the outcome — the team learns quickly, and correctly, that the process does not belong to them. It was handed down from outside. So they perform it rather than own it. And a performed process is a checkbox factory almost by definition.</p>
<p>Scrum was partly designed to dissolve the independent infrastructure team — the group whose dashboard was their product, whose relationship to the actual product was mediated by tickets and queues. DevOps recognised the same problem and tried to dissolve the boundary between build and run. What neither fully resolved was the deeper pattern: that any specialised group whose success metric is their own domain, rather than the outcome of the product, will optimise for their domain. The pipeline will run. The ceremonies will happen. The dashboard will stay green.</p>
<p>The answer is not to abolish specialisation. It is to ensure that the people who feel the consequence of the product's success or failure are never outnumbered and never out-authorised by the people who don't.</p>
<hr />
<h2>Back to Honda</h2>
<p>Honda's engineers did not have a Definition of Done. They had a standard they could not compromise, enforced not by a checklist but by the complete absence of distance between themselves and the consequences of falling short.</p>
<p>They did not have a product owner translating customer needs into stories. They had customers whose reactions to prototypes shaped the next iteration of the design directly, through the hands and judgment of the people doing the work.</p>
<p>They did not have a process coach facilitating their ceremonies. They had senior engineers whose authority came from depth of knowledge and shared consequence — people who removed obstacles because the obstacles were in their way too.</p>
<p>What Takeuchi and Nonaka observed was not a process. It was what process looks like when it is fully owned by the people who cannot afford for it to fail. The ceremonies that mattered emerged from the work. The ones that didn't, didn't happen — because nobody with skin in the game had time for them.</p>
<p>Scrum, at its best, is an attempt to recreate that condition in software teams. The framework is sound. The ceremonies are scaffolding. The roles are starting points. None of them are the point.</p>
<p>The point is consequence density. Who in the room cannot afford to be wrong? Who will feel it tomorrow if the model is off? Who has no dashboard to hide behind and no next engagement to retreat to when the product fails?</p>
<p>Keep those people close. Give them authority. Let the process serve them rather than the other way around. Make the business owner's continuous presence the normal condition rather than the exceptional one. Treat the story as a question, not a ticket. Let the sprint answer it. Let the product as it stands be the permanent starting point for the next conversation.</p>
<p>And when you feel the urge to add another sign-off, another role, another ceremony — ask first whether you are closing a genuine gap or substituting for a conversation that should just happen.</p>
<p>Because if the business owner is in the room, you already know the answer. And you don't need a checkbox to confirm it.</p>
<hr />
<p><em>This article is part of a series on software engineering craft. The previous piece, "The Gods That Ate the Engineers," examines how the broader software industry mistook its tools for its craft.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Gods That Ate the Engineers]]></title><description><![CDATA[There is a conversation that happens in software teams every day. Someone proposes a simpler approach. Someone else says "but we need this to scale." The first person asks what scale is actually requi]]></description><link>https://blog.leonpennings.com/the-gods-that-ate-the-engineers</link><guid isPermaLink="true">https://blog.leonpennings.com/the-gods-that-ate-the-engineers</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[Java]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Wed, 27 May 2026 05:53:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/dd415cd4-44da-4ec6-95e8-a1361c994331.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a conversation that happens in software teams every day. Someone proposes a simpler approach. Someone else says "but we need this to scale." The first person asks what scale is actually required. The second person explains that the architecture team has decided on the standard stack. The first person points out that the application has twenty-five users. The second person suggests talking to the infrastructure architect.</p>
<p>The conversation ends there. Not because the technical argument was resolved. Because the two engineers were no longer speaking the same language. One was speaking the language of context — what does this problem actually require? The other was speaking the language of compliance — what does the standard say we should do? Those two languages have no shared grammar. The conversation cannot proceed, so it escalates instead.</p>
<p>This is not a story about stubbornness. It is a story about a profession that has progressively lost the vocabulary of first principles, and replaced it with the vocabulary of tools — and what happens when the people who should be having the hard conversation have never been taught the words.</p>
<hr />
<h2>The Measurement Problem Nobody Talks About</h2>
<p>Software engineering has a property no other engineering discipline shares: its quality is almost entirely invisible.</p>
<p>A bridge that is over-engineered costs more to build. A building with poor thermal design costs more to heat. Even a book that doesn't serve its readers fails to sell. In each case there is a signal — a cost, a measurement, a market response — that connects engineering decisions to outcomes.</p>
<p>Software has one test: does it work? If the application runs in production, the engineering passes. If it doesn't, it fails. There is no measurement for whether it could have been built in a fraction of the time with a fraction of the complexity. Nobody built that version. There is no reference to compare against.</p>
<p>This is not just a gap in measurement. It is the foundational problem of the entire discipline. Because when the only validation is "it works," everything that produces working software becomes equally valid. The team that spent three months on spikes and produced a distributed microservices architecture that nobody fully understands — it works. The team that spent one day with domain experts, modeled the core concepts, and built a coherent system in three weeks — it also works. The outcomes look identical. The costs are incomparable.</p>
<p>Fred Brooks captured this tragedy in 1986: every system is built only once. There is no second version built with different assumptions, run for five years, and compared on total cost of ownership. The counterfactual does not exist. The cost of bad decisions is permanently invisible.</p>
<p>What fills the vacuum left by absent measurement? Authority. Convention. And demigods.</p>
<hr />
<h2>The Rise of the Demigods</h2>
<p>A demigod is not a false god. That is important. A false god has no power. A demigod has real power — but finite power, power over a specific domain, power that has limits it will not advertise.</p>
<p><strong>TDD</strong> is a demigod. It genuinely reduces certain classes of bugs. It creates a feedback loop between intention and implementation. Used with understanding, it is a valuable practice. But TDD defines the questions before it discovers the theory. Write the test, make it pass. The test describes an action — a thing the system should do. It says nothing about the mechanism that should enable that action, the underlying structure that would make the action natural rather than bolted-on. You can TDD your way to a perfectly tested mess. The tests are green. The architecture is incoherent. The demigod delivered what it promised and nothing more.</p>
<p><strong>CQRS</strong> is a demigod. Separating reads from writes treats a real symptom — but that symptom is often produced by a deeper failure. When reads and writes conflict, it is frequently because the domain model isn't carrying its weight: state is inconsistent, rules are scattered, the persistence layer has leaked into everything. CQRS resolves the tension by physically separating it, at significant architectural cost, while the cause goes unexamined. The mess that made CQRS feel necessary is sealed behind the architecture and forgotten.</p>
<p>The conventional wisdom holds that complex domains — high-scale transactional systems, regulated industries, extreme concurrency requirements — genuinely justify this kind of architecture. The conventional wisdom has it backwards. Those are precisely the domains where a behavior-carrying domain model would deliver the most value, making invariants explicit and enforcing consistency rules at the model level rather than externalizing them into orchestration layers and read/write splits. What looks like sophisticated enterprise architecture is, in many cases, sophisticated coping with a modeling failure that the architecture was never asked to fix.</p>
<p><strong>Microservices</strong> are a demigod. <strong>Scrum</strong> is a demigod. Each of them originated as an observation — someone looked at good engineering practice, noticed a pattern, and named it. The name spread. The observation became a methodology. The methodology became a certification. The certification became a hiring criterion. And somewhere in that journey, the principle the observation was pointing at quietly disappeared.</p>
<p>What remains is ceremony. Scrum was an insight about feedback loops: build something small, expose it to reality, learn, adjust. Now it is planning poker, velocity points, and a definition of done. The ceremonies survived. The epistemology was discarded. You can run perfect Scrum and never once have a conversation that deepens your understanding of the domain you are building for.</p>
<p><strong>Spring</strong> is a demigod — and the most instructive one, because it did not merely obscure first principles. It industrialized their replacement.</p>
<p>Spring's recipe is seductive in its clarity: a controller receives the request, a service orchestrates the logic, a repository handles the persistence. Learn the recipe and you can implement almost any user story. The pattern is consistent, communicable, and scales across teams. It is also procedural programming wearing object-shaped clothing. The service class becomes the address for all behavior, because the recipe has no concept of behavior belonging to the domain objects themselves. Every new requirement gets the same answer: add a method to the service. The mechanism — the structure of the business domain, the responsibilities of its concepts, the rules that govern its behavior — is never considered, because the recipe answered the structural question before you asked it.</p>
<p>This is not a side effect of Spring. It is what Spring teaches. A library gives you capabilities and leaves the thinking to you. Spring gives you the thinking pre-done. Engineers who learned Spring as their foundation did not learn to reason about structure — they learned to apply a structure that was handed to them. When the recipe always fits, you never develop the judgment to know when it doesn't. The capacity atrophies quietly, and working software confirms at every step that nothing is wrong.</p>
<p>Spring did not create the anemic domain model. But it mass-produced it, certified it, and made it the industry default. It turned a modeling failure into a career path.</p>
<p><strong>AI</strong> is a demigod — the latest, the most powerful, and the most dangerous one the profession has yet encountered.</p>
<p>AI is genuinely transformative at the implementation level. It can generate code, implement features, navigate unfamiliar frameworks, and eliminate enormous amounts of repetitive work. In a well-understood domain with an explicit model, it is an extraordinary accelerator — handling the mechanical expression of things the engineer already understands. That is real and significant power.</p>
<p>But AI has the same hard limit every demigod has. It cannot ask what the mechanism should be before implementing the action. It cannot determine whether a concept belongs in the domain model or whether it is accidental complexity in disguise. It cannot notice that the service class has become a procedural script, or that the architecture has answered the structural questions before anyone understood the structure. Give AI a well-modeled domain and it accelerates good engineering. Give it a recipe and a backlog and it produces Spring-shaped procedural code at a speed no human team could match — complete with tests, documentation, and a green pipeline, none of which will tell you that the map was never drawn.</p>
<p>The previous demigods papered over the absence of first principles. AI industrializes that papering at a velocity that makes the underlying absence nearly impossible to see and nearly impossible to recover from. The mess accumulates faster than any previous generation of engineers could have produced it. Every demigod arrived as a silver bullet. AI is the latest — and the profession is following the pattern with the same fidelity it always has.</p>
<hr />
<h2>When Tools Become Identity</h2>
<p>Here is where the measurement problem and the demigod problem combine into something more serious — and where the economic machinery that drives the industry becomes visible.</p>
<p>Software development scaled faster than the supply of engineers who understood it deeply. The response was industrialization. If you are running a software factory, you need interchangeable parts. Interchangeable engineers require standardized tools. You cannot factory-manage engineering judgment — it is invisible, contextual, slow to assess, and impossible to replicate at scale. But you can factory-manage Spring Boot certification. You can standardize on Kubernetes. You can mandate the architecture diagram before the domain conversation happens, because the architecture diagram fits into a project timeline and engineering judgment does not.</p>
<p>The factory model did not choose tools over judgment because it was ignorant of the difference. It chose tools because tools are manageable and judgment is not. That choice, made millions of times in hiring decisions and project kickoffs and architecture reviews, compounded into an industry.</p>
<p>The economic incentives completed the picture. An engineer cannot put "sound engineering judgment" on a CV. They can put Kubernetes, Kafka, Spring Boot, and AWS. The market rewards tool-hoarding because tool-hoarding is legible and judgment is not. So engineers rationally invest in tools. They accumulate certifications. They learn the next framework. The career incentive and the factory requirement point in the same direction, and the profession follows.</p>
<p>The consequence is a generation of practitioners who were never taught the underlying principles — not because they are poor engineers by disposition, but because the path through the profession did not require those principles. Framework knowledge was sufficient. It got them hired. It gets features shipped. It passes the only test anyone applies.</p>
<p>This is where the Dunning-Kruger effect enters — and it enters structurally, not individually. When "it works" is the only feedback signal, the gap between tool expertise and engineering judgment produces no visible failures. The feedback loop that would expose the gap never fires. An engineer who has only ever navigated by Spring's recipe has no evidence that another kind of navigation exists, because both arrive at working software.</p>
<p>What happens when that engineer is challenged on a technical decision? They cannot retreat to first principles, because those principles were never their foundation. They can only defend the tool. And defending the tool looks like defending engineering — because in the world they have always inhabited, they are the same thing.</p>
<p>This is why the conversation about the build agent ends with "talk to the infrastructure architect." Not stubbornness. Not bad faith. The argument has moved to terrain where their map does not reach, and the only available response is to invoke authority rather than reasoning. The map was never drawn because nobody required it.</p>
<hr />
<h2>The Cost of Working Software</h2>
<p>At this point a reasonable person might object: so what? It works, doesn't it? The software ships. The business runs. Teams are productive. Perhaps the architecture is heavier than it needs to be, but that is a philosophical concern, not a practical one.</p>
<p>It is not a philosophical concern. It has a price. And that price is paid in headcount, infrastructure spend, and organizational mass — every month, permanently, at a scale most organizations have never stopped to calculate because they have nothing to compare it against.</p>
<p>Start with the team. A Scrum-based delivery organization does not just have engineers. It has product owners to translate business needs into stories, scrum masters to run the ceremonies, agile coaches to optimize the ceremonies, and program managers to coordinate across the teams that have multiplied because the architecture decomposed the system into services each requiring ownership. None of these roles existed before the ceremony required them. They are not a consequence of software complexity. They are a consequence of the process layer that was wrapped around it.</p>
<p>The infrastructure follows the same logic. A well-modeled application, sized honestly to its problem, might run on a handful of servers with a deployment process a single engineer can understand. The standard stack requires container orchestration, service meshes, distributed tracing, centralized log aggregation, secrets management, cloud cost governance, and a security perimeter that scales with the number of services rather than the complexity of the domain. Someone has to build and own that infrastructure — which means an infrastructure team. Someone has to own the pipeline tooling — which means a platform team. Someone has to operate the observability stack that exists entirely because the system is too opaque to reason about directly — which means an observability practice, which means tooling budgets, which means vendor contracts.</p>
<p>Count it all. The ceremony layer, the infrastructure department, the platform team, the observability tooling, the architecture review board that exists because the architecture requires governing. Compare it to a team organized around an honest domain model, sized to the actual problem, with infrastructure that serves the domain rather than managing the accidental complexity the domain was never asked to absorb.</p>
<p>The difference in team size is not marginal. Doubling is optimistic. Tripling is closer. When infrastructure and tooling costs are included, the multiplier on total cost of ownership reaches further than most organizations want to calculate — because the calculation would require admitting that the standard stack is not an engineering choice. It is an organizational commitment, billed indefinitely, justified by working software that could have been built and maintained at a fraction of the cost by a team that understood what it was building.</p>
<p>The most expensive software is the software everyone agrees is fine.</p>
<hr />
<h2>What First Principles Actually Means</h2>
<p>First principles in software engineering are not a methodology. They are not a framework. They cannot be certified.</p>
<p>In any engineering discipline, first principles means reasoning from what is actually true about the problem — from the undeniable constraints of physics, economics, or logic — before selecting any tool or approach. In bridge building, you start with loads, materials, and forces. In software, the undeniable truth is the business domain itself: what it does, what it needs, what rules govern it, what concepts exist within it. Everything else is a choice. The domain is not a choice. It is the ground the system must stand on.</p>
<p>First principles therefore begins with a single question: what mechanism does this business need, and what is the structure of that mechanism?</p>
<p>Not: what actions need to happen. Not: what user stories need to be implemented. Not: what does the recipe provide.</p>
<p>The distinction between actions and mechanisms is the one the entire profession routinely misses — and it is the one that determines everything that follows.</p>
<p>An action is something the system does. Place an order. Send an invoice. Notify a customer. Actions are visible, speakable, easy to write as user stories. They are also infinite. There is always another action. A system built around implementing actions never reaches coherence — it reaches a different kind of completeness, the kind where every story is closed and nobody can tell you where any particular rule lives.</p>
<p>A mechanism is the structure that makes actions possible. The domain concepts, their responsibilities, their relationships, the rules they enforce. Mechanisms are finite. A business domain has a bounded set of real concepts — not infinite. Once you understand them, new actions find their natural place. The mechanism does not need to change because a new action arrived; the action was always expressible in terms of the mechanism. You just had not asked for it yet.</p>
<p>This is why a day spent with domain experts outperforms four sprints of discovery spikes. The spikes are action-oriented. They produce implementations of specific scenarios, each one leaving a deposit of logic somewhere convenient, none of them building toward a coherent structure. The domain conversation is mechanism-oriented. It produces understanding of what the system actually is — and from that understanding, implementations become fast, because they are no longer navigating blind.</p>
<p>The domain expert knows the story. The engineer's job is to understand the mechanisms that story requires — and then model those mechanisms honestly, directly in code, without a documentation layer between the understanding and the implementation. A whiteboard sketch is a thinking tool. The code is the model. There is no pile of upfront design, no architecture document that creates its own maintenance burden and its own resistance to change. Formal documentation does not just resist change mechanically — it raises the social cost of being right. The person who says the abstraction is wrong is not raising a technical question. They are implicitly criticising the judgment of everyone who approved the document. So people stop saying it. Understanding goes directly into structure, continuously, as the understanding grows.</p>
<p>This is not big upfront design. It is the opposite. Big upfront design tries to answer everything before building anything. First principles thinking says: understand what is true now, encode it honestly, and stay honest as truth evolves. A payment system models credit cards — until digital wallets arrive, and the domain reveals the real concept was always a payment method. The model grows because the understanding grew. Not because a story was implemented. Because something was learned.</p>
<hr />
<h2>The Speed That Nobody Measures</h2>
<p>The most persistent myth about principles-first development is that it is slow.</p>
<p>It is not slow. It is the fastest path available — and it gets faster as it goes, while the alternative gets slower.</p>
<p>Tool-driven, action-focused development feels fast because it is always moving. Tickets close. PRs merge. Velocity is high. But the team is navigating by taking the next available turn rather than reading the terrain. Enormous distance is covered traveling a short path. Each new feature lands in a codebase without a map, and finding where it belongs takes longer each time, because the codebase is larger and less coherent than it was before.</p>
<p>Principles-first development feels slower at the start because the team is reading the map. But the map converts future distance into present understanding. Features find their place. The model tells you where things belong. The implementation follows from the understanding — and because the model is clear, the implementation is the smaller part of the work, not the larger.</p>
<p>The asymmetry compounds over time. Principles-first gets faster. Tool-driven gets slower. They do not just start at different speeds — they move in opposite directions. And because both produce working software, the team on the slower path has no signal that another trajectory exists. The velocity metric measures motion, not progress. You can cover enormous distance going the wrong way and call it delivery.</p>
<hr />
<h2>The Conversation We Can No Longer Have</h2>
<p>Something has been lost that is harder to recover than a methodology or a framework.</p>
<p>When two engineers disagree about a technical decision, resolution requires a shared language: first principles. Does this decision reflect the actual complexity of the domain? Is the added mechanism justified by what the domain requires? Is this accidental complexity or essential complexity? Those questions have answers that are reasoned, not asserted. But they require both participants to have internalized the same foundation — to reason from what is true about the problem, rather than advocate from what their tools provide.</p>
<p>When tool expertise replaces engineering judgment, that conversation becomes structurally impossible. Not because people argue in bad faith, but because they are operating from entirely different premises. One person is asking what the domain requires. The other is asserting what the standard stack provides. These are not positions that can be reconciled by better argument. They are not even positions in the same debate.</p>
<p>The engineer with first principles asks: what scale do we actually need? The engineer with tools answers: we use the scalable architecture. The first engineer points to the user count. The second engineer escalates to the infrastructure architect. This is not a failure of communication. It is a failure of shared foundation — and the shared foundation was never built, because the profession stopped requiring it.</p>
<p>The solution is not a new methodology. It is not another demigod. The last thing the industry needs is a certification in first principles thinking. It is the recovery of something quietly discarded as the profession industrialized — the understanding that engineering judgment precedes tool selection, that mechanisms precede actions, that one focused conversation about what the domain actually is will outperform any number of sprints implementing what the domain appears to do.</p>
<hr />
<h2>The Only Test That Matters</h2>
<p>Software engineering currently applies one test: does it work?</p>
<p>That test is necessary but nowhere near sufficient. A system can work and be incomprehensible. A system can work and cost ten times what it should have. A system can work and reflect no coherent understanding of the domain it serves. The pipeline is green. The retrospective is positive. The modeling failure is invisible, as it always was.</p>
<p>The organization built to sustain the demigod stack — the scrum masters and platform teams and observability engineers and architecture review boards — has a structural interest in the stack continuing to be necessary. The demigods do not just persist because engineers worship them. They persist because the organizations that grew up around them cannot afford to question them.</p>
<p>That is where the profession is. Not failing. Working. Expensively, slowly, with tripled teams and bloated infrastructure and a generation of engineers who were handed a recipe instead of a craft.</p>
<p>Until someone asks: but what scale do we actually need?</p>
<p>And the room goes quiet.</p>
<p>And someone says: talk to the infrastructure architect.</p>
<p>And nothing changes — until engineers are once again taught that the map comes before the journey, and that knowing how to apply a recipe is not the same as knowing how to think.</p>
]]></content:encoded></item><item><title><![CDATA[The Properties of Enterprise Software That Lasts]]></title><description><![CDATA["Perfection is achieved not when there is nothing more to add, but when there is nothing more to remove." — Antoine de Saint-Exupéry

Introduction
Enterprise software is different from other software.]]></description><link>https://blog.leonpennings.com/the-properties-of-enterprise-software-that-lasts</link><guid isPermaLink="true">https://blog.leonpennings.com/the-properties-of-enterprise-software-that-lasts</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[software development]]></category><category><![CDATA[enterprise software]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Rich Domain Model]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Thu, 21 May 2026 08:40:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/b5cd8d76-f192-4dc4-945c-4adfee46e438.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>"Perfection is achieved not when there is nothing more to add, but when there is nothing more to remove."</em> — Antoine de Saint-Exupéry</p>
<hr />
<h2>Introduction</h2>
<p>Enterprise software is different from other software. Not in the technologies used to build it, not in the frameworks, not in the methodologies. It is different in its purpose: it must work correctly today, remain correct over time, survive the people who built it, and adapt to a business domain that will change in ways nobody can fully predict. Most software is built to solve today's problem. Enterprise software must be built to outlast today's understanding.</p>
<p>That is a fundamentally different design goal. And it demands a fundamentally different way of thinking about software — about what matters, what doesn't, and what the job of a developer actually is.</p>
<p><strong>The code is downstream of the thinking.</strong> The properties which determine whether enterprise software survives — or quietly becomes the system nobody dares touch — are not primarily technical. They are properties of understanding. And the thinking starts long before the first line is written.</p>
<hr />
<h2>The Six Properties</h2>
<h3>1. Longevity</h3>
<p>The core of enterprise software should, in retrospect, survive ten to fifteen years. Not the UI framework. Not the ORM. Not the messaging library. The <em>core</em> — the domain logic, the structural decisions, the way the system understands and represents the business.</p>
<p>This sounds obvious until you consider how rarely it is treated as a design constraint. Most development decisions are made under short-term pressure: the sprint deadline, the current team's preferences, the framework that is fashionable today. None of those inputs have any relationship to what the system will need to be in year eight.</p>
<p>Longevity is not achieved by predicting the future. It is achieved by not over-committing to the present. Every unnecessary dependency, every piece of logic tied to a specific framework's idiom, every abstraction built around today's tooling rather than today's domain — these are bets that the present will continue. In enterprise software, the present never continues long enough.</p>
<p>Longevity is the north star. The properties that follow are the means to achieve it.</p>
<hr />
<h3>2. Upgradeability</h3>
<p>Upgradeability is not about keeping dependencies current. Keeping dependencies current is maintenance. Upgradeability is structural: it is the capacity of the system to accept functional change without requiring a rewrite of its core.</p>
<p>This distinction matters enormously. A system can have perfectly up-to-date dependencies and be completely unupgradeable — because its structure was built around the features known at the time, implemented in a way that assumes those features are the final shape of the domain. When the business changes, and it will, there is nowhere to go.</p>
<p>Building for upgradeability means building with the understanding that what you know today is not everything. It does not mean building features you don't need — that is the opposite of the principle. It means implementing what you know today in a way that does not foreclose tomorrow. The structure should be open to extension, refactoring, and replacement at the right level of granularity.</p>
<p>This is also where the conventional wisdom about test coverage becomes a liability. Class-level unit tests — one test class per production class, testing the internal mechanics of each — are a contract on the current implementation. They make refactoring expensive by breaking whenever the internals change, even when the behavior is preserved. Over time, they become the reason the system cannot be restructured: the test suite has calcified the implementation.</p>
<p>Behavioral tests — tests that assert what a piece of functionality does, not how a particular class does it — are a contract on the domain. They survive refactoring because refactoring does not change behavior, only implementation. Upgradeability requires the right level of test coupling. Tests should be coupled to what the system does, not to how it currently does it.</p>
<hr />
<h3>3. Maintainability</h3>
<p>Maintainability in long-lived software is primarily a question of dependency discipline. Every external dependency is a commitment: to a version, to an API contract, to a community that may or may not continue to support it. Over fifteen years, many of those commitments will become liabilities.</p>
<p>The critical discipline is asking, for every dependency: what does this actually buy us? Not in theory — in practice, in this specific system, for this specific use case. The question is not whether a dependency is good in the abstract — a battle-tested cryptography library, a well-maintained time handling library, a parser for a complex format — these earn their place because the alternative is genuinely worse. The question is whether <em>this</em> dependency serves <em>this</em> production system's domain needs, or whether it serves the tooling, the framework preference, or the developer's convenience.</p>
<p>The dependency that should be rejected without hesitation is the one whose primary justification is testability of the production code. Testability is a testing concern, not a production concern. Production code should not be structured, abstracted, or made more complex to accommodate the needs of the test suite.</p>
<p>This manifests in two particularly damaging patterns. The first is mocking-driven architecture: interfaces created not because the domain has multiple implementations of a concept, but because the test framework needs a seam to inject a mock. An interface with one real implementation, existing purely to enable a unit test, adds a layer of indirection with no domain justification. Every future reader follows the code, hits the interface, and must go find the implementation. The test was marginally easier to write. Every reader pays for that convenience forever.</p>
<p>The second is Aspect-Oriented Programming applied to cross-cutting concerns. The promise was clean separation — keep business logic free of logging, transactions, security, caching. In practice, the result is code where you cannot tell what is executing by reading it. The aspects are invisible in the source. Behavior is woven in at runtime by configuration that must be hunted for separately. You need a debugger to understand what your own code does. That is not decoupling. It is hidden coupling, which is strictly worse than visible coupling because at least visible coupling can be read.</p>
<p>Both patterns share the same failure: a tooling concern reshaped the production code in ways that made it harder to understand. The test suite or the framework became easier to work with. The system became harder to reason about. That is the wrong trade, and it compounds over fifteen years in ways that eventually make the system unreformable.</p>
<p>The simpler path is to make the production code so clear in its intent that the need for complex testing infrastructure is reduced rather than accommodated. Nobody tests <code>string.trim()</code> — not because someone decided it was below the testing threshold, but because its intent and behavior are completely transparent. The ambition for domain logic should be the same. <code>order.send()</code> can be just as obvious if the implementation reads like a statement of business intent rather than a sequence of technical operations.</p>
<hr />
<h3>4. Extensibility</h3>
<p>Extensibility requires locatability. Before you can extend a piece of functionality, you must be able to find it — and find it with confidence that you have found all of it, not just the most obvious part.</p>
<p>This is where fat services fail. When business logic accumulates in large service classes organised around user stories or features, the domain structure disappears. Logic that belongs together by domain reason is separated. Logic that is separate by domain reason collides in the same class. Over time, the service becomes an archaeological record of every feature request, in chronological order, and understanding what it does requires reading its entire history.</p>
<p>Extensibility is only achievable when the code is structured around the domain — around what the business actually is, not around how it was requested. When that structure exists, adding a new capability means finding the right place in a coherent map. When it does not exist, extending the system means navigating a maze and hoping you found everything relevant.</p>
<hr />
<h3>5. Readability</h3>
<p>Readability is not a soft property. It is not aesthetic. It has direct economic consequences over a fifteen-year lifespan that compound in ways that eventually make a system unreformable.</p>
<p>The measure of readability in enterprise software is not whether an experienced developer finds the code elegant. It is whether the intent and structure are followable to a non-engineer — a domain expert, a compliance officer, a business analyst — who can read the code and recognise their domain in it. This does not mean every line reads as plain prose. Some domains have irreducible technical density: complex financial calculations, regulatory rule engines, actuarial models. The bar is not that the implementation is self-explanatory to someone without domain expertise. The bar is that the <em>structure</em> expresses the domain, that the <em>intent</em> is visible, and that the domain expert can follow the logic well enough to identify where their understanding is or is not correctly represented.</p>
<p>If the code reads like hocus pocus at the structural level to the person who understands the business, the code has failed at its most important communication task.</p>
<p>This standard has consequences for every micro-decision in implementation. It argues against stream operations where a for-loop is clearer to a broader audience — not because streams are wrong, but because in domains where large in-memory sets are never permitted by design, the performance justification evaporates and only the readability cost remains. It argues against boilerplate reduction that sacrifices expressiveness for terseness. It argues against every clever idiom that shortens the code for its author while lengthening the cognitive load for its future readers.</p>
<p>"Boilerplate" is only boilerplate if it has no business purpose. Code that is verbose because it is expressing a business process is not boilerplate — it is documentation, in the only place documentation is always current. The argument to reduce it is always an argument to optimise for the writer. In enterprise software, the reader is nearly always more important. The code will be read an order of magnitude more times than it is written, by people who were not present when it was created.</p>
<p>On large data sets specifically: the correct architectural response is not to optimise how they are processed in memory — it is to enforce a boundary that prevents unbounded datasets from reaching the application layer at all. Chunk the data before it is loaded. This is an architectural constraint, not a performance trick. By making large in-memory sets structurally impossible, the design eliminates the entire class of optimisation pressure they create. The complexity of cursor management and pagination lives at the data access boundary, where it belongs, not scattered as stream operations through business logic. The upstream constraint produces downstream simplicity.</p>
<p>Readability is the condition that makes the other properties achievable. Code that reads like the domain can be upgraded because the domain is visible in it. Code that expresses intent clearly can be maintained because its purpose is self-evident. Code that maps the domain accurately can be extended because the map can be followed. It is not one property among five — it is the keystone.</p>
<hr />
<h3>6. Organisation</h3>
<p>Organisation is qualitatively different from the first five properties. Those are visible in the codebase — you can read them, measure them, argue about them in a code review. Organisation is visible in what the codebase was <em>allowed to become</em>. It is the soil in which the other properties grow or fail to grow. Making it an explicit pillar says: this cannot be managed by ignoring it.</p>
<p>The question every development team eventually confronts is whether the organisation is supportive or restrictive. The honest answer is that it is almost always intended to be supportive and frequently experienced as restrictive — and the gap between those two is where a significant amount of enterprise software complexity originates.</p>
<p>The most common form this takes is architectural mandate without domain justification. Platform teams, rightly responsible for consistency and infrastructure standards, apply patterns designed for large distributed systems universally — including to applications that are, by domain definition, a single coherent thing. Microservices architectures get mandated for systems with no independent scaling requirements, no team boundary that would justify a service boundary, no domain reason for a network boundary to exist. The result is artificial complexity: deployment pipelines for services with no independent reason to exist, network calls where function calls would suffice, operational overhead that consumes development capacity without adding production value.</p>
<p>The architecture was not wrong for all systems. It was wrong for this system, for this domain, at this scale. But the mandate did not ask about the domain. It asked about organisational standards. And the production system pays the difference on every deployment, every change, every new hire who must learn the infrastructure before they can touch the domain.</p>
<p>This is organisational complexity billed to the production system. It feels like support. From the production system's perspective it is an undiscussed tax with no domain justification.</p>
<h4>The Toyota Parallel</h4>
<p>Toyota solved this problem in manufacturing and the solution translates directly to software development. The Toyota Way rests on two pillars: continuous improvement, and respect for people. Both are violated by the organisational patterns that produce restrictive environments.</p>
<p>Respect for people, in the Toyota sense, is not about workplace culture. It is an epistemological principle: the people closest to the work hold the most valuable knowledge about the work. On the production floor, the assembly worker who notices something wrong knows something the engineer in the office does not. Toyota's andon cord exists to make that knowledge immediately actionable — any worker can stop the line when they identify a defect, because the cost of a defect that travels further down the line is exponentially higher than the cost of stopping to fix it now.</p>
<p>In software development the people closest to the work are the developers and the domain experts. The domain expert who says "this doesn't reflect how we actually work" is pulling the andon cord. The developer who identifies a structural problem in the architecture is pulling the andon cord. Organisations that route those signals through layers of translation — product owners, project managers, UX designers, platform architects — are not being more rigorous. They are covering the cord in bureaucratic insulation and walking past it.</p>
<p>The second Toyota concept worth applying directly is <em>genchi genbutsu</em> — go and see for yourself. Do not manage from reports. Do not accept translated summaries. Go to where the work happens and observe it directly. For software this means the developer sitting with the domain expert, watching them work, seeing where the system creates friction, understanding the domain from its source rather than from a requirements document that passed through three people before it arrived. Every layer of translation between the domain expert and the developer is a layer where meaning is lost and assumption is substituted.</p>
<p>The third is <em>jidoka</em> — quality built in, not inspected in after the fact. You cannot UX-design your way to a correct domain model. You cannot test your way to a correct domain model. The correctness must be present from the beginning, in the understanding that shaped the implementation. When domain feedback arrives late — filtered through contact persons who are not the domain authorities, interpreted as a UX problem rather than a domain problem — the system has already been built around an incomplete model. Correcting it at that point is expensive. The organisational structure that produced the late feedback is the root cause, not the feedback itself.</p>
<h4>Domain Feedback Is Always a Learning Opportunity</h4>
<p>When domain experts say a system is too complex or doesn't make sense to them, the instinct in process-first organisations is to call a UX designer. This is solving the wrong problem at the wrong layer. UX is interface orientation — it makes existing concepts easier to navigate. It cannot fix a missing concept. If the domain model is incomplete, no amount of interface polish makes it clearer. You cannot design your way around a hole in the domain.</p>
<p>"Too complex" from a domain expert almost always means one of two things: a concept that exists in their mental model is absent from the system, or the system is telling a story the domain expert doesn't recognise as their own. Both are domain problems. The correct response is a domain conversation, not a design review.</p>
<p>This reframes what domain feedback actually is. It is not obstruction. It is not a sign that the users don't understand the system. It is the most valuable signal available — an authoritative source reporting that the model is incomplete. Organisations that treat it as a learning opportunity produce better software. Organisations that treat it as a user adoption problem produce expensive workarounds for incorrect models.</p>
<h4>Discovery-Driven Implementation</h4>
<p>The organisational conditions described above — domain experts who can reach the development team, feedback treated as learning, developers trusted to inquire beyond the story — enable something that process-constrained environments make nearly impossible: discovery-driven implementation.</p>
<p>Most software development is story-driven. The solution space is bounded by what was requested. The developer's job is to implement the described behaviour correctly and completely. This produces correct implementations of incomplete specifications, reliably and at scale.</p>
<p>Discovery-driven implementation starts from the same user story but treats it as a symptom description rather than a solution specification. The developer who asks enough questions about the domain — who wants to understand not just what was asked but why, what problem it actually solves, what the current process costs, where it fails — occasionally discovers that the problem as described is not the real problem. The real problem is upstream. And the solution to the real problem makes the described problem structurally impossible rather than better managed.</p>
<p>This kind of insight cannot be mandated. It cannot be specified in advance. It cannot be written as a test before it exists. It emerges from genuine engagement with the domain, from the developer who treats the user story as a starting point rather than a work order, from the organisation that protects the space for that inquiry rather than constraining every hour to story execution.</p>
<p>The deepest return on domain understanding is not better implementation of what was asked. It is the occasional recognition that the problem as described is a symptom — and that the real solution makes the symptom structurally impossible. That insight cannot be mandated, cannot be specified, cannot be tested before it exists. It emerges from genuine engagement with the domain, and it is available only to the developer who treated the user story as a starting point rather than a work order. Organisations that protect that space — that trust developers to inquire, to discover, to propose solutions nobody asked for because nobody knew to ask — produce software that solves real problems. Organisations that constrain that space to story execution produce software that manages symptoms, expensively, forever.</p>
<hr />
<h2>The Foundation Beneath the Properties</h2>
<p>Every property described above is downstream of something that is not a technical practice at all. It is understanding.</p>
<p>You cannot write readable code about something you do not understand. You cannot structure something well that you have not thought through. You cannot know what to leave out — which is often more important than knowing what to put in — unless you understand the domain well enough to recognise what is essential and what is incidental.</p>
<h3>The User Story Is Not a Work Order</h3>
<p>A user story is a starting point for a conversation, not a specification for implementation. The moment a developer treats it as a work order — something to be implemented against acceptance criteria, tested to green, and closed — they have accepted someone else's translation of the domain as complete and correct. That translation is almost never complete, and sometimes critically incorrect.</p>
<p>The developer's job before the first line of code is to understand the business goal behind the story. Not the described behaviour — the goal. This requires asking questions. Not to clarify ambiguous requirements, but to understand the domain itself. What is this actually trying to achieve? What are the edge cases the domain expert considers obvious? What should this system never do, and why?</p>
<p>Consider a user story about calculating UBO — Ultimate Beneficial Ownership. A developer implementing against the story might write: find all natural persons with ownership percentage above the threshold. That is what the acceptance criteria describe. The tests pass. The implementation is wrong.</p>
<p>A correct understanding of UBO reveals that it is not about direct ownership percentage in isolation. It is about effective control — who ultimately determines the decisions of the entity, regardless of how the ownership structure is arranged. The question is not just who is the UBO. It is who <em>else</em> is the UBO. And it is who <em>also</em> has control. If there is no "also" — there is just one.</p>
<p>That small shift in framing immediately surfaces a class of scenarios that the acceptance-criteria reading misses entirely. Consider natural person 1 who holds 4% in company A and 4% in company B. Company A holds 96% in company B. Company B holds 96% in company A. By direct ownership percentage, natural person 1 appears below the UBO threshold. By effective control, natural person 1 is 100% the UBO of both companies — because the circular cross-ownership means neither company has any independent shareholder beyond this person.</p>
<p>No test-first methodology surfaces this. No refactoring produces it. Domain understanding produces it, in the conversation before a line of code is written, because a developer who understands what UBO law is actually designed to do recognises this scenario not as an edge case but as a textbook example of what the law was written to catch.</p>
<h3>What the Implementation Should Not Be</h3>
<p>Domain understanding does not only tell you what to build. It tells you what not to build — and that is often more valuable.</p>
<p>When you understand that UBO is about effective control through any structure, you immediately know the implementation should not be a threshold check on direct ownership percentages. That single "should not" eliminates the naive implementation before it is written. It eliminates an entire class of wrong solutions without a single line of code.</p>
<p>This is the discipline of subtraction. Every constraint that comes from genuine domain understanding is a constraint that prevents future complexity. What is not there cannot introduce a bug. What is not there requires no maintenance. What is not there cannot become the thing nobody dares touch because nobody understands why it exists.</p>
<p>The simplest correct solution is also the most durable one. Not because simplicity is aesthetically preferable, but because complexity compounds. Every unnecessary abstraction, every dependency added for theoretical future benefit, every pattern introduced for a problem the system does not have — each one is a tax on every future change, every new hire, every upgrade cycle. Over fifteen years those taxes become the reason a system becomes unreformable.</p>
<h3>The Right Level of Test Coverage</h3>
<p>Honest test coverage in enterprise software is not a percentage target. It is a risk assessment.</p>
<p>The question is never "what percentage of lines are covered?" It is: "where are the places this system could be silently wrong, and how quickly would we know?" Tests earn their place where the real-world feedback loop is too slow, too infrequent, or too opaque to catch failures naturally.</p>
<p>A login page that breaks gets reported within minutes — high-frequency paths like these are well covered by integration, smoke, and end-to-end tests that run as part of any competent CI pipeline. Deep unit testing of those flows is redundant effort. A UBO calculation might run once a day for a small compliance team. It could be wrong for weeks before anyone notices. The domain is complex enough that failures are non-obvious. That is precisely where a behavioral test earns its place: not as a development guiderail, but as a specification of correctness for something that does not announce when it is wrong.</p>
<p>In practice, this produces test coverage in the range of 30 to 50 percent — not because the rest of the code is untested, but because the rest of the code is covered by higher-level tests and validated continuously by the people using it. The 30 to 50 percent that is explicitly tested at the unit or behavioral level is the core domain logic: the calculations, the rule evaluations, the business-critical paths where silent failure is a real and consequential risk.</p>
<p>This is a more defensible position than 90 percent coverage that includes getters, setters, login flows, and string formatting. Coverage as a metric measures lines executed, not correctness guaranteed. Behavioral tests on the domain core, combined with integration tests on the main flows and a system simple enough that its failures are visible, produces better assurance than a heavily instrumented suite that tests implementation details nobody will care about in year seven.</p>
<h3>The Training Wheels Problem</h3>
<p>There is a pattern in software development where tests function not as a quality mechanism but as a substitute for understanding. If the developer does not fully understand what they are building, green tests provide a guiderail: as long as the tests pass, the implementation is probably acceptable.</p>
<p>Training wheels do not teach balance. They teach riding without balance — a different skill entirely. A developer conditioned by green tests as their primary signal learns to satisfy the tests. A developer who understands the domain learns what the business actually needs. Those are not the same education, and in complex domains they produce starkly different results.</p>
<p>The test suite becomes a confidence mechanism decoupled from correctness. The tests reflect the developer's mental model of the domain. If that mental model is incomplete — and without domain inquiry it almost certainly is — the tests are an incomplete specification, confidently asserted as complete. This is worse than no tests. It is false assurance.</p>
<p>The cure is not better tests. It is understanding deep enough that the test's contribution becomes marginal. If the code expresses the domain correctly and reads plainly enough for a domain expert to validate its structure, the test suite's role as documentation and safety net diminishes considerably. A tester who says his functional tests serve as documentation of the application is making an admission: the production code has failed at its most important job. Documentation belongs in the place where it is always current — in code that reads like the domain it represents.</p>
<hr />
<h2>When the Process Becomes the Bug</h2>
<p>There is a question worth asking of every engineering practice, every tool, every ceremony: is this the best choice for the production system, or is it the best choice for the process, the tooling, or trend compliance?</p>
<p>The production system is the artifact that matters. Everything else — the sprint board, the Jira backlog, the test suite, the deployment pipeline, the architecture decision records — is support infrastructure. It exists to serve the production system. The moment any of it starts making decisions for the production system, the hierarchy has inverted. And it inverts constantly, quietly, and with complete institutional legitimacy.</p>
<p>Nobody says "we are going to let Jira determine our engineering decisions." But when a five-minute bug fix gets put on the backlog because the process requires it, Jira just made an engineering decision. When a developer adds an abstraction layer to satisfy a test framework rather than to express the domain, the test suite just shaped the production system. When a simple piece of logic gets restructured to comply with a framework convention that has no business relevance, trend compliance just overrode domain clarity.</p>
<p><strong>Process thinking asks:</strong> are we following the process correctly? <strong>Production thinking asks:</strong> what is the best outcome for the system?</p>
<p>When they conflict, the answer should be immediate and unambiguous: the production system wins. The process is a tool. Tools do not have votes.</p>
<h3>The Bug Economics</h3>
<p>Consider the real cost of a simple bug — a button that doesn't work, an enum stored as an integer instead of a string — when it travels through a process-first system versus a production-first one.</p>
<p>In a production-first system with simple, readable code and a CI pipeline that allows release at any time: the bug is reported, understood, fixed, and released the same day. Total engineering time: five minutes to fix, minutes to release. The user experiences a brief interruption and a same-day resolution.</p>
<p>In a process-first system the same bug looks like this:</p>
<ul>
<li><p>Reported and logged: 10 minutes of administration</p>
</li>
<li><p>Discussed in standup or triage: 20 minutes</p>
</li>
<li><p>Estimated and planned into a sprint: 15 minutes in a planning meeting</p>
</li>
<li><p>Picked up one or two sprints later by a developer who must first relearn the context, understand the bug, navigate the abstraction layers, fix the code, fix the broken tests, and write new tests: 60 minutes or more</p>
</li>
</ul>
<p>Total: approximately 110 minutes of engineering time to resolve a 5-minute problem, with the user waiting six weeks for a fix that was always trivial. That is a 22-times cost multiplier applied entirely by the process. The bug is not better fixed. The system is not more stable. The outcome is strictly worse in every dimension — cost, speed, and user experience — and the process produced it.</p>
<h3>The Kaizen Parallel</h3>
<p>This is not a new insight. Toyota's lean manufacturing principles identified this failure mode decades ago under the concept of <em>muda</em> — waste. Waste in production systems is any activity that consumes resources without adding value. The 105 minutes of process overhead on a 5-minute fix is almost pure waste: motion without value, waiting, unnecessary processing.</p>
<p>The deeper Kaizen principle is that the person closest to the problem is best positioned to fix it. The developer who wrote the code, who understands it today, who can see the bug clearly right now — that person fixing it immediately is the optimal outcome by every measure. Deferring it transfers the problem to a different person at a different time with less context, more overhead, and a worse result.</p>
<p>Empirically, this approach does not produce more bugs. Teams that have observed both models report comparable defect rates. The difference is resolution time: same-day fixes versus multi-sprint delays. On the metric that actually matters to the business — how long does a known problem affect users — the simple, production-first system wins decisively.</p>
<hr />
<h2>The Real Job</h2>
<p>The assembly part of software development — implementing a described behaviour to pass a set of tests — is a commodity skill. It is increasingly automatable. It produces measurable output in a sprint and moves tickets across a board. It is the part of the job that process-first thinking measures, rewards, and optimises for.</p>
<p>The understanding part is not a commodity. It is not automatable. It does not show up in velocity metrics or test coverage percentages. But it is the part that determines whether the software is actually correct. It is the part that finds the circular ownership scenario before it becomes a compliance incident. It is the part that knows what to leave out. It is the part that produces code readable enough that a domain expert can spot an error without running a test. It is the part that makes a bug a five-minute fix rather than a two-sprint project. And it is the part that occasionally recognises that the problem as described is a symptom — and builds the thing that makes the symptom impossible.</p>
<p>Everything that is not in direct service of the production system is not neutral overhead today. It is an obstacle tomorrow. The fifteen-year lifespan makes this visible in a way that a two-year project never does. The complexity accumulates. The process overhead compounds. The abstractions added for testability become the walls that trap the system. The dependencies added for framework compliance become the liabilities that prevent the upgrade. The architectural mandates applied without domain justification become the constraints that make every change expensive.</p>
<p>Ask of every decision: is this the best choice for the production system? If the honest answer is "no, but it satisfies the process" — remove it. Whatever is not there cannot break, does not need maintenance, and does not need to be understood.</p>
<p>Simplicity is not the absence of effort. It is the result of understanding deep enough to know what to remove.</p>
<hr />
<p><em>The properties described in this article — longevity, upgradeability, maintainability, extensibility, readability, and organisation — are not independent qualities to be optimised separately. They are consequences of a single discipline: understanding the domain well enough to represent it simply, correctly, and durably in code that will outlast the people who wrote it. The process serves that goal. When it stops serving that goal, the process is the bug.</em></p>
<hr />
]]></content:encoded></item><item><title><![CDATA[What Is a Rich Domain Model?]]></title><description><![CDATA[Most articles about rich domain models get lost in comparisons to anemic models, debates about OOP mechanics, or pattern catalogues. This is not one of those articles.
A rich domain model is not a tec]]></description><link>https://blog.leonpennings.com/what-is-a-rich-domain-model</link><guid isPermaLink="true">https://blog.leonpennings.com/what-is-a-rich-domain-model</guid><category><![CDATA[Rich Domain Model]]></category><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[DDD]]></category><category><![CDATA[Java]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Tue, 19 May 2026 11:46:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/a19e0a96-16dc-4f49-9fdc-8bd76e883848.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most articles about rich domain models get lost in comparisons to anemic models, debates about OOP mechanics, or pattern catalogues. This is not one of those articles.</p>
<p>A rich domain model is not a technical pattern. It is a discipline — one that produces a living, explicit representation of the essential complexity of a business domain. Understanding what that means, and what it unlocks, requires stepping back from the code entirely.</p>
<hr />
<h2>Essential Complexity, Made Explicit</h2>
<p>Start with what a rich domain model actually is.</p>
<p>It is a set of objects, each playing a defined role in the business domain, each owning the responsibility that role entails. Not what state they carry — but what they know, what they decide, and what belongs to them. Think of it less like a data structure and more like a cast of actors: each one has a role, and the role defines everything. What they are responsible for. What they know. What they act on. What they refuse.</p>
<p>This distinction matters more than it might seem. An actor on stage is not described by listing their costume and props. They are described by their role — what they do, what they own, what they are accountable for. The props are incidental. In the same way, a domain object is not defined by the fields it holds. It is defined by its responsibility. State may be part of how it fulfills that responsibility — but it is an implementation detail of the role, not the definition of it.</p>
<p>The contrast with an anemic model follows directly. An anemic model is a cast of actors who have been stripped of their roles. They stand on stage holding props while someone offstage calls out instructions. The data is visible. The knowledge of what to do with it is gone — moved into service classes, transaction scripts, and workflow configurations that grow without principle and conflict without resolution.</p>
<p>Fred Brooks gave us the vocabulary to understand why this matters. He distinguished between <em>essential complexity</em> — the complexity intrinsic to the problem itself, which cannot be removed — and <em>accidental complexity</em>, everything else: the frameworks, the indirections, the patterns applied without cause.</p>
<p>The actors and their roles <em>are</em> the essential complexity. They are not a representation of it or a metaphor for it — they are it, made visible and explicit. Every business rule that is genuinely hard, every lifecycle that has real consequences, every constraint that exists because the business demands it: these find their home in a role, owned by an actor, named and present in the model. You can see the essential complexity. You can point to it. You can reason about it directly.</p>
<p>Once the essential complexity is that explicit, accidental complexity loses its camouflage. It cannot pretend to belong. Every framework choice, every infrastructure decision, every pattern applied can be held up against a simple question: does a domain object — a named actor with a defined role — actually require this? If not, it is accidental complexity, and it has no business being there. The model makes that judgment possible because the essential complexity is no longer hiding.</p>
<p>This is not the same as reducing complexity. The business is as complex as it is. What changes is whether that complexity is visible, owned, and honest — or scattered, implicit, and discovered only when things break. The rich domain model ensures the essential complexity is always primary. Everything else is secondary, and known to be so.</p>
<hr />
<h2>A Tool for Learning the Domain</h2>
<p>Most development approaches start from requirements. A user story describes motion through a system: a user does something, something happens. This teaches you the rivers — the flows, the happy paths, the scenarios that have been thought of so far.</p>
<p>A domain model teaches you the terrain. Once you understand the terrain, the rivers make sense. Without it, you are always following water, never knowing where you are.</p>
<p>This distinction matters enormously in practice. When a developer learns a business domain through user stories and debugging, they accumulate procedural knowledge. They learn symptoms. They build a mental model that is a patchwork of scenarios, edge cases, and tribal knowledge. That understanding does not transfer easily and does not survive personnel changes.</p>
<p>When a developer learns through the domain model — starting with the core concepts, understanding their responsibilities and relationships — they learn causes. The <em>what</em> and <em>why</em> of the business becomes clear before the <em>how</em>. Onboarding that previously took months can take hours, not because the business became simpler, but because its essential structure was made explicit and navigable.</p>
<hr />
<h2>Canonical Truth for the Business Domain</h2>
<p>A codebase without a domain model has no authoritative reference for what the business believes. Logic accumulates in transaction scripts, in service classes, in stored procedures, in workflow configurations. It is never gathered in one place where you can ask: is this consistent? Does this conflict with that?</p>
<p>The rich domain model is that place. It is not documentation in the sense of comments or wikis — those go stale and lie. It is living documentation, expressed in code, that is wrong only when the code is wrong. When two features conflict, the domain model is the referee. When a new requirement arrives, the model is the context in which it is evaluated — is this already expressed somewhere? Does this contradict something that exists?</p>
<p>Without that context, conflicting logic does not just happen occasionally. It is inevitable. There is no shared reference, so there is no way to prevent divergence. The model prevents it not through process or discipline, but through the simple fact of existing.</p>
<hr />
<h2>What Belongs in the Domain Model</h2>
<p>A common misconception is that domain objects are database rows dressed up with methods. This conflation produces models that are anemic by construction — the shape of the schema becomes the shape of the domain, and the domain becomes a mirror of the persistence layer rather than a representation of the business.</p>
<p>The domain object is defined by its <em>responsibility</em>, not by its persistence. Whether it holds state is irrelevant to whether it belongs in the model. What matters is whether it represents a genuine business concept with a defined responsibility.</p>
<p>Some domain objects have state that should be persisted. In that case, the ORM annotations live on the domain object itself — there is no separate entity class, no parallel representation. The domain object is the single source of truth, and persistence is simply a capability some objects happen to have. There is no ORM object that is not a domain object. If one exists, that is the smell — not a feature. Some will object that this violates persistence ignorance — that the domain should not know about its own storage. But a domain object declaring what it needs is not pollution. It is honesty. The alternative — a parallel entity class that mirrors the domain object field by field — is not cleaner architecture. It is the same information written twice, with an extra layer of indirection between them and nothing gained in return.</p>
<p>Other domain objects have no persistent state at all. A <code>CurrencyConversion</code> that owns the rules and cache for converting between currencies is a full citizen of the domain model. An <code>Interaction</code> that represents a session of intent against the domain — carrying the current user, the transaction boundary, the active roles — is a domain object. Neither has a table. Both have clear, defined responsibilities.</p>
<p>The question is never "does this have a table?" The question is always "does this represent something real in the business, with a responsibility that can be named?"</p>
<hr />
<h2>The Interaction: A Worked Example</h2>
<p><code>Interaction</code> deserves particular attention because it illustrates what correct modeling unlocks beyond the obvious.</p>
<p>Every non-trivial business application has the concept of an interaction: a moment of intent against the domain, initiated by a known user, within a defined transactional boundary, with a lifecycle that has a beginning and an end. This concept exists whether you model it or not. The question is whether it is explicit or scattered across framework configuration, security filters, transaction annotations, and audit log scrapers.</p>
<p>When modeled explicitly — made available within the execution context, whether via <code>ThreadLocal</code>, scoped storage, or whatever the runtime demands — <code>Interaction</code> becomes the natural owner of everything that belongs to that lifecycle. The storage mechanism is an implementation detail. The concept is not.</p>
<p>During an interaction, any part of the domain can ask <code>Interaction.hasUserRole(CancelOrderRole.class)</code> — not as a security check imposed from outside, but as a domain question answered where the action is performed. Authentication is resolved before the interaction begins; a valid <code>Interaction</code> means a valid user. Authorization is expressed where it is enforced.</p>
<p>At the end of an interaction, deferred actions execute within the same transactional boundary. Emails are sent, events are fired, downstream reactions trigger — and if anything fails, everything rolls back, including the email that had not yet been sent. This guarantee is structurally impossible to achieve with a message broker bolted onto the outside of an application without significant infrastructure overhead. Here it is a natural consequence of the model.</p>
<p>After the end of an interaction, post-transaction actions execute outside the boundary, intentionally and explicitly. On cleanup, state is torn down predictably — no leaked state between requests.</p>
<p>The audit trail — who did what, when, and did it succeed — emerges naturally because <code>Interaction</code> already knows all of it. It is not assembled from logs after the fact.</p>
<p>None of these capabilities were designed individually. They are all consequences of modeling the right concept. This is what essential complexity, made explicit, produces.</p>
<hr />
<h2>The Order: Lifecycle as Domain Responsibility</h2>
<p>The same principle applies to any object with a meaningful lifecycle. Consider an <code>Order</code>.</p>
<p><code>Order</code> is constructed from an <code>OrderRequest</code>. In its constructor — or through an <code>assemble()</code> method called immediately — it validates that all items have prices (failing fast if not), reserves inventory, creates the <code>Invoice</code>, determines from the request whether fulfillment is pickup or delivery, and if delivery, creates the <code>Shipment</code> internally. No external coordinator performs these steps. The <code>Order</code> knows what it means to be an order.</p>
<p>Once assembled, the <code>Order</code>'s state gates what is possible. <code>deliver()</code> is only reachable because <code>assemble()</code> completed. Anything attached to an order — documents, notes, events — is evaluated against the current state. The object enforces its own rules.</p>
<p>The lifecycle of the order is expressed in <code>OrderMilestone</code> objects: <code>created</code> at <code>LocalDateTime</code> X, <code>ItemsCompleted</code> at X+1, <code>Shipped</code> at X+2. This is not logging in the developer sense. This is the <code>Order</code> remembering its own history. Audit trails, reporting, and debugging are free consequences of a model that is honest about time.</p>
<p>There is no <code>OrderService</code> that knows the steps. There is no <code>OrderProcessor</code> that coordinates the flow. What is often called orchestration is simply the <code>Order</code>'s own behavior, waiting to be claimed.</p>
<hr />
<h2>There Is No Such Thing as Orchestration</h2>
<p>"Orchestration" is a concept that appears when objects are not carrying enough responsibility. The argument is that some flows are too complex to live in any single object, that something external must coordinate. But this argument always rests on the same foundation: the objects being coordinated are anemic. They cannot coordinate themselves because they hold no behavior.</p>
<p>The stronger claim is this: orchestration is a business process, and every business process has an owner. The moment you ask "whose responsibility is this flow?" the answer is always a named thing in the business. Named things in the business belong in the domain model.</p>
<p>If the checkout flow belongs to <code>Order</code>, there is no orchestration — only an object doing its job. If a more complex cross-domain process exists, the business has a name for it. That name is your object.</p>
<p>The workflow engine question resolves the same way. A workflow engine is infrastructure for implementing an unmodelled requirement. It allows a business process to be encoded without ever being understood. The process runs, tickets close, and the pressure to model never arrives. Meanwhile the process becomes invisible — it lives in configuration, not in the domain, and the model no longer reflects reality.</p>
<p>By making the process explicit in the model, you force the understanding upfront. Traceability, accountability, and auditability are not bolted on afterward — they are natural consequences of a process that is owned and expressed. And the model becomes resistant to casual change. A workflow engine can be reconfigured quietly. A domain object that explicitly models a process requires intentional change. You must touch the model. That is not a constraint — it is a feature.</p>
<hr />
<h2>The Architecture That Emerges</h2>
<p>When the domain model is honest and complete, the architecture that surrounds it becomes remarkably simple.</p>
<p>The domain is the center. Everything else is translation. An adapter takes an external signal — an HTTP request, a queue message, a UI event, a file drop — translates it into something the domain understands, and translates the response back. Whether that adapter is called a web service, a UI connector, or a queue client is an implementation detail. Its functional purpose is always the same: adapt an external request to the domain, and an answer from the domain to the outside world.</p>
<p>This framing eliminates the need for many patterns that exist only because the domain is not carrying its weight. There is no need for a dependency injection container to wire together a domain that is self-contained. There is no need for a repository pattern when persistence is an annotation on the domain object that requires it. There is no layered architecture to enforce when the boundary between domain and adapter is conceptual and obvious.</p>
<p>The complexity budget is spent entirely on essential complexity, because there is nowhere for accidental complexity to hide. Every technology choice can be evaluated against a single question: does a domain object require this? If not, it has no business being there. The domain model is not just a design tool — it is the justifier for every architectural decision, the brake on over-engineering, and the answer to YAGNI grounded not in gut feel but in domain reasoning.</p>
<hr />
<h2>The Principle Underneath</h2>
<p>There is a principle that connects everything above:</p>
<p><em>The ease of implementing something without modeling it is proportional to the hidden cost of never having modeled it.</em></p>
<p>Every approach that starts from <em>how</em> rather than <em>what</em> — procedural scripts, transaction-script architectures, use-case driven development — shares this characteristic. The requirement is the input, the implementation is the output, and the domain never appears. Each new requirement starts from scratch, because there is no accumulated understanding to build on. The codebase grows. The knowledge does not.</p>
<p>A rich domain model inverts this entirely. The domain is the input. Requirements are queries against that understanding. New requirements find their place in something that already exists — or reveal, through the friction of not fitting, that the domain needs to grow. Either way, understanding accumulates. The model becomes more true over time, not less.</p>
<p>That is what a rich domain model is. Not a pattern. Not a layer. A discipline of making the essential complexity of a business explicit, owned, and honest — and letting everything else follow from that.</p>
<hr />
<hr />
<h2>Sidebar: On AI-Assisted Development</h2>
<p>AI is genuinely useful in a domain-centric codebase — for implementing adapters, generating boilerplate, and accelerating everything that surrounds the model. It pattern-matches well against known structures, and once the domain is understood, there is plenty of that work to do.</p>
<p>Domain modeling is a different activity. It requires understanding what the business actually is — not just what a ticket describes. It requires recognizing when a concept is missing, resisting the obvious implementation in favor of the correct abstraction, and making judgment calls about responsibility that have no objectively correct answer. AI has no access to the lived understanding that produces those judgments.</p>
<p>The most useful role for AI in a modeling context is as a mirror — a Socratic partner for stress-testing a hypothesis about a concept's responsibility or boundary. It surfaces objections, identifies gaps, and forces precision. That is valuable. But the modeling itself remains a human activity, and the discipline of doing it remains more important in an AI-assisted world, not less. Without the model, AI produces procedural code at unprecedented speed — and accumulates the hidden cost of unmodeled requirements faster than any previous approach.</p>
<hr />
<h2>Sidebar: On Practical Effects</h2>
<p>The common perception is that a rich domain model requires heavy upfront investment — that you must design everything before writing any code, and that this slows delivery. In practice the opposite is true, and the gap becomes visible quickly.</p>
<p>Early in a project, a team building a rich domain model is establishing core concepts and their responsibilities. This feels slower than a team wiring up framework configuration and generating boilerplate. But by the time the first meaningful features are being built, the domain team is adding behavior to objects that already understand the business. New requirements find their place. The model tells you where things belong. The other team is asking "where does this code go?" for every new feature — and the answers are becoming less consistent, not more.</p>
<p>The acceleration compounds. Maintenance is cheaper because the model is the documentation — it cannot go stale, because it is the code. Debugging is faster because the model expresses business intent, not just technical state. The difference between "the Order refused shipment because it was already delivered" and "some process node returned an unexpected status" is the difference between understanding and archaeology.</p>
<p>The people costs tell the same story. Onboarding a developer onto a well-modeled domain takes hours, not months. The knowledge is in the model, not in the heads of the people who built it. That is not just an efficiency gain — it is a risk reduction. The bus factor of an application with an explicit domain model is structurally higher than one without.</p>
<p>The cost of not modeling is real, large, and almost never measured — because there is no comparable version of the same application where it was modeled. You cannot see the cost of understanding you never accumulated. You only feel it, gradually, in every feature that takes longer than it should, every bug that touches more than it should, and every developer who leaves taking knowledge that was never made explicit.</p>
<hr />
<hr />
<h2>Further Reading</h2>
<p>This article is part of a series on domain-centric thinking.</p>
<p>If this raised the question of <em>how to start modeling</em> — how to discover the actors, assign the roles, and run a discovery session before a line of code is written — that is covered in <a href="https://blog.leonpennings.com/rich-domain-models-start-with-what-is-not-what-happens">[Rich Domain Models: Start with What Is, Not What Happens]</a>.</p>
<p>If you want to see a domain model grow through concrete examples — how a real model evolves as understanding deepens, and what it means to let a new requirement reshape the model rather than just add to it — that is covered in <a href="https://blog.leonpennings.com/rich-domain-modelling-a-library-story">[Rich Domain Models: A Library Story]</a>.</p>
<hr />
<p><em>The concepts in this article reflect practical experience building domain-centric applications. The</em> <code>Interaction</code> <em>pattern described has been in production use since 2009.</em></p>
]]></content:encoded></item><item><title><![CDATA[The Architecture Tax — Why Enterprise Software Is Expensive, and Why AI Won't Fix It]]></title><description><![CDATA[The story the industry tells
Enterprise software is expensive. It requires large teams, significant infrastructure, complex deployment pipelines, and sustained operational effort. Requirements that so]]></description><link>https://blog.leonpennings.com/the-architecture-tax-why-enterprise-software-is-expensive-and-why-ai-won-t-fix-it</link><guid isPermaLink="true">https://blog.leonpennings.com/the-architecture-tax-why-enterprise-software-is-expensive-and-why-ai-won-t-fix-it</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[distributed system]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[Rich Domain Model]]></category><category><![CDATA[DDD]]></category><dc:creator><![CDATA[Leon Pennings]]></dc:creator><pubDate>Mon, 18 May 2026 07:36:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6909c071175a29281d26fa0e/b252f4e4-0f55-4df3-86e1-d767f855f20e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>The story the industry tells</h2>
<p>Enterprise software is expensive. It requires large teams, significant infrastructure, complex deployment pipelines, and sustained operational effort. Requirements that sound simple take weeks. Systems that should be stable require constant attention. The codebase that was coherent at year one is opaque by year four. New developers take months to become productive. Changes that touch multiple parts of the system require coordination that absorbs more time than the implementation itself.</p>
<p>This is treated as a given. Enterprise software is complex, therefore it costs what it costs. The architecture — microservices, distributed infrastructure, containerised deployments, orchestration layers — is presented as the response to that complexity. Sophisticated problems require sophisticated solutions.</p>
<p>The argument this article makes is the opposite.</p>
<p>Most of what the industry calls the cost of enterprise software is not the cost of the domain. It is the cost of workarounds for a missing domain model — compounded over years, normalised by the fact that every team around you is paying the same price and calling it inevitable. The architecture is not the response to the complexity. In most cases, it is the cause of it.</p>
<p>And the reason this remains invisible is that the alternative was never built. You cannot compare your system to the system that does not exist. So the costs accumulate, get attributed to the nature of enterprise software, and become the baseline against which all future decisions are made.</p>
<p>This article is about what is actually in that price tag, and what it would cost without it.</p>
<hr />
<h2>The context problem</h2>
<p>When a team starts building a system, the code is small. The domain is not yet fully understood, but the surface area is manageable. A developer can hold the whole thing in their head. A new feature means adding a function. The system works. Nobody is in pain.</p>
<p>Three years later, the same team — or more likely, a partially replaced team — is asking a different question. Not "does this work?" but "where does this live?" Where does the discount calculation happen? Who owns the rule that a cancelled order cannot be reinstated after shipment? If we change how rush orders are priced, how many places do we need to touch, and how many of those will we miss?</p>
<p>These are not questions about the business domain. The business domain has not become harder. An order is still an order. The questions are about the system — specifically, about where the system chose to put things, and whether that choice was made deliberately or simply accumulated over time.</p>
<p>This is the context problem. It is the root cause of most of the complexity that teams eventually reach for distributed architectures to solve. And it has nothing to do with the scale or ambition of the domain. It is a structural property of how the code was organised from the beginning.</p>
<p>Context, in the sense used here, has a specific meaning. It is not a folder, a module name, or a service boundary. It is the answer to a structural question: <em>given a concept in the domain, is there one authoritative location where all rules governing that concept are defined and enforced?</em></p>
<p>A concept has a context when the answer is yes. It does not have a context when the answer is "it depends" — or "mostly here, but also there, and that other place handles the exception."</p>
<p>The distinction matters because systems do not stay small. Rules accumulate. Exceptions are added. Behaviour that was simple in year one becomes conditional in year two and contradictory in year three. In a system with clear context ownership, that accumulation is manageable — the rules are in one place, contradiction is visible, and the design either holds or signals clearly that it needs to change. In a system without context ownership, accumulation is invisible until it becomes crisis.</p>
<hr />
<h2>Object orientation was supposed to solve this</h2>
<p>The context problem is not new. It is precisely the problem that object-oriented programming was designed to address.</p>
<p>Object orientation, in its original conception, was not about classes, inheritance hierarchies, or design patterns. It was about a single structural idea: that data and the rules governing that data belong together, in one place, unreachable from outside except through defined behaviour. An object is not a container for data with methods attached. It is a context — a thing that knows its own state, enforces its own rules, and decides what to do when asked. The outside world cannot manipulate its internals. It can only send messages.</p>
<p>This is context ownership as a structural property of the code. Logic cannot drift to wherever it is convenient to put it, because the object's state is private. The rule that a shipped order cannot be cancelled does not live in a service method that someone has to know to call. It lives on the order itself, enforced by the fact that the order's status cannot be changed except through the order's own behaviour. It is not a convention. It is a constraint.</p>
<p>This is what object orientation was for.</p>
<hr />
<h2>What Java enterprise actually practises</h2>
<p>The dominant pattern in Java enterprise development — and in enterprise development more broadly — looks like this:</p>
<p>An <code>Order</code> entity holds fields annotated for persistence. Its fields are private, which gives the appearance of encapsulation. An <code>OrderService</code> contains the business logic — the methods that create, modify, and query orders. An <code>OrderRepository</code> handles the database interaction. Data transfer objects carry information between layers.</p>
<p>This pattern is widely understood to be object-oriented. It uses objects. It has private fields. It has classes with clear names and single responsibilities. Senior developers teach it. Frameworks are built around it. It is the default.</p>
<p>It is procedural programming.</p>
<p>The test is not whether the code uses classes. The test is whether data and the rules governing that data are in the same place. In the service-DTO-repository pattern, they are not. The <code>Order</code> entity holds data. The <code>OrderService</code> holds logic. The logic is separated from the data it governs. That is the definition of procedural code — regardless of the language, regardless of the annotations, regardless of the private keyword on the fields.</p>
<p>The private fields are not encapsulation in any meaningful sense. Encapsulation means the object protects its own invariants. Nothing outside can put it in an invalid state. But if <code>OrderService</code> loads an <code>Order</code>, inspects its fields, and decides what to do — the private keyword is decoration. The order is a struct. The service is a function that operates on it. The fact that both are expressed as classes changes nothing about the structure.</p>
<p>A senior developer once described object orientation as "just using a lot of objects." In the Spring ecosystem, that description is accidentally accurate. The objects are present. The orientation — the structural commitment to context ownership — is not.</p>
<p>This matters because it means most teams believe they are already doing what a rich domain model offers. The gap between what they believe and what is actually true is where the context problem silently grows — invisible, until it becomes the thing that makes the system expensive.</p>
<hr />
<h2>How a procedural system rots</h2>
<p>The rot does not happen at once. It has a characteristic progression that is worth tracing, because understanding the mechanism is what makes the solution legible.</p>
<p><strong>Year one.</strong> The system is small. The team is mostly the original team. The rules fit in one or two services. <code>OrderService</code> is coherent because it is young and the domain is still understood by everyone who touches it. Velocity is high. The architecture feels like a good decision.</p>
<p><strong>Year two.</strong> The product grows. New rules are added. The team adds members who know the services they own but not the full picture. A pricing exception is added in <code>OrderService</code> because that is where the original pricing logic lives. A second exception is added in <code>PricingService</code> because by then the first developer has left and the new one reasonably concluded that pricing rules belong in the pricing service. Both are correct by local reasoning. Neither is aware of the other.</p>
<p><strong>Year three.</strong> The team is running two integration tests that cover the same scenario and produce different results depending on which code path is invoked. A bug report arrives: under certain conditions, the price shown to the customer differs from the price on the invoice. Three services are involved in producing those two numbers. The fix requires coordinating changes across all three, understanding the original intent of logic nobody wrote, and ensuring that the correction does not break the scenarios the divergent logic was accidentally handling correctly.</p>
<p>This is not a failure of discipline. The developers are competent. It is the structural consequence of a system that provided no home for rules — so rules went wherever they were needed, and the system slowly became a map of historical decisions rather than a coherent model of the domain.</p>
<p>No amount of discipline permanently solves a structural problem. Discipline degrades over time and team turnover. Structure does not.</p>
<hr />
<h2>The rich domain model as structural answer</h2>
<p>A rich domain model addresses the context problem through structure, not discipline.</p>
<p>The principle is simple: an object owns the rules that govern its own state, and state changes happen only through that object's behaviour. An <code>Order</code> does not have its price calculated by a service. An <code>Order</code> knows its price — it is a property of the order, derived from the order's own data and the rules encoded in the order's own methods. The service does not reach in and manipulate the order's internals. It asks the order to do something, and the order either does it according to its rules, or refuses.</p>
<p>Consider an order system modelled this way:</p>
<pre><code class="language-java">public class Order {

    private final List&lt;OrderLine&gt; lines;
    private final Customer customer;
    private OrderStatus status;
    private ShippingMethod shippingMethod;

    public Money calculatePrice() {
        Money base = lines.stream()
            .map(OrderLine::lineTotal)
            .reduce(Money.ZERO, Money::add);
        return shippingMethod.applyTo(base);
    }

    public void confirm() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Only draft orders can be confirmed.");
        }
        this.status = OrderStatus.CONFIRMED;
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("Shipped orders cannot be cancelled.");
        }
        this.status = OrderStatus.CANCELLED;
    }
}
</code></pre>
<p>The rule that a shipped order cannot be cancelled lives on the <code>Order</code>. Not in <code>OrderService</code>, not in a validator upstream, not in a flag checked somewhere in the call chain. It lives in the only place it could coherently live: the object that owns the concept. A developer three years from now, touching this code for the first time, cannot accidentally bypass that rule — not because the system trusts their discipline, but because the structure does not give them a way to.</p>
<p>The service that orchestrates this is correspondingly simple:</p>
<pre><code class="language-java">@Transactional
public OrderConfirmation createOrder(OrderRequest request) {
    Order order = new Order(request);
    inventory.reserve(order);
    return OrderConfirmation.of(order);
}
</code></pre>
<p>The database transaction is the failure boundary. If anything fails, nothing happened. There are no compensating calls, no saga steps, no partial states to reconcile. The infrastructure serves the domain. The domain is not distorted to accommodate the infrastructure.</p>
<hr />
<h2>Design pressure as a feature</h2>
<p>There is a property of the rich domain model that is easy to overlook: it makes bad design visible before it becomes operational pain.</p>
<p>When a new rule is added that does not fit cleanly — when a developer sits down to implement something and cannot find a natural home for it in the model — that is not an inconvenience. It is a signal. The model is telling you that either the rule is being misunderstood, or the model needs to evolve to accommodate a concept it does not yet represent.</p>
<p>In a procedural system, that signal does not fire. The developer adds a condition to an existing service method, or adds a new service if the feature is large enough. The rule is implemented. It works. The fact that it created divergence from an existing rule, or that it sits awkwardly between two existing concepts, is not visible until months later when something breaks in a way that requires archaeology to understand.</p>
<p>The rich model converts architectural drift from a silent accumulation into an explicit design question. That question is not always comfortable. But discomfort at design time costs a discussion. Discomfort at runtime costs an incident.</p>
<hr />
<h2>The business changed. As it always does.</h2>
<p>The system above handles standard orders. The domain is coherent. The rules are clear. Now the business introduces a new requirement.</p>
<p><strong>Rush orders.</strong> A customer can request expedited fulfilment. This attracts a surcharge — the order price increases by fifteen percent, and the shipping method is upgraded to express.</p>
<p>In a procedural system, this requires touching multiple places. The pricing calculation needs a condition. The shipping assignment needs a condition. If those live in different services, both need to change, both need to be deployed, and the rule "rush orders cost fifteen percent more and ship express" exists nowhere as a statement. It exists as a set of conditional branches distributed across the system.</p>
<p>In the rich domain model, the question the implementation forces you to answer is: <em>what is a rush order?</em> Is it a type of order? A property? Does it affect the order itself or its fulfilment? Answering that question is the design. And the answer produces something like:</p>
<pre><code class="language-java">public class Order {

    private final List&lt;OrderLine&gt; lines;
    private final Customer customer;
    private final boolean rush;
    private final ShippingMethod shippingMethod;

    public Order(OrderRequest request) {
        this.lines = request.lines();
        this.customer = request.customer();
        this.rush = request.isRush();
        this.shippingMethod = rush
            ? ShippingMethod.EXPRESS
            : ShippingMethod.STANDARD;
    }

    public Money calculatePrice() {
        Money base = lines.stream()
            .map(OrderLine::lineTotal)
            .reduce(Money.ZERO, Money::add);
        Money withShipping = shippingMethod.applyTo(base);
        return rush
            ? withShipping.multiplyBy(1.15)
            : withShipping;
    }
}
</code></pre>
<p>The rule lives on the <code>Order</code>. It cannot live anywhere else. Every developer who touches order pricing in the future will find it here, because there is only one place to look.</p>
<hr />
<h2>The business changed again.</h2>
<p>Three weeks after the rush order feature ships, a new requirement arrives.</p>
<p><strong>VIP customers do not pay the rush surcharge.</strong> The expedited shipping still applies — VIPs get the faster fulfilment — but the fifteen percent price increase is waived as a benefit of their status.</p>
<p>This requirement is three sentences of business logic. What it does to a system without context ownership is disproportionate to its size.</p>
<p>In a procedural system, the question is: <em>where does this condition go?</em> The rush surcharge is currently in — actually, let us retrace that. The original pricing was in <code>OrderService</code>. The rush surcharge was added in <code>PricingService</code> because that seemed more appropriate for a pricing concern. The VIP status lives in <code>CustomerService</code>. A rule that says "apply the surcharge unless the customer is a VIP" now requires either a call from <code>PricingService</code> to <code>CustomerService</code> — coupling two services that were not coupled before — or an orchestration layer that assembles the inputs before calling either, or a flag passed through the call chain from wherever the customer is known to wherever the pricing happens, leaking context across layers that should not share it.</p>
<p>Each of these is a workaround. Each adds a seam. And each seam is a place where, two years from now, someone adds another condition, and the question "what does this order actually cost?" requires reading four services to answer.</p>
<p>In the rich domain model, the question is different and better: <em>who owns the rule that VIP customers are exempt from the rush surcharge?</em> Is it the Order? The Customer? A pricing policy?</p>
<p>This is a domain design question. It has a defensible answer:</p>
<pre><code class="language-java">public Money calculatePrice() {
    Money base = lines.stream()
        .map(OrderLine::lineTotal)
        .reduce(Money.ZERO, Money::add);
    Money withShipping = shippingMethod.applyTo(base);

    if (rush &amp;&amp; !customer.isVip()) {
        return withShipping.multiplyBy(1.15);
    }
    return withShipping;
}
</code></pre>
<p>The rule is in one place. It reads as a statement of business intent. It is testable in isolation. When the next requirement arrives — "VIP customers also get free express shipping on rush orders over two hundred euros" — the developer knows exactly where to go, and the existing logic tells them exactly what the current rules are.</p>
<p>If the pricing logic grows complex enough, the model signals it:</p>
<pre><code class="language-java">public Money calculatePrice() {
    Money base = lines.stream()
        .map(OrderLine::lineTotal)
        .reduce(Money.ZERO, Money::add);
    Money withShipping = shippingMethod.applyTo(base);
    return PricingPolicy.forCustomer(customer).apply(withShipping, this);
}
</code></pre>
<p>The complexity of <code>calculatePrice</code> has surfaced a new concept: a <code>PricingPolicy</code>. Not because a framework required it, not because a service boundary forced it, but because the model told you that pricing rules had become rich enough to deserve their own home. This is design evolution driven by the domain — the right kind of complexity, appearing at the right time, for the right reason.</p>
<hr />
<h2>The distributed workaround</h2>
<p>Teams that build procedural systems eventually hit the context problem at scale. Logic is spread across a growing codebase with no clear ownership. Rules diverge. The system becomes expensive to change. The industry's standard response is to enforce context through service boundaries. Order rules live in the Order service. Pricing rules live in the Pricing service. The boundary makes it structurally difficult for one service to reach into another's domain.</p>
<p>This is attempting, through infrastructure, to solve a problem that a domain model solves through structure.</p>
<p>The intuition is understandable. The result is a workaround that costs more than the problem it replaces.</p>
<p>Consider what the VIP rush exemption requires in a distributed system. The Order service needs to price a rush order for a VIP customer. It cannot reach into the Pricing service's data — that violates the boundary. So it calls the Pricing service. But the Pricing service needs to know whether the customer is a VIP — and the Customer service owns that. Now the services are coupled in ways the original boundary was meant to prevent, or an orchestration layer is required to assemble inputs before calling either service, or an event-driven flow is constructed in which services react to each other asynchronously — introducing eventual consistency, message ordering concerns, and a debugging surface that spans multiple log streams.</p>
<p>And this is before considering what happens when the action fails halfway through.</p>
<p>In a monolith with a rich domain model, failure costs a database rollback. One word. The action either completed or it did not. There is no intermediate state. There is no question of what to clean up.</p>
<p>In the distributed system, there is no transaction. If the order is created but the pricing service fails before responding, the system is in a partial state. That partial state must be resolved — not by the database, which knows nothing about it, but by compensating logic: a designed, implemented, tested, and maintained sequence of calls that undoes the steps that completed before the failure. For four services, the failure paths grow as O(n²). Each compensation is a domain operation that must be reachable, idempotent, and tested both in isolation and in combination.</p>
<p>Before any of this business logic runs, the infrastructure required to support it exists permanently: a message broker, a saga framework or hand-rolled saga state table, distributed tracing with correlation IDs propagated through every service and every event envelope, an idempotency layer in every service because message brokers guarantee at-least-once delivery, API contracts and versioning because a breaking schema change is a production incident in every downstream service, and per-service CI/CD pipelines, databases, and operational overhead — multiplied by the number of services.</p>
<p>None of this delivers business value. All of it exists solely to reconstruct, at permanent cost, the properties that a single database transaction provided for free: atomicity, consistency, rollback on failure, and a single coherent answer to what just happened.</p>
<p>The VIP rush exemption — three sentences of business requirement — now requires coordinating across three services, with asynchronous event flows, compensating transactions, and a debugging surface that no single developer can hold in their head.</p>
<p>The Russian space program used a pencil.</p>
<hr />
<h2>The refactorability that distribution destroys</h2>
<p>There is a cost of microservices that receives less attention than sagas and eventual consistency, but which compounds more severely over time: the loss of refactorability.</p>
<p>In a rich domain model, a refactoring is a restructuring of code within a coherent boundary. If <code>PricingPolicy</code> needs to become its own concept, the compiler identifies every place that needs to change. You make the changes, run the tests, deploy. The refactoring is complete.</p>
<p>In a distributed system, a refactoring that touches a service contract is a migration. The event schema consumed by downstream services cannot simply change — it requires a versioning strategy, a migration window, a period of running old and new schemas simultaneously, and coordination across teams who own the downstream consumers. The boundary introduced to enforce ownership has become a fossilised contract. The ownership is preserved. The ability to evolve is not.</p>
<p>This is the trade that distribution forces: you gain enforcement of service boundaries, and you lose the ability to change them cheaply. In a domain that is still being understood — which is most domains, for most of their lifetime — that trade is almost always wrong. The boundaries drawn at year one reflect year-one understanding. The domain will teach you things in year two that make those boundaries look naive. In a monolith with a rich domain model, you redraw the boundary and the compiler helps you. In a distributed system, you live with it, or you pay the migration cost. Most teams live with it. The boundaries fossilise. The system carries the imprint of how the domain was understood at its beginning, permanently.</p>
<hr />
<h2>When distribution is genuinely warranted</h2>
<p>Distribution has legitimate use cases. They share a common property: they are external constraints on the system, not assessments of the current domain.</p>
<p><strong>Proven, asymmetric load.</strong> When one component has a demonstrably different scaling profile — proven by measurement under real conditions, not anticipated in theory — isolating it may be warranted. The question is not "could this theoretically need more scale?" It is "is this the measured bottleneck today, and does the cost of isolation exceed the cost of scaling the whole?" In most systems, no individual component is the bottleneck. The constraint is the atomic action as a whole. Scaling the whole is cheaper and simpler than the industry assumes.</p>
<p><strong>Physical or regulatory constraints.</strong> When data must remain within a specific jurisdiction by law, geographic distribution is warranted. The right approach is to deploy a complete instance of the domain within that boundary — not to split the domain action across a jurisdictional boundary. The atomic action stays atomic. The domain model stays unified. What changes is the deployment target, not the architecture.</p>
<p>Notice what is absent from this list: <em>domain concepts that currently appear independent.</em></p>
<p>Independence is a present-tense assessment of a future-tense system. Two concepts that have no transactional relationship today may acquire one tomorrow when a requirement arrives that neither anticipated. A recommendation engine and a payment processor appear independent until the business introduces a rule that links them. When that happens in a rich domain model, you answer a design question. When it happens in a distributed system, you face a migration — or you violate the boundary with a coupling that was supposed to be impossible, and accumulate the technical debt of a boundary that no longer reflects reality.</p>
<p>Distribution should be warranted by constraints that are immune to domain evolution. Load and regulatory geography qualify. Current domain independence does not. It is a prediction dressed as a structural justification, and systems that are built on predictions about domain shape tend to look naive by the time they are old enough to evaluate.</p>
<hr />
<h2>The modelling capability problem</h2>
<p>A rich domain model does not build itself. It requires developers who can model — who can look at a domain, identify the concepts, understand their rules, and express those rules in objects that own them. This is a different skill from implementing features in a service layer. It is rarer, harder to teach, and not well served by the frameworks and patterns that dominate enterprise Java development.</p>
<p>This is worth stating honestly, because it is the most common objection to everything argued above. "In theory, yes — in practice, we don't have the developers who can do this."</p>
<p>The objection is real. But it is also a consequence of the same feedback loop. The industry has spent two decades building curricula, frameworks, and hiring pipelines around the service-DTO-repository pattern. Developers trained on Spring Boot are trained to think in services and data flows, not in domain concepts and object behaviour. The modelling skill atrophied because the dominant patterns did not require it — and then its absence became a justification for patterns that do not require it.</p>
<p>The distributed architecture does not require modelling capability. It requires operational capability — the ability to manage brokers, sagas, contracts, and deployment pipelines. Those skills are available. They are well-documented. They are what the frameworks teach. So the distributed system gets built, not because it is the right architecture, but because it is the one the available skills support.</p>
<p>What the industry normalised as "enterprise development" is, in significant part, the consequence of this skills gap and the infrastructure that grew up around it. The expensive architecture is the one that does not require the harder skill. The cheaper architecture — cheaper in every long-term dimension — requires developers who can model. Cultivating that capability is a different investment from buying more infrastructure. But it is the one with the compounding return.</p>
<hr />
<h2>But AI will fix this</h2>
<p>The most current version of the objection to everything argued above is not about developer skill. It is about AI coding tools. The argument runs: with AI assistance, the cost of writing procedural code drops dramatically. Features are generated in minutes. Boilerplate disappears. The velocity problem that made structural discipline seem expensive is solved by the tool. So the modelling skill gap does not matter — AI fills it.</p>
<p>This is a plausible argument for small systems at early stages. It does not survive contact with the actual problem.</p>
<p>AI coding tools are, in their current form, genuinely impressive at procedural implementation. Describe a feature clearly and the tool produces technically correct, well-structured code, fast. But the tool does not hold the domain. It holds the prompt. It implements what the prompt describes, in whatever pattern the surrounding codebase suggests — which in most enterprise codebases means a service method, a DTO, and a repository call. The implementation is correct with respect to the request. Whether it is consistent with the system's existing rules is a different question, and one the tool is structurally unable to answer reliably.</p>
<p>The contradiction arrives quietly. In January, a developer prompts: "add a fifteen percent surcharge for rush orders." The AI implements it, correctly, in <code>PricingService</code>. In March, a different developer prompts: "VIP customers should not pay extra for rush orders." The AI implements that too, correctly, somewhere in the call chain — perhaps in <code>OrderService</code>, where the customer context is available. Both implementations are technically sound. Neither developer intended a contradiction. The AI had no way to know one existed, because the domain has no center. The rule "what does a rush order cost?" is not owned by anything. It is distributed across the history of prompts that touched it.</p>
<p>In a rich domain model, this contradiction surfaces immediately. Both rules must live on <code>Order</code>. When the second developer — or the AI they are directing — goes to implement the VIP exemption, the rush surcharge is already there, visible, in the same method. The conflict is structural and immediate. The developer makes a decision. The model is updated. The system reflects the current understanding of the business.</p>
<p>In a procedural system, the conflict is invisible until a customer receives a price that is neither the intended standard price, nor the intended VIP price, but an artifact of two implementations that never knew about each other.</p>
<p>There is a counterargument worth taking seriously: AI tools with sufficient codebase context — through large context windows, retrieval-augmented generation, or persistent memory across sessions — could theoretically detect such contradictions before implementing. Some tools already attempt this. The counterargument is real, and it would be wrong to dismiss it entirely.</p>
<p>But even if the AI detects the contradiction, it cannot resolve it. The question "should VIP customers pay the rush surcharge?" is not answerable by reading the codebase. It is a business decision. The AI can surface the conflict. It cannot determine which rule reflects the current intent of the business, which rule is outdated, or whether both should coexist under different conditions. That requires domain understanding — and domain understanding requires a human with a model, not a tool with a context window.</p>
<p>What the rich domain model provides is not a barrier to AI assistance. It is the structure that makes AI assistance most effective. When the domain is explicit, concepts are well-named, and rules are owned by the objects they govern, AI-generated code within that model tends to be good — because the model itself provides the context the AI needs to generate correctly. The right place to put a new rule is unambiguous. The existing rules are co-located and readable. The AI operates within a structure that guides it toward coherent output.</p>
<p>The deeper issue is velocity. Procedural systems accumulate drift gradually, over years, as developers add logic wherever it is convenient. AI-assisted development does not change the direction of that drift. It changes the speed. What used to take three years of incremental addition now takes months of accelerated feature generation. The same structural absence of context ownership, at an order of magnitude higher throughput. The codebase grows faster than any team's ability to understand it, and the AI has no understanding to compensate with — only pattern matching against what is already there.</p>
<p>AI does not fix the context problem. In a system without a domain model, it compounds it. The same rot, faster. The same contradictions, earlier. The same invisible price tag, arriving sooner.</p>
<p>What AI changes is the cost of implementation. What it does not change — what nothing changes — is that implementation without structure is the most expensive kind. The structure has to come first. The model has to exist before the tool can be trusted to work within it. AI is a powerful accelerant. The question, as always, is what it is accelerating toward.</p>
<hr />
<h2>The invisible price tag</h2>
<p>Consider what a mature enterprise system built on microservices actually costs, outside the domain work itself.</p>
<p>A containerised infrastructure running tens or hundreds of services. An orchestration layer — Kubernetes or equivalent — with its own operational model, upgrade cycle, and expertise requirement. A message broker cluster maintained for high availability. A distributed tracing stack. A log aggregation platform, because individual service logs are unreadable without one. A schema registry and contract testing infrastructure. Per-service CI/CD pipelines, each with its own configuration, deployment windows, and rollback strategy. An on-call rotation that covers distributed failure modes — partial outages, broker lag, compensation failures — that do not exist in a single-process system. A platform or infrastructure team whose entire function is to keep the operational substrate running.</p>
<p>None of this is the domain. None of it delivers business value. All of it is the permanent operational cost of workarounds for missing context ownership.</p>
<p>Now consider the same domain in a well-modelled monolith. A small number of deployable artefacts — perhaps one, perhaps a handful if genuine load asymmetry has been measured and justified. A relational database. A load balancer. Standard application monitoring. A CI/CD pipeline that deploys the whole. An on-call rotation that reads stack traces. The failure modes are the domain's failure modes, not the infrastructure's.</p>
<p>The difference in team size, infrastructure cost, and operational overhead is not the cost of enterprise software. It is the cost of the workaround. The domain is the same. The business rules are the same. The problem being solved is the same. What differs is whether the system paid for a domain model or paid for the infrastructure required to simulate one.</p>
<p>This difference is invisible in most organisations because the alternative was never built. The costs of the distributed system accumulate, get attributed to the scale and complexity of the enterprise domain, and become the benchmark against which new decisions are made. The next system is also built with microservices, because that is what enterprise software costs — and the incomparability between what was built and what could have been built means the attribution is never seriously questioned.</p>
<hr />
<h2>What the rich domain model actually gives enterprise software</h2>
<p>The argument for the rich domain model in large enterprise systems is not that it is elegant or theoretically correct. It is that it is the mechanism by which enterprise software remains manageable over time.</p>
<p><strong>Oversight.</strong> When every rule about an order lives on <code>Order</code>, a developer can understand order behaviour by reading one place. Not by reconstructing a distributed flow across services, event schemas, and asynchronous reactions. One place. This is not a convenience — it is what makes oversight possible as the system grows. Without it, understanding the system requires understanding its history, because the structure no longer maps to the domain.</p>
<p><strong>Insight.</strong> A rich domain model makes the domain legible to the team. The concepts are explicit. The rules are expressed in the language of the domain, not buried in service method conditionals and event handler logic. A new developer can read the model and understand the business. A non-technical stakeholder can, with modest translation, verify that the model reflects their understanding. That legibility is not incidental — it is the mechanism by which teams catch misunderstandings before they become bugs.</p>
<p><strong>Simplicity under growth.</strong> A procedural system grows by addition — new services, new methods, new conditions. A rich domain model grows by evolution — concepts become richer, responsibilities shift, new objects emerge when the design signals they are needed. Evolution is guided by the model. Addition is guided by expediency. Over five years, the difference in the resulting codebase is not marginal.</p>
<p><strong>Preserved optionality.</strong> A well-modelled domain in a single deployable can be split later, when measurement proves a specific boundary is warranted. The model already knows its own concepts — the split follows the domain's natural lines, guided by evidence. A distributed system cannot be reassembled cheaply once contracts have fossilised and team ownership has hardened around service lines. The simple starting point preserves optionality. The complex starting point spends it immediately, in exchange for flexibility that may never be needed.</p>
<hr />
<h2>First principles</h2>
<p>There is nothing novel in the argument this article makes.</p>
<p><em>Structure your thinking before you structure your infrastructure.</em> The question of where a rule lives is a question about the domain. Answer it in the domain — in the model, in the objects that own the concepts — before reaching for any infrastructure to enforce it. Infrastructure that enforces a boundary you have not yet thought through will enforce it permanently and expensively.</p>
<p><em>The location of a rule is part of the design.</em> A rule in the right place is findable, testable, and changeable. A rule in the place that was convenient to add it becomes a historical artefact, discoverable only by reading the history of the system.</p>
<p><em>Complexity introduced to compensate for missing structure is the most expensive kind.</em> It does not reduce over time. It compounds. Every saga that exists because a transaction boundary was removed, every contract that fossilises a year-one boundary decision, every service that owns zero domain concepts but exists to coordinate between services that do — these are permanent operational costs, paid every day, for the lifetime of the system.</p>
<p><em>What the industry calls the cost of enterprise software is largely the cost of not modelling.</em> The infrastructure, the teams, the operational overhead — these are not the price of scale or complexity. They are the price of workarounds for a missing domain model, normalised by the fact that everyone around you is paying the same price and the alternative was never built to compare against.</p>
<p>The rich domain model is not a technique for senior engineers on greenfield systems. It is the thing that makes enterprise software manageable at all — the only mechanism that preserves oversight, insight, and simplicity as a system grows. The alternative is the same complexity, without the structure to contain it, with an expensive distributed scaffolding erected around it to simulate the containment the model would have provided for free.</p>
<p>Build the model. Let the model tell you where the rules live, when the design needs to evolve, and when — if measurement ever demands it — a boundary has genuinely earned the right to become a service.</p>
<p>The model will not mislead you. The path of least resistance will.</p>
]]></content:encoded></item></channel></rss>