<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Leon Pennings</title>
    <description>The latest articles on DEV Community by Leon Pennings (@leonpennings).</description>
    <link>https://dev.to/leonpennings</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3596884%2Feba64cf4-e1c3-4a53-8a5f-6a340619080e.JPG</url>
      <title>DEV Community: Leon Pennings</title>
      <link>https://dev.to/leonpennings</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/leonpennings"/>
    <language>en</language>
    <item>
      <title>The Technical Candy Problem in Software Development</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Sat, 29 Aug 2026 12:23:03 +0000</pubDate>
      <link>https://dev.to/leonpennings/the-technical-candy-problem-in-software-development-6j4</link>
      <guid>https://dev.to/leonpennings/the-technical-candy-problem-in-software-development-6j4</guid>
      <description>&lt;h2&gt;
  
  
  Goodhart's Law, software architecture, and the questions it lets teams stop asking
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"When a measure becomes a target, it ceases to be a good measure."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This article is about why that keeps happening, decade after decade, under a different name each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two teams, same language, different sociology
&lt;/h2&gt;

&lt;p&gt;Take two Java teams of equal size, given the same ticket volume.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Team B treats each ticket as a question first: &lt;em&gt;what does this belong to?&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Both teams are nominally writing object-oriented Java. Only one of them is doing object-oriented &lt;em&gt;design&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;concept&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candy problem
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Team A's approach isn't the result of laziness or incompetence. It's the &lt;em&gt;default&lt;/em&gt; outcome whenever the tools make it easy to avoid asking "what owns this."&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fat services&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Technical layering&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Workflow and eventing engines&lt;/strong&gt; 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 &lt;em&gt;harder&lt;/em&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Microservices&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Boilerplate reduction&lt;/strong&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the proxy always wins
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Real goal&lt;/th&gt;
&lt;th&gt;Proxy that replaces it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Well-tested&lt;/td&gt;
&lt;td&gt;Test coverage percentage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Well-designed&lt;/td&gt;
&lt;td&gt;Proper layering (services, repositories, DTOs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scalable&lt;/td&gt;
&lt;td&gt;Number of services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintainable&lt;/td&gt;
&lt;td&gt;Adherence to a named technical pattern (layering conventions, workflow orchestration)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reduced complexity&lt;/td&gt;
&lt;td&gt;Reduced lines of code (boilerplate elimination)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two questions that resist the proxy
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The first is &lt;em&gt;why do you need it&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;Sometimes the need is real, though, and that's where the second question does the work the first one can't: &lt;em&gt;where in the model does this belong?&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Together the two questions describe a different order of operations than the one most tickets follow by default. The usual path runs &lt;em&gt;ticket → technical mechanism → implementation&lt;/em&gt;: pick a plausible tool, then build. The alternative runs &lt;em&gt;need → domain behavior → ownership → mechanism&lt;/em&gt;: 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.&lt;/p&gt;

&lt;p&gt;Neither question scales by making everyone a domain expert. Both scale because they're cheap and because of &lt;em&gt;when&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>architecture</category>
      <category>java</category>
      <category>richdomainmodels</category>
    </item>
    <item>
      <title>Tooling Is the Icing on the Cake, Not the Cake Itself</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Thu, 20 Aug 2026 13:15:33 +0000</pubDate>
      <link>https://dev.to/leonpennings/tooling-is-the-icing-on-the-cake-not-the-cake-itself-43b</link>
      <guid>https://dev.to/leonpennings/tooling-is-the-icing-on-the-cake-not-the-cake-itself-43b</guid>
      <description>&lt;p&gt;There's a recurring message on LinkedIn:&lt;/p&gt;

&lt;p&gt;Java 8 is legacy.&lt;/p&gt;

&lt;p&gt;Java 21 is enterprise-ready.&lt;/p&gt;

&lt;p&gt;Upgrade, or fall behind.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Kinds of Simple
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The other kind of simple means: the question was never asked. This is the kind a framework usually sells. Spring's &lt;code&gt;@Transactional&lt;/code&gt; 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 &lt;code&gt;begin&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;rollback&lt;/code&gt; by hand. That looks like the same kind of win as the first case — less to write, less to think about. It isn't.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;Tooling is the icing. The model is the cake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Now run the same subtraction on a system that was held together by its tooling — where &lt;code&gt;@Transactional&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Icing, Honestly Sold
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;var&lt;/code&gt; 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. &lt;code&gt;var&lt;/code&gt; 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 &lt;code&gt;Object x = ...&lt;/code&gt; as a style — an unstated type was recognized as a readability cost then. &lt;code&gt;var&lt;/code&gt; reintroduces the same gap with better manners.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Icing Mistaken for Cake
&lt;/h2&gt;

&lt;p&gt;This is where the actual damage lives, and &lt;code&gt;@Transactional&lt;/code&gt; is only the entry point.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;@Transactional&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Actually Being Optimized
&lt;/h2&gt;

&lt;p&gt;Sort everything in this piece — &lt;code&gt;@Transactional&lt;/code&gt;, sagas, virtual threads, sealed types, &lt;code&gt;var&lt;/code&gt; — into two bins, and the pattern underneath all of it stops being about Java at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One bin is essential complexity&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other bin is accidental complexity&lt;/strong&gt;: 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, &lt;code&gt;@Transactional&lt;/code&gt; executing a boundary that was already deliberately scoped.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;@Transactional&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;@Transactional&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Question
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How Do You Tell Your Software Application Is of High Quality? Part 2</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Thu, 13 Aug 2026 08:57:52 +0000</pubDate>
      <link>https://dev.to/leonpennings/how-do-you-tell-your-software-application-is-of-high-quality-part-2-k0e</link>
      <guid>https://dev.to/leonpennings/how-do-you-tell-your-software-application-is-of-high-quality-part-2-k0e</guid>
      <description>&lt;h3&gt;
  
  
  Part 2 — Essential and Accidental Complexity
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://blog.leonpennings.com/how-do-you-tell-your-software-application-is-of-high-quality" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;'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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ferrari That Plows the Field
&lt;/h2&gt;

&lt;p&gt;Picture a user story: &lt;em&gt;"As a farm owner, I need my field plowed."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the risk sitting underneath every request phrased as behavior: &lt;em&gt;plow the field&lt;/em&gt;, &lt;em&gt;approve the loan&lt;/em&gt;, &lt;em&gt;route the shipment&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Requirements Can't Be Perfect — By Definition
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;during&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Farmer Never Needed a Model — and the Engineer Does
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;does&lt;/em&gt; the domain — years of accumulated pattern-matching that never had to be made explicit because it was never being transferred to anyone.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This reframes what a domain model is &lt;em&gt;for&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Model Talks Back
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Show a farmer a diagram of &lt;em&gt;Field&lt;/em&gt;, &lt;em&gt;Terrain&lt;/em&gt;, &lt;em&gt;PlowDepth&lt;/em&gt;, and &lt;em&gt;SoilCondition&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sundial and the Clock
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;how time works&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://blog.leonpennings.com/rich-domain-modelling-a-library-story" rel="noopener noreferrer"&gt;library article&lt;/a&gt;'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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming It: Essential and Accidental Complexity
&lt;/h2&gt;

&lt;p&gt;Fred Brooks named this distinction in 1986, in &lt;em&gt;No Silver Bullet&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Compounds With Scale
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It stops being free for two separate reasons once a system grows.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Premature Boundaries Are Waterfall, Applied to Structure
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;boundaries&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves Us
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How Do You Tell Your Software Application Is of High Quality?</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Mon, 10 Aug 2026 09:50:19 +0000</pubDate>
      <link>https://dev.to/leonpennings/how-do-you-tell-your-software-application-is-of-high-quality-3dnb</link>
      <guid>https://dev.to/leonpennings/how-do-you-tell-your-software-application-is-of-high-quality-3dnb</guid>
      <description>&lt;h2&gt;
  
  
  Part 1 — Problem Definition
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "No Comparison" Actually Means
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;are&lt;/em&gt; 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 &lt;em&gt;is&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Only Metric Available: Does It Work
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boilerplate Reduction Is Not Quality Either
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Working Ships Beat Well-Structured" Dogma
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;faster&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom: Tooling Validates Itself — Twice
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom: Dunning-Kruger, Structurally Guaranteed
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom: Cost That Accumulates Without a Cause
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves Us
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>architecture</category>
      <category>java</category>
    </item>
    <item>
      <title>Discovery and Fit Testing: The Two Jobs of a Domain Model</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 21 Jul 2026 07:51:42 +0000</pubDate>
      <link>https://dev.to/leonpennings/discovery-and-fit-testing-the-two-jobs-of-a-domain-model-17pj</link>
      <guid>https://dev.to/leonpennings/discovery-and-fit-testing-the-two-jobs-of-a-domain-model-17pj</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Call the first one &lt;strong&gt;discovery&lt;/strong&gt;: finding out what the domain actually is by refusing to accept a stated want as the real need. Call the second one &lt;strong&gt;fit testing&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery: Finding Out What the Domain Actually Is
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fit Testing: The Code Is the Reality Check
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This shows up anywhere code starts accumulating branches instead of concepts, not just in the classic entity-relationship examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isVIP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wasVIP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isTemporaryVIP&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why These Are Two Skills, Not One
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Discovery Went
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;the&lt;/em&gt; design phase — the moment the important architectural thinking supposedly takes place.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Changes, and What It Doesn't
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  So What
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>cleancode</category>
      <category>java</category>
    </item>
    <item>
      <title>Why Expensive Software Development Never Looks Expensive</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Thu, 16 Jul 2026 06:47:05 +0000</pubDate>
      <link>https://dev.to/leonpennings/why-expensive-software-development-never-looks-expensive-53e9</link>
      <guid>https://dev.to/leonpennings/why-expensive-software-development-never-looks-expensive-53e9</guid>
      <description>&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Villain: Process Over Product
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Produces the Same Tools, Regardless of Fit
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger the System, the Simpler the Code Needs to Be
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Controls the Lifetime Cost of a System
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Test
&lt;/h2&gt;

&lt;p&gt;None of the above is abstract. It can be checked, directly, against a system you already have, in a few minutes. Ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If your current framework disappeared tomorrow, how much of your actual business logic would survive untouched?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>technicaldebt</category>
      <category>java</category>
    </item>
    <item>
      <title>Why Software Can't Tell You It's Wrong</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:52:26 +0000</pubDate>
      <link>https://dev.to/leonpennings/why-software-cant-tell-you-its-wrong-3l8i</link>
      <guid>https://dev.to/leonpennings/why-software-cant-tell-you-its-wrong-3l8i</guid>
      <description>&lt;p&gt;Software architecture debates have a problem that most other engineering disciplines don't: the alternative was never built.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Metric That Hides the Problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mechanics, Not the Behavior
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Essential and Accidental Complexity
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cogwheels of the Business
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;WithdrawalService&lt;/code&gt; and a &lt;code&gt;BalanceCheckService&lt;/code&gt; and a &lt;code&gt;TransactionValidationService&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Correctability as the Partial Answer
&lt;/h2&gt;

&lt;p&gt;This is where the unfalsifiability problem becomes something other than a purely philosophical concern.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  So What
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Software Bug AI Can't Find</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Fri, 26 Jun 2026 20:41:32 +0000</pubDate>
      <link>https://dev.to/leonpennings/the-software-bug-ai-cant-find-cc1</link>
      <guid>https://dev.to/leonpennings/the-software-bug-ai-cant-find-cc1</guid>
      <description>&lt;p&gt;Coding is not the hard part.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Everything that follows is a consequence of that distinction.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Transaction Is Not a Technical Detail
&lt;/h2&gt;

&lt;p&gt;A single atomic transaction is the simplest possible implementation of loud failure.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens When You Distribute
&lt;/h2&gt;

&lt;p&gt;Now take the same unexpected scenario and run it through a distributed system.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The user may not even see an error. The system appears to have worked.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Part the "It Works" Argument Misses
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It is not theoretical. It is probabilistic — and the probability scales directly with the thing you most want to scale.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI Accelerates the Accumulation
&lt;/h2&gt;

&lt;p&gt;This is where the current moment makes the stakes undeniable.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Not: we built at a speed that outran our understanding, into an architecture that was designed to hide what we didn't know.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Industry Got Here
&lt;/h2&gt;

&lt;p&gt;Nobody chose this deliberately. That is worth saying plainly before any diagnosis.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;They are not solutions to the underlying problem. They are ways of making the underlying problem less observable. Which, at sufficient scale, is worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Engineering for Properties, Not Popularity
&lt;/h2&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Which it will. It always does.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to Do With This
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwaredevelopment</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>SOLID Principles: Forks to Eat Soup</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 23 Jun 2026 07:46:54 +0000</pubDate>
      <link>https://dev.to/leonpennings/solid-principles-forks-to-eat-soup-1mgm</link>
      <guid>https://dev.to/leonpennings/solid-principles-forks-to-eat-soup-1mgm</guid>
      <description>&lt;h2&gt;
  
  
  The Problem With Architecture Debates
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Forks to Eat Soup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;To understand why, it helps to look at what each principle actually means — and what it tends to mean in practice instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Single Responsibility Principle
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;A class should have only one reason to change.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The intent is to prevent fat objects — objects that accumulate unrelated responsibilities until they become incomprehensible. A &lt;code&gt;Customer&lt;/code&gt; object should be responsible for what a customer &lt;em&gt;is and does&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Technology Layering as Responsibility Separation
&lt;/h3&gt;

&lt;p&gt;A common pattern treats technology boundaries as responsibility boundaries. A &lt;code&gt;Customer&lt;/code&gt; domain object gets split into &lt;code&gt;CustomerDTO&lt;/code&gt;, &lt;code&gt;CustomerRepository&lt;/code&gt;, &lt;code&gt;CustomerService&lt;/code&gt;, and &lt;code&gt;CustomerController&lt;/code&gt; — 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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;email.send()&lt;/code&gt; — not a static utility method, but a method on an existing email instance, because sending is part of what an email &lt;em&gt;is&lt;/em&gt;. The SMTP detail is essential complexity: it exists because reality requires it. The &lt;code&gt;EmailService&lt;/code&gt;, &lt;code&gt;IEmailSender&lt;/code&gt;, &lt;code&gt;SmtpEmailSender&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: Dual Responsibility Objects in DDD Contexts
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;One reading of this is to create multiple &lt;code&gt;Customer&lt;/code&gt; variants — a &lt;code&gt;ShippingCustomer&lt;/code&gt;, a &lt;code&gt;BillingCustomer&lt;/code&gt; — 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 &lt;code&gt;Customer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A more faithful reading is composition rather than decomposition. &lt;code&gt;Customer&lt;/code&gt; retains its single responsibility: being a customer. Shipping-specific behavior belongs in a separate object that &lt;em&gt;has&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Order order = new Order(customer, shoppingCart);
Payment payment = new Payment(invoice);
Email email = new Email(subject, content);
email.send();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each object owns exactly what its concept requires. &lt;code&gt;Customer&lt;/code&gt; does not know about &lt;code&gt;Order&lt;/code&gt;. &lt;code&gt;Order&lt;/code&gt; knows about &lt;code&gt;Customer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;What bounded contexts produce, when modelled this way, is not multiple versions of the same object — it is context-specific objects that &lt;em&gt;use&lt;/em&gt; domain objects without absorbing them. The core object stays coherent because it is never asked to be something it isn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Open/Closed Principle
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Software entities should be open for extension but closed for modification.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The classic illustration of where this gets complicated is the circle and ellipse problem. Should &lt;code&gt;Circle&lt;/code&gt; extend &lt;code&gt;Ellipse&lt;/code&gt;, or &lt;code&gt;Ellipse&lt;/code&gt; extend &lt;code&gt;Circle&lt;/code&gt;? 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Liskov Substitution Principle
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Objects of a subclass should be substitutable for objects of the superclass without altering the correctness of the program.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The practical guide to avoiding these violations is simpler than it might appear. Inheritance is the right relationship when something genuinely &lt;em&gt;is a&lt;/em&gt; 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 &lt;em&gt;has a&lt;/em&gt; reference to something else and adds its own behavior alongside it. The domain tells you which one applies.&lt;/p&gt;

&lt;p&gt;A useful test is to say the relationship out loud. "A ShippingCustomer &lt;em&gt;is a&lt;/em&gt; Customer" — but is it? A ShippingCustomer is not a more specific kind of Customer. It is shipping-related information that belongs &lt;em&gt;to&lt;/em&gt; a Customer. Saying it out loud already suggests the right structure: it &lt;em&gt;has a&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Interface Segregation Principle
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Clients should not be forced to depend on interfaces they do not use.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The Java &lt;code&gt;Servlet&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dependency Inversion Principle
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What The Principles Actually Share
&lt;/h2&gt;

&lt;p&gt;Looking across all five, the same underlying concern appears in each one: conceptual integrity at the model level.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SRP&lt;/strong&gt;: one coherent concept per object&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OCP&lt;/strong&gt;: inheritance respects the concept's contract&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LSP&lt;/strong&gt;: substitutability follows from genuine subtype relationships&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ISP&lt;/strong&gt;: interfaces reflect actual behavioral contracts, not convenience bundles&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DIP&lt;/strong&gt;: dependencies follow conceptual relationships, not technical ones&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Practical Case for Getting This Right
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The other thing that happens with practice is harder to quantify but worth naming. Thinking in responsibilities — asking what a concept &lt;em&gt;is&lt;/em&gt;, what it &lt;em&gt;does&lt;/em&gt;, what it should &lt;em&gt;know&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>solidprinciples</category>
      <category>java</category>
      <category>oop</category>
    </item>
    <item>
      <title>What is the reason for using a rich domain model in the age of AI?</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Fri, 19 Jun 2026 11:39:56 +0000</pubDate>
      <link>https://dev.to/leonpennings/what-is-the-reason-for-using-a-rich-domain-model-in-the-age-of-ai-3gg</link>
      <guid>https://dev.to/leonpennings/what-is-the-reason-for-using-a-rich-domain-model-in-the-age-of-ai-3gg</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tool, and what it takes for the tool to work
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A model is something you learn through, not something you draw once
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The path of least resistance puts the loan dates directly on &lt;code&gt;Book&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt;? 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Book&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;So a &lt;code&gt;Loan&lt;/code&gt; entity gets introduced. It points to a book and a borrower, and carries its own data: start date, end date, return date. &lt;code&gt;Book&lt;/code&gt; goes back to being just a book. Each concept is responsible for what it actually is.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Book&lt;/code&gt; class and close the ticket.&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;Loan&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second correction
&lt;/h3&gt;

&lt;p&gt;A new requirement arrives: the library wants to lend DVDs too.&lt;/p&gt;

&lt;p&gt;The path of least resistance here is just as easy to predict: add a &lt;code&gt;DVD&lt;/code&gt; 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 &lt;code&gt;DVD&lt;/code&gt; class, instead of as new information about a domain that had just revealed something about itself.&lt;/p&gt;

&lt;p&gt;The actual question isn't "how do we add DVD." It's: was &lt;code&gt;Book&lt;/code&gt; 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 &lt;code&gt;Book&lt;/code&gt; and &lt;code&gt;DVD&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The concept the domain actually needed, it turns out, was never &lt;code&gt;Book&lt;/code&gt;. It was &lt;code&gt;LendableItem&lt;/code&gt; — something that can be lent, regardless of what it physically is. &lt;code&gt;Book&lt;/code&gt; becomes &lt;code&gt;LendableItem&lt;/code&gt;; what kind of item it is becomes data (&lt;code&gt;ItemType&lt;/code&gt;), 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 &lt;code&gt;ItemType&lt;/code&gt;. A new lendable thing can be defined through configuration, without a release.&lt;/p&gt;

&lt;p&gt;This isn't abstraction for its own sake — starting with &lt;code&gt;Book&lt;/code&gt; 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 &lt;em&gt;evidence&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;Here is the part worth sitting with: &lt;strong&gt;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.&lt;/strong&gt; Turning &lt;code&gt;Book&lt;/code&gt; into &lt;code&gt;LendableItem&lt;/code&gt; produces a wave of compile errors at every call site that assumed a &lt;code&gt;Book&lt;/code&gt; — 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Book&lt;/code&gt;. Some duplicate the "is this thing currently out" check inline. Some call into a shared &lt;code&gt;BookService&lt;/code&gt; that does it correctly, and some call into an older one that doesn't quite. When the DVD requirement lands, &lt;em&gt;finding&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fat is not a size problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Take &lt;code&gt;Customer&lt;/code&gt;. Almost every enterprise system has one, and almost every one of them eventually starts absorbing things that don't belong to it: a &lt;code&gt;preferredCarrier&lt;/code&gt; field set because shipping needed it, a &lt;code&gt;creditLimit&lt;/code&gt; because billing needed it, an &lt;code&gt;slaTier&lt;/code&gt; because support needed it. Years of this, and &lt;code&gt;Customer&lt;/code&gt; is enormous — hundreds of fields, half of them nullable, conditional logic scattered through anything that touches it, and nobody able to describe what &lt;code&gt;Customer&lt;/code&gt; actually means anymore, because it means five different things depending on who's asking.&lt;/p&gt;

&lt;p&gt;This is a real failure, and the diagnosis matters, because two very different responses are available, and only one of them fixes anything.&lt;/p&gt;

&lt;p&gt;The popular response is to split. Give shipping its own &lt;code&gt;ShippingCustomer&lt;/code&gt;, billing its own &lt;code&gt;BillingCustomer&lt;/code&gt;, support its own &lt;code&gt;SupportCustomer&lt;/code&gt; — 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.&lt;/p&gt;

&lt;p&gt;Look closer and notice what actually happened: &lt;code&gt;ShippingCustomer&lt;/code&gt; is not a different concept from the bloated &lt;code&gt;Customer&lt;/code&gt;. It's the same god object, just with the bloat partitioned by department instead of concentrated in one file. The information that crept into &lt;code&gt;Customer&lt;/code&gt; 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: &lt;code&gt;Billing&lt;/code&gt;'s context has to publish something, &lt;code&gt;Shipping&lt;/code&gt;'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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the fix actually looks like
&lt;/h3&gt;

&lt;p&gt;The right response to a fat &lt;code&gt;Customer&lt;/code&gt; is the same response that turned &lt;code&gt;Book&lt;/code&gt; into &lt;code&gt;Loan&lt;/code&gt; and &lt;code&gt;LendableItem&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Customer&lt;/code&gt; a &lt;code&gt;List&amp;lt;ShippingPreference&amp;gt;&lt;/code&gt; directly — replace the flat &lt;code&gt;preferredCarrier&lt;/code&gt; 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: &lt;code&gt;Customer&lt;/code&gt; has no business knowing that shipping preferences exist as a concept at all. A &lt;code&gt;ShippingPreference&lt;/code&gt; living directly on &lt;code&gt;Customer&lt;/code&gt; is &lt;code&gt;Customer&lt;/code&gt; quietly absorbing knowledge of how it's consumed downstream — the exact failure that produced &lt;code&gt;ShippingCustomer&lt;/code&gt; in the first place, just wearing an interface instead of a flag.&lt;/p&gt;

&lt;p&gt;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: &lt;code&gt;CustomerShipping&lt;/code&gt;. It holds a reference to the &lt;code&gt;Customer&lt;/code&gt; it concerns, and a list of &lt;code&gt;CustomerShippingPreference&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;CustomerShippingPreference&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;precedence&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DefaultShipping&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerShippingPreference&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;precedence&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CarrierChoice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GoldTierShipping&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerShippingPreference&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;precedence&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CarrierChoice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DHL"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofDays&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExplicitDateOverride&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;CustomerShippingPreference&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;precedence&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CarrierChoice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedCarrier&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestedDate&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomerShipping&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CustomerShippingPreference&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="nf"&gt;shippingMethodFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Comparator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;comparingInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;CustomerShippingPreference:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;precedence&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Customer&lt;/code&gt; itself never branches on tier, never checks for an override, never holds a single field related to shipping — it doesn't even know &lt;code&gt;CustomerShipping&lt;/code&gt; exists. &lt;code&gt;Shipment&lt;/code&gt;, when it needs a carrier, doesn't ask &lt;code&gt;Customer&lt;/code&gt; anything directly. It takes an &lt;code&gt;Order&lt;/code&gt;, reads the &lt;code&gt;Customer&lt;/code&gt; off it, looks up or builds the &lt;code&gt;CustomerShipping&lt;/code&gt; for that customer, and asks &lt;em&gt;that&lt;/em&gt; object for the shipping method given the order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Shipment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Shipment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CustomerShippingRepository&lt;/span&gt; &lt;span class="n"&gt;shippingLookup&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;CustomerShipping&lt;/span&gt; &lt;span class="n"&gt;shipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shippingLookup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;forCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;CarrierChoice&lt;/span&gt; &lt;span class="n"&gt;carrier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shipping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shippingMethodFor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new rule — a holiday rush exception, a regional carrier restriction, a future platinum tier — is a new class implementing &lt;code&gt;CustomerShippingPreference&lt;/code&gt;, added to &lt;code&gt;CustomerShipping&lt;/code&gt;'s list, never touching &lt;code&gt;Customer&lt;/code&gt; at all. The arbitration logic — &lt;em&gt;given several applicable rules, which one wins&lt;/em&gt; — has exactly one home, and &lt;code&gt;Customer&lt;/code&gt; stays exactly as ignorant of shipping as &lt;code&gt;Book&lt;/code&gt; stayed ignorant of loans.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Customer&lt;/code&gt; or into a separate &lt;code&gt;ShippingCustomer&lt;/code&gt; clone — when what it actually needed was its own name, sitting between the two, owning exactly the relationship it represents and nothing else."&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;between&lt;/em&gt; them — which is precisely the responsibility &lt;code&gt;CustomerShipping&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;There's a second, less obvious benefit to &lt;code&gt;CustomerShipping&lt;/code&gt; 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 &lt;code&gt;Customer&lt;/code&gt; after the fact, without modifying &lt;code&gt;Customer&lt;/code&gt;, without &lt;code&gt;Customer&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary as a bet you can't yet price
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Customer&lt;/code&gt;, 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. &lt;code&gt;CustomerShipping&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;you cannot know today every cross-cutting rule the business will need tomorrow.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Customer&lt;/code&gt; needs to be deleted, fully and verifiably. In a single database, behind a single transaction, this is mostly handled by the database itself: if &lt;code&gt;CustomerShipping&lt;/code&gt; references &lt;code&gt;Customer&lt;/code&gt; 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 &lt;em&gt;prevent&lt;/em&gt; 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 &lt;code&gt;ConstraintViolationException&lt;/code&gt; 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 &lt;code&gt;CustomerShipping&lt;/code&gt;'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 &lt;code&gt;CustomerShipping&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;But making GDPR the centerpiece would be a mistake, because it hands every team without a regulator standing over them a clean exit: &lt;em&gt;we're not compliance-heavy, so this doesn't apply to us.&lt;/em&gt; 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 &lt;code&gt;CustomerShipping&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary that doesn't justify itself
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Book&lt;/code&gt; made before &lt;code&gt;Loan&lt;/code&gt; 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 &lt;code&gt;Order&lt;/code&gt;, &lt;code&gt;Invoice&lt;/code&gt;, and &lt;code&gt;Shipment&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This is worth stating plainly, because it's easy to mistake for a concession it isn't: the deeper the semantic divergence, the &lt;em&gt;more&lt;/em&gt; 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 &lt;code&gt;ReinsuranceContract&lt;/code&gt; and &lt;code&gt;ClaimCase&lt;/code&gt; 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 &lt;em&gt;for&lt;/em&gt; doing the modeling work, not the exception that excuses skipping it.&lt;/p&gt;

&lt;p&gt;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: &lt;strong&gt;does the domain model have to change shape to accommodate the split?&lt;/strong&gt; 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 &lt;em&gt;does&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the wrong thing very precisely
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Customer&lt;/code&gt; should hold a &lt;code&gt;preferredCarrier&lt;/code&gt; field directly or delegate that entirely to a &lt;code&gt;CustomerShipping&lt;/code&gt; object that doesn't exist on &lt;code&gt;Customer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;feels&lt;/em&gt; like one thing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gets more urgent, not less, with AI
&lt;/h2&gt;

&lt;p&gt;Here is the part that didn't apply five years ago in quite the same way.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;DVD&lt;/code&gt; as a sibling of &lt;code&gt;Book&lt;/code&gt;, then adds &lt;code&gt;Vinyl&lt;/code&gt; six months later, then writes an increasingly complex query to aggregate loan counts across three separate entity types — that developer &lt;em&gt;feels&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Essential complexity, made tangible and testable
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>softwaredevelopment</category>
      <category>architecture</category>
    </item>
    <item>
      <title>AntiPatterns Never Left, We Just Stopped Calling Them by Name</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Mon, 15 Jun 2026 12:10:54 +0000</pubDate>
      <link>https://dev.to/leonpennings/antipatterns-never-left-we-just-stopped-calling-them-by-name-969</link>
      <guid>https://dev.to/leonpennings/antipatterns-never-left-we-just-stopped-calling-them-by-name-969</guid>
      <description>&lt;p&gt;In 1998, a book called &lt;em&gt;AntiPatterns&lt;/em&gt; did something unusual: instead of cataloguing good solutions to recurring problems, it catalogued &lt;em&gt;bad&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;failure mode&lt;/em&gt;, or of something that actively blocks or resists effective development, even when (especially when) it doesn't look like a mistake at the time.&lt;/p&gt;

&lt;p&gt;What makes an AntiPattern dangerous isn't that it's obviously wrong. It's that the failure mode it describes tends to be &lt;strong&gt;invisible while it's happening&lt;/strong&gt;. This is the &lt;strong&gt;unfalsifiability problem&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;altitude&lt;/em&gt; at which the unfalsifiability problem operates — from the codebase, to the organization, to the industry at large.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 1: Technical Axis vs. Domain Axis
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Software has at least two legitimate ways to be sliced. One is by &lt;em&gt;what the business cares about&lt;/em&gt; — a Risk Summary, a Customer Account, an Order. The other is by &lt;em&gt;technical concern&lt;/em&gt; — 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 &lt;em&gt;domain&lt;/em&gt; concept coherent anymore — everybody's job is to keep their &lt;em&gt;layer&lt;/em&gt; coherent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jumble → "Accidental Layering"
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The modern, much more common version of this is subtler and looks like &lt;em&gt;good practice&lt;/em&gt;: "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 &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;You never reuse a query — you reuse what it represents.&lt;/strong&gt; A query is just an implementation detail; "risk total for this category" is the &lt;em&gt;concept&lt;/em&gt; that needs to stay consistent. Reusing the query method gives you textual reuse of some SQL. Reusing the Risk Summary object — calling its &lt;code&gt;riskTotal()&lt;/code&gt; — gives you reuse of the &lt;em&gt;context&lt;/em&gt;: the rules about what counts, what's excluded, how categories nest, all of it living in one place that knows what "risk total" means.&lt;/p&gt;

&lt;p&gt;The failure mode this produces is depressingly specific and common: need A comes along, and the existing query in the repository is &lt;em&gt;almost&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The opposite also happens, and it's arguably worse. Need A is &lt;em&gt;slightly&lt;/em&gt; 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 &lt;em&gt;some&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;own&lt;/em&gt; 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 &lt;em&gt;at the time it was written&lt;/em&gt;. 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, &lt;em&gt;or&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;accidental layering&lt;/strong&gt;: 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 &lt;em&gt;works&lt;/em&gt;. It compiles, it returns data, the tests pass. The cost only shows up later, when "risk total" quietly stops meaning one thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Functional Decomposition + Poltergeists → The Anemic Domain Model
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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: &lt;code&gt;Service&lt;/code&gt; classes full of methods that orchestrate behavior, operating on &lt;code&gt;Entity&lt;/code&gt; objects and DTOs that are really just data bags with getters and setters. The class structure is object-oriented. The &lt;em&gt;behavior&lt;/em&gt; 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 &lt;em&gt;to&lt;/em&gt; the objects.&lt;/p&gt;

&lt;p&gt;Layered on top of this, the Poltergeists are everywhere: mapper classes that convert entities to DTOs and back, one-shot orchestrator classes, &lt;code&gt;*Factory&lt;/code&gt; and &lt;code&gt;*Builder&lt;/code&gt; and &lt;code&gt;*Handler&lt;/code&gt; classes whose entire lifecycle is "get instantiated, shuttle control from the controller to the service to the repository, disappear." None of these classes &lt;em&gt;know&lt;/em&gt; anything. They just move data and call the next thing.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;object&lt;/em&gt; 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 &lt;code&gt;riskTotal()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lava Flow → Dead Artifacts in Event-Driven Architecture
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;handled&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;subscription&lt;/em&gt;, a &lt;em&gt;topic&lt;/em&gt;, a &lt;em&gt;handler registration&lt;/em&gt;, 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 &lt;em&gt;connections&lt;/em&gt; — 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 &lt;em&gt;inert&lt;/em&gt; — it sits there, unused, until someone deletes it. In EDA, dead code can be &lt;em&gt;active&lt;/em&gt;: a ghost process, still executing, still costing money, with no stack trace and no error to tell you it's a ghost.&lt;/p&gt;

&lt;p&gt;But there's a cost that shows up even while everything is alive and healthy, which is arguably the more important one: the &lt;em&gt;dependency itself&lt;/em&gt; 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 &lt;em&gt;anywhere in the code&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;are&lt;/em&gt;: 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 &lt;em&gt;together&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;coordination&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vendor Lock-In → Framework Lock-In Without a Vendor
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The interesting modern twist is that lock-in no longer requires a &lt;em&gt;vendor&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;someone&lt;/em&gt; to keep the substrate underneath you current — which is the textbook definition of dependency, just relabeled.&lt;/p&gt;

&lt;p&gt;On top of that: a sufficiently "Spring-native" codebase — laced with &lt;code&gt;@Autowired&lt;/code&gt;, &lt;code&gt;@Transactional&lt;/code&gt;, &lt;code&gt;@Service&lt;/code&gt;, component scanning, and the conventions that make all of that work — is &lt;em&gt;enormously&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;This matters because none of it &lt;em&gt;feels&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 2: Adoption Without Evaluation
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;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."&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Continuous Obsolescence → Dialect Drift Inside "the Same Language"
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;learning that codebase's Scala&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;var&lt;/code&gt;, records, sealed types, pattern matching, and the steady cultural push toward "boilerplate reduction" — has been adding exactly the kind of expressive, compact, &lt;em&gt;idiomatic&lt;/em&gt; 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 &lt;em&gt;styles&lt;/em&gt; of Java a developer now needs to be fluent in before "knowing Java" actually means being productive.&lt;/p&gt;

&lt;p&gt;This is Continuous Obsolescence at the level of &lt;em&gt;readability&lt;/em&gt; rather than &lt;em&gt;dependency versions&lt;/em&gt; — 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."&lt;/p&gt;

&lt;h3&gt;
  
  
  Golden Hammer
&lt;/h3&gt;

&lt;p&gt;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 &lt;em&gt;alternatives&lt;/em&gt; to reach for — is, charmingly, still the prescribed fix in 2026, and still mostly doesn't happen.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;platform&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;language's&lt;/em&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture by Implication → Survivorship Bias as Architecture
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The deeper version of this: every system is, in practice, only ever built &lt;em&gt;once&lt;/em&gt;. The cheaper, simpler alternative was never actually built, so there's no comparison to make. If the system that &lt;em&gt;was&lt;/em&gt; 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 &lt;em&gt;that&lt;/em&gt; version would have shipped faster, cost less, and been easier to change.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;worked&lt;/em&gt; 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 &lt;em&gt;pattern&lt;/em&gt;, when it's really just validation that the constraints were tolerable — which tells you nothing about whether the pattern was &lt;em&gt;necessary&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Intellectual Violence → Complexity as Social Leverage
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The modern version doesn't even require an intimidator. CQRS, Dependency Injection, Event-Driven Architecture — these are genuinely complex enough that &lt;em&gt;disagreeing&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;This connects directly to the rest of the chapter. Continuous Obsolescence and Golden Hammer explain &lt;em&gt;what&lt;/em&gt; gets adopted — features and tools chosen for familiarity or availability rather than fit. Architecture by Implication explains why nobody's &lt;em&gt;checking&lt;/em&gt; whether the adoption was the right call (no visible counterfactual). Intellectual Violence explains why, even when someone privately &lt;em&gt;suspects&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 3: The Cure Regrows the Disease
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Spaghetti Code → Distributed Spaghetti
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;can&lt;/em&gt; be spaghetti-free in a way a 100-object monolith struggles to be.&lt;/p&gt;

&lt;p&gt;But the failure mode doesn't require any single service to be tangled. It requires the &lt;em&gt;system&lt;/em&gt; 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, &lt;em&gt;because the compiler caught some of it&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;The decomposition didn't remove the failure mode. It changed its blast radius — and traded a problem you could &lt;em&gt;see&lt;/em&gt; (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).&lt;/p&gt;

&lt;h3&gt;
  
  
  Stovepipe Enterprise → Microservices and the Ossified Boundary
&lt;/h3&gt;

&lt;p&gt;The original Stovepipe Enterprise describes a &lt;em&gt;lack&lt;/em&gt; of coordination and planning across systems — each one solving its own slice in isolation, duplicating effort, creating integration headaches with everyone else.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;current&lt;/em&gt; 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 &lt;em&gt;code&lt;/em&gt; structure ossifies around a snapshot of domain understanding that the domain itself has already moved past.&lt;/p&gt;

&lt;p&gt;The original Stovepipe Enterprise is mostly about &lt;em&gt;waste and duplication from never coordinating&lt;/em&gt;. 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 &lt;em&gt;un&lt;/em&gt;-split it when that understanding changes, which is exactly when you'd need to.&lt;/p&gt;

&lt;p&gt;None of this means microservices are categorically wrong. It means microservices trade a maintenance cost you can &lt;em&gt;see&lt;/em&gt; — 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Throw It Over the Wall → Platform Teams
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the clearest example of a pattern that recurs across this entire article: &lt;strong&gt;the antidote regrows the disease in a different organ.&lt;/strong&gt; 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 &lt;em&gt;works&lt;/em&gt; — which is exactly why nobody notices the disease came back. It just moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Chapter 4: Who Feels the Pain Doesn't Decide
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Mushroom Management → Scrum's Intermediary, Reborn
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Modern Scrum was, in part, supposed to fix this — user stories as &lt;em&gt;conversations&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design by Committee + Grand Old Duke of York → Chickens, Pigs, and Flattened Roles
&lt;/h3&gt;

&lt;p&gt;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 &lt;em&gt;abstractions&lt;/em&gt; — there are, in practice, two different skill sets (call them abstractionists and implementationists), and ignoring that distinction hurts projects.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;flattens&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The 1998 framing at least &lt;em&gt;acknowledged&lt;/em&gt; 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 &lt;em&gt;working&lt;/em&gt; afterward doesn't tell you whether it was the &lt;em&gt;right&lt;/em&gt; decision, only that it wasn't an immediately &lt;em&gt;fatal&lt;/em&gt; one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Corncob
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What's worth naming explicitly is &lt;em&gt;why&lt;/em&gt; 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 &lt;em&gt;other&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing: Reinventing the Wheel, On Purpose
&lt;/h2&gt;

&lt;p&gt;There's one more entry from the original list worth ending on, because it inverts the usual direction of travel: &lt;strong&gt;Reinvent the Wheel&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;and&lt;/em&gt; a hundred things you don't, each of which is now a thing your codebase is entangled with.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;survive&lt;/em&gt;. 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."&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;shapes&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;Nobody's selling the antidote. But the list was always right there.&lt;/p&gt;

</description>
      <category>java</category>
      <category>designpatterns</category>
      <category>softwaredevelopment</category>
      <category>software</category>
    </item>
    <item>
      <title>How To Prevent Contradicting AI Prompts</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:00:21 +0000</pubDate>
      <link>https://dev.to/leonpennings/how-to-prevent-contradicting-ai-prompts-217a</link>
      <guid>https://dev.to/leonpennings/how-to-prevent-contradicting-ai-prompts-217a</guid>
      <description>&lt;h3&gt;
  
  
  You've Either Seen This Already, Or You Will
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Prompt Isn't The Problem
&lt;/h3&gt;

&lt;p&gt;Here is what the contradiction actually looks like.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;The problem is the absence of something the instructions could be consistent with.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Why Rebuilding Doesn't Work
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The center has to come first.&lt;/p&gt;




&lt;h3&gt;
  
  
  What Fred Brooks Knew
&lt;/h3&gt;

&lt;p&gt;The center has to come first. Fred Brooks identified why, sixty years ago, and the industry has spent most of that time ignoring him.&lt;/p&gt;

&lt;p&gt;Brooks distinguished between two kinds of complexity in software. &lt;strong&gt;Essential complexity&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accidental complexity&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  Rivers and Terrain
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Flows are rivers. And rivers follow terrain.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Terrain is what things &lt;em&gt;are&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The missing center is the terrain. The fix is to build the map before you build the rivers.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Domain Expert's River
&lt;/h3&gt;

&lt;p&gt;The natural response is: talk to the domain experts. Capture the requirements thoroughly. Understand the business before building. Let them define the terrain.&lt;/p&gt;

&lt;p&gt;This is right in intent and consistently wrong in execution — for a reason that matters enormously.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The constraint that created the workaround is gone. The workaround remains. Now it is load-bearing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Contextual Center
&lt;/h3&gt;

&lt;p&gt;When the terrain is mapped — when the domain is understood at the level of what things &lt;em&gt;are&lt;/em&gt; rather than what they &lt;em&gt;do&lt;/em&gt; — it becomes possible to build a contextual center.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complexity is the symptom. Simplicity is the proof.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The Scale Problem
&lt;/h3&gt;

&lt;p&gt;Here is where the industry is currently making its most expensive mistake.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The problem is that this success is being treated as proof that the approach scales.&lt;/p&gt;

&lt;p&gt;It does not. And the reason it does not is precisely the terrain problem.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why The Feedback Loop Cannot Be Outsourced
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is waterfall. And the industry already learned — expensively — why it does not work on complex domains.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Outsourcing Lesson
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The correction was to bring development back. Not for cultural reasons. Not for communication style. To keep the learning loop intact.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;"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.&lt;/p&gt;




&lt;h3&gt;
  
  
  Unfalsifiability, Again
&lt;/h3&gt;

&lt;p&gt;Why does this keep happening?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Unfalsifiability will do the rest.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Career Ceiling Nobody Discusses
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  How AI Should Actually Be Used
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What changes is how AI is used.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two Approaches, Two Invoices
&lt;/h3&gt;

&lt;p&gt;AI does not level the playing field between the terrain approach and the river approach. It widens the gap between them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Solution Is Thirty Years Old
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The center was always the answer. It still is.&lt;/p&gt;

&lt;p&gt;Build the map before you build the river. The rivers will be faster for it, and they will still be running in fifteen years.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is a follow-up to The Invisible Invoice: The Cost of Building Software Without Understanding It.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>ai</category>
      <category>architecture</category>
      <category>java</category>
    </item>
  </channel>
</rss>
