<?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>Forem: Leon Pennings</title>
    <description>The latest articles on Forem by Leon Pennings (@leonpennings).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3596884%2Feba64cf4-e1c3-4a53-8a5f-6a340619080e.JPG</url>
      <title>Forem: Leon Pennings</title>
      <link>https://forem.com/leonpennings</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/leonpennings"/>
    <language>en</language>
    <item>
      <title>The Properties of Enterprise Software That Lasts</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Thu, 21 May 2026 08:43:16 +0000</pubDate>
      <link>https://forem.com/leonpennings/the-properties-of-enterprise-software-that-lasts-4j50</link>
      <guid>https://forem.com/leonpennings/the-properties-of-enterprise-software-that-lasts-4j50</guid>
      <description>&lt;p&gt;&lt;em&gt;"Perfection is achieved not when there is nothing more to add, but when there is nothing more to remove."&lt;/em&gt; — Antoine de Saint-Exupéry&lt;/p&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Enterprise software is different from other software. Not in the technologies used to build it, not in the frameworks, not in the methodologies. It is different in its purpose: it must work correctly today, remain correct over time, survive the people who built it, and adapt to a business domain that will change in ways nobody can fully predict. Most software is built to solve today's problem. Enterprise software must be built to outlast today's understanding.&lt;/p&gt;

&lt;p&gt;That is a fundamentally different design goal. And it demands a fundamentally different way of thinking about software — about what matters, what doesn't, and what the job of a developer actually is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The code is downstream of the thinking.&lt;/strong&gt; The properties which determine whether enterprise software survives — or quietly becomes the system nobody dares touch — are not primarily technical. They are properties of understanding. And the thinking starts long before the first line is written.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Six Properties
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Longevity
&lt;/h3&gt;

&lt;p&gt;The core of enterprise software should, in retrospect, survive ten to fifteen years. Not the UI framework. Not the ORM. Not the messaging library. The &lt;em&gt;core&lt;/em&gt; — the domain logic, the structural decisions, the way the system understands and represents the business.&lt;/p&gt;

&lt;p&gt;This sounds obvious until you consider how rarely it is treated as a design constraint. Most development decisions are made under short-term pressure: the sprint deadline, the current team's preferences, the framework that is fashionable today. None of those inputs have any relationship to what the system will need to be in year eight.&lt;/p&gt;

&lt;p&gt;Longevity is not achieved by predicting the future. It is achieved by not over-committing to the present. Every unnecessary dependency, every piece of logic tied to a specific framework's idiom, every abstraction built around today's tooling rather than today's domain — these are bets that the present will continue. In enterprise software, the present never continues long enough.&lt;/p&gt;

&lt;p&gt;Longevity is the north star. The properties that follow are the means to achieve it.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Upgradeability
&lt;/h3&gt;

&lt;p&gt;Upgradeability is not about keeping dependencies current. Keeping dependencies current is maintenance. Upgradeability is structural: it is the capacity of the system to accept functional change without requiring a rewrite of its core.&lt;/p&gt;

&lt;p&gt;This distinction matters enormously. A system can have perfectly up-to-date dependencies and be completely unupgradeable — because its structure was built around the features known at the time, implemented in a way that assumes those features are the final shape of the domain. When the business changes, and it will, there is nowhere to go.&lt;/p&gt;

&lt;p&gt;Building for upgradeability means building with the understanding that what you know today is not everything. It does not mean building features you don't need — that is the opposite of the principle. It means implementing what you know today in a way that does not foreclose tomorrow. The structure should be open to extension, refactoring, and replacement at the right level of granularity.&lt;/p&gt;

&lt;p&gt;This is also where the conventional wisdom about test coverage becomes a liability. Class-level unit tests — one test class per production class, testing the internal mechanics of each — are a contract on the current implementation. They make refactoring expensive by breaking whenever the internals change, even when the behavior is preserved. Over time, they become the reason the system cannot be restructured: the test suite has calcified the implementation.&lt;/p&gt;

&lt;p&gt;Behavioral tests — tests that assert what a piece of functionality does, not how a particular class does it — are a contract on the domain. They survive refactoring because refactoring does not change behavior, only implementation. Upgradeability requires the right level of test coupling. Tests should be coupled to what the system does, not to how it currently does it.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Maintainability
&lt;/h3&gt;

&lt;p&gt;Maintainability in long-lived software is primarily a question of dependency discipline. Every external dependency is a commitment: to a version, to an API contract, to a community that may or may not continue to support it. Over fifteen years, many of those commitments will become liabilities.&lt;/p&gt;

&lt;p&gt;The critical discipline is asking, for every dependency: what does this actually buy us? Not in theory — in practice, in this specific system, for this specific use case. The question is not whether a dependency is good in the abstract — a battle-tested cryptography library, a well-maintained time handling library, a parser for a complex format — these earn their place because the alternative is genuinely worse. The question is whether &lt;em&gt;this&lt;/em&gt; dependency serves &lt;em&gt;this&lt;/em&gt; production system's domain needs, or whether it serves the tooling, the framework preference, or the developer's convenience.&lt;/p&gt;

&lt;p&gt;The dependency that should be rejected without hesitation is the one whose primary justification is testability of the production code. Testability is a testing concern, not a production concern. Production code should not be structured, abstracted, or made more complex to accommodate the needs of the test suite.&lt;/p&gt;

&lt;p&gt;This manifests in two particularly damaging patterns. The first is mocking-driven architecture: interfaces created not because the domain has multiple implementations of a concept, but because the test framework needs a seam to inject a mock. An interface with one real implementation, existing purely to enable a unit test, adds a layer of indirection with no domain justification. Every future reader follows the code, hits the interface, and must go find the implementation. The test was marginally easier to write. Every reader pays for that convenience forever.&lt;/p&gt;

&lt;p&gt;The second is Aspect-Oriented Programming applied to cross-cutting concerns. The promise was clean separation — keep business logic free of logging, transactions, security, caching. In practice, the result is code where you cannot tell what is executing by reading it. The aspects are invisible in the source. Behavior is woven in at runtime by configuration that must be hunted for separately. You need a debugger to understand what your own code does. That is not decoupling. It is hidden coupling, which is strictly worse than visible coupling because at least visible coupling can be read.&lt;/p&gt;

&lt;p&gt;Both patterns share the same failure: a tooling concern reshaped the production code in ways that made it harder to understand. The test suite or the framework became easier to work with. The system became harder to reason about. That is the wrong trade, and it compounds over fifteen years in ways that eventually make the system unreformable.&lt;/p&gt;

&lt;p&gt;The simpler path is to make the production code so clear in its intent that the need for complex testing infrastructure is reduced rather than accommodated. Nobody tests &lt;code&gt;string.trim()&lt;/code&gt; — not because someone decided it was below the testing threshold, but because its intent and behavior are completely transparent. The ambition for domain logic should be the same. &lt;code&gt;order.send()&lt;/code&gt; can be just as obvious if the implementation reads like a statement of business intent rather than a sequence of technical operations.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Extensibility
&lt;/h3&gt;

&lt;p&gt;Extensibility requires locatability. Before you can extend a piece of functionality, you must be able to find it — and find it with confidence that you have found all of it, not just the most obvious part.&lt;/p&gt;

&lt;p&gt;This is where fat services fail. When business logic accumulates in large service classes organised around user stories or features, the domain structure disappears. Logic that belongs together by domain reason is separated. Logic that is separate by domain reason collides in the same class. Over time, the service becomes an archaeological record of every feature request, in chronological order, and understanding what it does requires reading its entire history.&lt;/p&gt;

&lt;p&gt;Extensibility is only achievable when the code is structured around the domain — around what the business actually is, not around how it was requested. When that structure exists, adding a new capability means finding the right place in a coherent map. When it does not exist, extending the system means navigating a maze and hoping you found everything relevant.&lt;/p&gt;




&lt;h3&gt;
  
  
  5. Readability
&lt;/h3&gt;

&lt;p&gt;Readability is not a soft property. It is not aesthetic. It has direct economic consequences over a fifteen-year lifespan that compound in ways that eventually make a system unreformable.&lt;/p&gt;

&lt;p&gt;The measure of readability in enterprise software is not whether an experienced developer finds the code elegant. It is whether the intent and structure are followable to a non-engineer — a domain expert, a compliance officer, a business analyst — who can read the code and recognise their domain in it. This does not mean every line reads as plain prose. Some domains have irreducible technical density: complex financial calculations, regulatory rule engines, actuarial models. The bar is not that the implementation is self-explanatory to someone without domain expertise. The bar is that the &lt;em&gt;structure&lt;/em&gt; expresses the domain, that the &lt;em&gt;intent&lt;/em&gt; is visible, and that the domain expert can follow the logic well enough to identify where their understanding is or is not correctly represented.&lt;/p&gt;

&lt;p&gt;If the code reads like hocus pocus at the structural level to the person who understands the business, the code has failed at its most important communication task.&lt;/p&gt;

&lt;p&gt;This standard has consequences for every micro-decision in implementation. It argues against stream operations where a for-loop is clearer to a broader audience — not because streams are wrong, but because in domains where large in-memory sets are never permitted by design, the performance justification evaporates and only the readability cost remains. It argues against boilerplate reduction that sacrifices expressiveness for terseness. It argues against every clever idiom that shortens the code for its author while lengthening the cognitive load for its future readers.&lt;/p&gt;

&lt;p&gt;"Boilerplate" is only boilerplate if it has no business purpose. Code that is verbose because it is expressing a business process is not boilerplate — it is documentation, in the only place documentation is always current. The argument to reduce it is always an argument to optimise for the writer. In enterprise software, the reader is nearly always more important. The code will be read an order of magnitude more times than it is written, by people who were not present when it was created.&lt;/p&gt;

&lt;p&gt;On large data sets specifically: the correct architectural response is not to optimise how they are processed in memory — it is to enforce a boundary that prevents unbounded datasets from reaching the application layer at all. Chunk the data before it is loaded. This is an architectural constraint, not a performance trick. By making large in-memory sets structurally impossible, the design eliminates the entire class of optimisation pressure they create. The complexity of cursor management and pagination lives at the data access boundary, where it belongs, not scattered as stream operations through business logic. The upstream constraint produces downstream simplicity.&lt;/p&gt;

&lt;p&gt;Readability is the condition that makes the other properties achievable. Code that reads like the domain can be upgraded because the domain is visible in it. Code that expresses intent clearly can be maintained because its purpose is self-evident. Code that maps the domain accurately can be extended because the map can be followed. It is not one property among five — it is the keystone.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Organisation
&lt;/h3&gt;

&lt;p&gt;Organisation is qualitatively different from the first five properties. Those are visible in the codebase — you can read them, measure them, argue about them in a code review. Organisation is visible in what the codebase was &lt;em&gt;allowed to become&lt;/em&gt;. It is the soil in which the other properties grow or fail to grow. Making it an explicit pillar says: this cannot be managed by ignoring it.&lt;/p&gt;

&lt;p&gt;The question every development team eventually confronts is whether the organisation is supportive or restrictive. The honest answer is that it is almost always intended to be supportive and frequently experienced as restrictive — and the gap between those two is where a significant amount of enterprise software complexity originates.&lt;/p&gt;

&lt;p&gt;The most common form this takes is architectural mandate without domain justification. Platform teams, rightly responsible for consistency and infrastructure standards, apply patterns designed for large distributed systems universally — including to applications that are, by domain definition, a single coherent thing. Microservices architectures get mandated for systems with no independent scaling requirements, no team boundary that would justify a service boundary, no domain reason for a network boundary to exist. The result is artificial complexity: deployment pipelines for services with no independent reason to exist, network calls where function calls would suffice, operational overhead that consumes development capacity without adding production value.&lt;/p&gt;

&lt;p&gt;The architecture was not wrong for all systems. It was wrong for this system, for this domain, at this scale. But the mandate did not ask about the domain. It asked about organisational standards. And the production system pays the difference on every deployment, every change, every new hire who must learn the infrastructure before they can touch the domain.&lt;/p&gt;

&lt;p&gt;This is organisational complexity billed to the production system. It feels like support. From the production system's perspective it is an undiscussed tax with no domain justification.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Toyota Parallel
&lt;/h4&gt;

&lt;p&gt;Toyota solved this problem in manufacturing and the solution translates directly to software development. The Toyota Way rests on two pillars: continuous improvement, and respect for people. Both are violated by the organisational patterns that produce restrictive environments.&lt;/p&gt;

&lt;p&gt;Respect for people, in the Toyota sense, is not about workplace culture. It is an epistemological principle: the people closest to the work hold the most valuable knowledge about the work. On the production floor, the assembly worker who notices something wrong knows something the engineer in the office does not. Toyota's andon cord exists to make that knowledge immediately actionable — any worker can stop the line when they identify a defect, because the cost of a defect that travels further down the line is exponentially higher than the cost of stopping to fix it now.&lt;/p&gt;

&lt;p&gt;In software development the people closest to the work are the developers and the domain experts. The domain expert who says "this doesn't reflect how we actually work" is pulling the andon cord. The developer who identifies a structural problem in the architecture is pulling the andon cord. Organisations that route those signals through layers of translation — product owners, project managers, UX designers, platform architects — are not being more rigorous. They are covering the cord in bureaucratic insulation and walking past it.&lt;/p&gt;

&lt;p&gt;The second Toyota concept worth applying directly is &lt;em&gt;genchi genbutsu&lt;/em&gt; — go and see for yourself. Do not manage from reports. Do not accept translated summaries. Go to where the work happens and observe it directly. For software this means the developer sitting with the domain expert, watching them work, seeing where the system creates friction, understanding the domain from its source rather than from a requirements document that passed through three people before it arrived. Every layer of translation between the domain expert and the developer is a layer where meaning is lost and assumption is substituted.&lt;/p&gt;

&lt;p&gt;The third is &lt;em&gt;jidoka&lt;/em&gt; — quality built in, not inspected in after the fact. You cannot UX-design your way to a correct domain model. You cannot test your way to a correct domain model. The correctness must be present from the beginning, in the understanding that shaped the implementation. When domain feedback arrives late — filtered through contact persons who are not the domain authorities, interpreted as a UX problem rather than a domain problem — the system has already been built around an incomplete model. Correcting it at that point is expensive. The organisational structure that produced the late feedback is the root cause, not the feedback itself.&lt;/p&gt;

&lt;h4&gt;
  
  
  Domain Feedback Is Always a Learning Opportunity
&lt;/h4&gt;

&lt;p&gt;When domain experts say a system is too complex or doesn't make sense to them, the instinct in process-first organisations is to call a UX designer. This is solving the wrong problem at the wrong layer. UX is interface orientation — it makes existing concepts easier to navigate. It cannot fix a missing concept. If the domain model is incomplete, no amount of interface polish makes it clearer. You cannot design your way around a hole in the domain.&lt;/p&gt;

&lt;p&gt;"Too complex" from a domain expert almost always means one of two things: a concept that exists in their mental model is absent from the system, or the system is telling a story the domain expert doesn't recognise as their own. Both are domain problems. The correct response is a domain conversation, not a design review.&lt;/p&gt;

&lt;p&gt;This reframes what domain feedback actually is. It is not obstruction. It is not a sign that the users don't understand the system. It is the most valuable signal available — an authoritative source reporting that the model is incomplete. Organisations that treat it as a learning opportunity produce better software. Organisations that treat it as a user adoption problem produce expensive workarounds for incorrect models.&lt;/p&gt;

&lt;h4&gt;
  
  
  Discovery-Driven Implementation
&lt;/h4&gt;

&lt;p&gt;The organisational conditions described above — domain experts who can reach the development team, feedback treated as learning, developers trusted to inquire beyond the story — enable something that process-constrained environments make nearly impossible: discovery-driven implementation.&lt;/p&gt;

&lt;p&gt;Most software development is story-driven. The solution space is bounded by what was requested. The developer's job is to implement the described behaviour correctly and completely. This produces correct implementations of incomplete specifications, reliably and at scale.&lt;/p&gt;

&lt;p&gt;Discovery-driven implementation starts from the same user story but treats it as a symptom description rather than a solution specification. The developer who asks enough questions about the domain — who wants to understand not just what was asked but why, what problem it actually solves, what the current process costs, where it fails — occasionally discovers that the problem as described is not the real problem. The real problem is upstream. And the solution to the real problem makes the described problem structurally impossible rather than better managed.&lt;/p&gt;

&lt;p&gt;This kind of insight cannot be mandated. It cannot be specified in advance. It cannot be written as a test before it exists. It emerges from genuine engagement with the domain, from the developer who treats the user story as a starting point rather than a work order, from the organisation that protects the space for that inquiry rather than constraining every hour to story execution.&lt;/p&gt;

&lt;p&gt;The deepest return on domain understanding is not better implementation of what was asked. It is the occasional recognition that the problem as described is a symptom — and that the real solution makes the symptom structurally impossible. That insight cannot be mandated, cannot be specified, cannot be tested before it exists. It emerges from genuine engagement with the domain, and it is available only to the developer who treated the user story as a starting point rather than a work order. Organisations that protect that space — that trust developers to inquire, to discover, to propose solutions nobody asked for because nobody knew to ask — produce software that solves real problems. Organisations that constrain that space to story execution produce software that manages symptoms, expensively, forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Foundation Beneath the Properties
&lt;/h2&gt;

&lt;p&gt;Every property described above is downstream of something that is not a technical practice at all. It is understanding.&lt;/p&gt;

&lt;p&gt;You cannot write readable code about something you do not understand. You cannot structure something well that you have not thought through. You cannot know what to leave out — which is often more important than knowing what to put in — unless you understand the domain well enough to recognise what is essential and what is incidental.&lt;/p&gt;

&lt;h3&gt;
  
  
  The User Story Is Not a Work Order
&lt;/h3&gt;

&lt;p&gt;A user story is a starting point for a conversation, not a specification for implementation. The moment a developer treats it as a work order — something to be implemented against acceptance criteria, tested to green, and closed — they have accepted someone else's translation of the domain as complete and correct. That translation is almost never complete, and sometimes critically incorrect.&lt;/p&gt;

&lt;p&gt;The developer's job before the first line of code is to understand the business goal behind the story. Not the described behaviour — the goal. This requires asking questions. Not to clarify ambiguous requirements, but to understand the domain itself. What is this actually trying to achieve? What are the edge cases the domain expert considers obvious? What should this system never do, and why?&lt;/p&gt;

&lt;p&gt;Consider a user story about calculating UBO — Ultimate Beneficial Ownership. A developer implementing against the story might write: find all natural persons with ownership percentage above the threshold. That is what the acceptance criteria describe. The tests pass. The implementation is wrong.&lt;/p&gt;

&lt;p&gt;A correct understanding of UBO reveals that it is not about direct ownership percentage in isolation. It is about effective control — who ultimately determines the decisions of the entity, regardless of how the ownership structure is arranged. The question is not just who is the UBO. It is who &lt;em&gt;else&lt;/em&gt; is the UBO. And it is who &lt;em&gt;also&lt;/em&gt; has control. If there is no "also" — there is just one.&lt;/p&gt;

&lt;p&gt;That small shift in framing immediately surfaces a class of scenarios that the acceptance-criteria reading misses entirely. Consider natural person 1 who holds 4% in company A and 4% in company B. Company A holds 96% in company B. Company B holds 96% in company A. By direct ownership percentage, natural person 1 appears below the UBO threshold. By effective control, natural person 1 is 100% the UBO of both companies — because the circular cross-ownership means neither company has any independent shareholder beyond this person.&lt;/p&gt;

&lt;p&gt;No test-first methodology surfaces this. No refactoring produces it. Domain understanding produces it, in the conversation before a line of code is written, because a developer who understands what UBO law is actually designed to do recognises this scenario not as an edge case but as a textbook example of what the law was written to catch.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Implementation Should Not Be
&lt;/h3&gt;

&lt;p&gt;Domain understanding does not only tell you what to build. It tells you what not to build — and that is often more valuable.&lt;/p&gt;

&lt;p&gt;When you understand that UBO is about effective control through any structure, you immediately know the implementation should not be a threshold check on direct ownership percentages. That single "should not" eliminates the naive implementation before it is written. It eliminates an entire class of wrong solutions without a single line of code.&lt;/p&gt;

&lt;p&gt;This is the discipline of subtraction. Every constraint that comes from genuine domain understanding is a constraint that prevents future complexity. What is not there cannot introduce a bug. What is not there requires no maintenance. What is not there cannot become the thing nobody dares touch because nobody understands why it exists.&lt;/p&gt;

&lt;p&gt;The simplest correct solution is also the most durable one. Not because simplicity is aesthetically preferable, but because complexity compounds. Every unnecessary abstraction, every dependency added for theoretical future benefit, every pattern introduced for a problem the system does not have — each one is a tax on every future change, every new hire, every upgrade cycle. Over fifteen years those taxes become the reason a system becomes unreformable.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Right Level of Test Coverage
&lt;/h3&gt;

&lt;p&gt;Honest test coverage in enterprise software is not a percentage target. It is a risk assessment.&lt;/p&gt;

&lt;p&gt;The question is never "what percentage of lines are covered?" It is: "where are the places this system could be silently wrong, and how quickly would we know?" Tests earn their place where the real-world feedback loop is too slow, too infrequent, or too opaque to catch failures naturally.&lt;/p&gt;

&lt;p&gt;A login page that breaks gets reported within minutes — high-frequency paths like these are well covered by integration, smoke, and end-to-end tests that run as part of any competent CI pipeline. Deep unit testing of those flows is redundant effort. A UBO calculation might run once a day for a small compliance team. It could be wrong for weeks before anyone notices. The domain is complex enough that failures are non-obvious. That is precisely where a behavioral test earns its place: not as a development guiderail, but as a specification of correctness for something that does not announce when it is wrong.&lt;/p&gt;

&lt;p&gt;In practice, this produces test coverage in the range of 30 to 50 percent — not because the rest of the code is untested, but because the rest of the code is covered by higher-level tests and validated continuously by the people using it. The 30 to 50 percent that is explicitly tested at the unit or behavioral level is the core domain logic: the calculations, the rule evaluations, the business-critical paths where silent failure is a real and consequential risk.&lt;/p&gt;

&lt;p&gt;This is a more defensible position than 90 percent coverage that includes getters, setters, login flows, and string formatting. Coverage as a metric measures lines executed, not correctness guaranteed. Behavioral tests on the domain core, combined with integration tests on the main flows and a system simple enough that its failures are visible, produces better assurance than a heavily instrumented suite that tests implementation details nobody will care about in year seven.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Training Wheels Problem
&lt;/h3&gt;

&lt;p&gt;There is a pattern in software development where tests function not as a quality mechanism but as a substitute for understanding. If the developer does not fully understand what they are building, green tests provide a guiderail: as long as the tests pass, the implementation is probably acceptable.&lt;/p&gt;

&lt;p&gt;Training wheels do not teach balance. They teach riding without balance — a different skill entirely. A developer conditioned by green tests as their primary signal learns to satisfy the tests. A developer who understands the domain learns what the business actually needs. Those are not the same education, and in complex domains they produce starkly different results.&lt;/p&gt;

&lt;p&gt;The test suite becomes a confidence mechanism decoupled from correctness. The tests reflect the developer's mental model of the domain. If that mental model is incomplete — and without domain inquiry it almost certainly is — the tests are an incomplete specification, confidently asserted as complete. This is worse than no tests. It is false assurance.&lt;/p&gt;

&lt;p&gt;The cure is not better tests. It is understanding deep enough that the test's contribution becomes marginal. If the code expresses the domain correctly and reads plainly enough for a domain expert to validate its structure, the test suite's role as documentation and safety net diminishes considerably. A tester who says his functional tests serve as documentation of the application is making an admission: the production code has failed at its most important job. Documentation belongs in the place where it is always current — in code that reads like the domain it represents.&lt;/p&gt;




&lt;h2&gt;
  
  
  When the Process Becomes the Bug
&lt;/h2&gt;

&lt;p&gt;There is a question worth asking of every engineering practice, every tool, every ceremony: is this the best choice for the production system, or is it the best choice for the process, the tooling, or trend compliance?&lt;/p&gt;

&lt;p&gt;The production system is the artifact that matters. Everything else — the sprint board, the Jira backlog, the test suite, the deployment pipeline, the architecture decision records — is support infrastructure. It exists to serve the production system. The moment any of it starts making decisions for the production system, the hierarchy has inverted. And it inverts constantly, quietly, and with complete institutional legitimacy.&lt;/p&gt;

&lt;p&gt;Nobody says "we are going to let Jira determine our engineering decisions." But when a five-minute bug fix gets put on the backlog because the process requires it, Jira just made an engineering decision. When a developer adds an abstraction layer to satisfy a test framework rather than to express the domain, the test suite just shaped the production system. When a simple piece of logic gets restructured to comply with a framework convention that has no business relevance, trend compliance just overrode domain clarity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process thinking asks:&lt;/strong&gt; are we following the process correctly? &lt;strong&gt;Production thinking asks:&lt;/strong&gt; what is the best outcome for the system?&lt;/p&gt;

&lt;p&gt;When they conflict, the answer should be immediate and unambiguous: the production system wins. The process is a tool. Tools do not have votes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bug Economics
&lt;/h3&gt;

&lt;p&gt;Consider the real cost of a simple bug — a button that doesn't work, an enum stored as an integer instead of a string — when it travels through a process-first system versus a production-first one.&lt;/p&gt;

&lt;p&gt;In a production-first system with simple, readable code and a CI pipeline that allows release at any time: the bug is reported, understood, fixed, and released the same day. Total engineering time: five minutes to fix, minutes to release. The user experiences a brief interruption and a same-day resolution.&lt;/p&gt;

&lt;p&gt;In a process-first system the same bug looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Reported and logged: 10 minutes of administration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Discussed in standup or triage: 20 minutes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Estimated and planned into a sprint: 15 minutes in a planning meeting&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Picked up one or two sprints later by a developer who must first relearn the context, understand the bug, navigate the abstraction layers, fix the code, fix the broken tests, and write new tests: 60 minutes or more&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total: approximately 110 minutes of engineering time to resolve a 5-minute problem, with the user waiting six weeks for a fix that was always trivial. That is a 22-times cost multiplier applied entirely by the process. The bug is not better fixed. The system is not more stable. The outcome is strictly worse in every dimension — cost, speed, and user experience — and the process produced it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Kaizen Parallel
&lt;/h3&gt;

&lt;p&gt;This is not a new insight. Toyota's lean manufacturing principles identified this failure mode decades ago under the concept of &lt;em&gt;muda&lt;/em&gt; — waste. Waste in production systems is any activity that consumes resources without adding value. The 105 minutes of process overhead on a 5-minute fix is almost pure waste: motion without value, waiting, unnecessary processing.&lt;/p&gt;

&lt;p&gt;The deeper Kaizen principle is that the person closest to the problem is best positioned to fix it. The developer who wrote the code, who understands it today, who can see the bug clearly right now — that person fixing it immediately is the optimal outcome by every measure. Deferring it transfers the problem to a different person at a different time with less context, more overhead, and a worse result.&lt;/p&gt;

&lt;p&gt;Empirically, this approach does not produce more bugs. Teams that have observed both models report comparable defect rates. The difference is resolution time: same-day fixes versus multi-sprint delays. On the metric that actually matters to the business — how long does a known problem affect users — the simple, production-first system wins decisively.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Job
&lt;/h2&gt;

&lt;p&gt;The assembly part of software development — implementing a described behaviour to pass a set of tests — is a commodity skill. It is increasingly automatable. It produces measurable output in a sprint and moves tickets across a board. It is the part of the job that process-first thinking measures, rewards, and optimises for.&lt;/p&gt;

&lt;p&gt;The understanding part is not a commodity. It is not automatable. It does not show up in velocity metrics or test coverage percentages. But it is the part that determines whether the software is actually correct. It is the part that finds the circular ownership scenario before it becomes a compliance incident. It is the part that knows what to leave out. It is the part that produces code readable enough that a domain expert can spot an error without running a test. It is the part that makes a bug a five-minute fix rather than a two-sprint project. And it is the part that occasionally recognises that the problem as described is a symptom — and builds the thing that makes the symptom impossible.&lt;/p&gt;

&lt;p&gt;Everything that is not in direct service of the production system is not neutral overhead today. It is an obstacle tomorrow. The fifteen-year lifespan makes this visible in a way that a two-year project never does. The complexity accumulates. The process overhead compounds. The abstractions added for testability become the walls that trap the system. The dependencies added for framework compliance become the liabilities that prevent the upgrade. The architectural mandates applied without domain justification become the constraints that make every change expensive.&lt;/p&gt;

&lt;p&gt;Ask of every decision: is this the best choice for the production system? If the honest answer is "no, but it satisfies the process" — remove it. Whatever is not there cannot break, does not need maintenance, and does not need to be understood.&lt;/p&gt;

&lt;p&gt;Simplicity is not the absence of effort. It is the result of understanding deep enough to know what to remove.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The properties described in this article — longevity, upgradeability, maintainability, extensibility, readability, and organisation — are not independent qualities to be optimised separately. They are consequences of a single discipline: understanding the domain well enough to represent it simply, correctly, and durably in code that will outlast the people who wrote it. The process serves that goal. When it stops serving that goal, the process is the bug.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>softwareengineering</category>
      <category>devops</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>What Is a Rich Domain Model?</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 19 May 2026 11:48:05 +0000</pubDate>
      <link>https://forem.com/leonpennings/what-is-a-rich-domain-model-2d0k</link>
      <guid>https://forem.com/leonpennings/what-is-a-rich-domain-model-2d0k</guid>
      <description>&lt;p&gt;Most articles about rich domain models get lost in comparisons to anemic models, debates about OOP mechanics, or pattern catalogues. This is not one of those articles.&lt;/p&gt;

&lt;p&gt;A rich domain model is not a technical pattern. It is a discipline — one that produces a living, explicit representation of the essential complexity of a business domain. Understanding what that means, and what it unlocks, requires stepping back from the code entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Essential Complexity, Made Explicit
&lt;/h2&gt;

&lt;p&gt;Start with what a rich domain model actually is.&lt;/p&gt;

&lt;p&gt;It is a set of objects, each playing a defined role in the business domain, each owning the responsibility that role entails. Not what state they carry — but what they know, what they decide, and what belongs to them. Think of it less like a data structure and more like a cast of actors: each one has a role, and the role defines everything. What they are responsible for. What they know. What they act on. What they refuse.&lt;/p&gt;

&lt;p&gt;This distinction matters more than it might seem. An actor on stage is not described by listing their costume and props. They are described by their role — what they do, what they own, what they are accountable for. The props are incidental. In the same way, a domain object is not defined by the fields it holds. It is defined by its responsibility. State may be part of how it fulfills that responsibility — but it is an implementation detail of the role, not the definition of it.&lt;/p&gt;

&lt;p&gt;The contrast with an anemic model follows directly. An anemic model is a cast of actors who have been stripped of their roles. They stand on stage holding props while someone offstage calls out instructions. The data is visible. The knowledge of what to do with it is gone — moved into service classes, transaction scripts, and workflow configurations that grow without principle and conflict without resolution.&lt;/p&gt;

&lt;p&gt;Fred Brooks gave us the vocabulary to understand why this matters. He distinguished between &lt;em&gt;essential complexity&lt;/em&gt; — the complexity intrinsic to the problem itself, which cannot be removed — and &lt;em&gt;accidental complexity&lt;/em&gt;, everything else: the frameworks, the indirections, the patterns applied without cause.&lt;/p&gt;

&lt;p&gt;The actors and their roles &lt;em&gt;are&lt;/em&gt; the essential complexity. They are not a representation of it or a metaphor for it — they are it, made visible and explicit. Every business rule that is genuinely hard, every lifecycle that has real consequences, every constraint that exists because the business demands it: these find their home in a role, owned by an actor, named and present in the model. You can see the essential complexity. You can point to it. You can reason about it directly.&lt;/p&gt;

&lt;p&gt;Once the essential complexity is that explicit, accidental complexity loses its camouflage. It cannot pretend to belong. Every framework choice, every infrastructure decision, every pattern applied can be held up against a simple question: does a domain object — a named actor with a defined role — actually require this? If not, it is accidental complexity, and it has no business being there. The model makes that judgment possible because the essential complexity is no longer hiding.&lt;/p&gt;

&lt;p&gt;This is not the same as reducing complexity. The business is as complex as it is. What changes is whether that complexity is visible, owned, and honest — or scattered, implicit, and discovered only when things break. The rich domain model ensures the essential complexity is always primary. Everything else is secondary, and known to be so.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Tool for Learning the Domain
&lt;/h2&gt;

&lt;p&gt;Most development approaches start from requirements. A user story describes motion through a system: a user does something, something happens. This teaches you the rivers — the flows, the happy paths, the scenarios that have been thought of so far.&lt;/p&gt;

&lt;p&gt;A domain model teaches you the terrain. Once you understand the terrain, the rivers make sense. Without it, you are always following water, never knowing where you are.&lt;/p&gt;

&lt;p&gt;This distinction matters enormously in practice. When a developer learns a business domain through user stories and debugging, they accumulate procedural knowledge. They learn symptoms. They build a mental model that is a patchwork of scenarios, edge cases, and tribal knowledge. That understanding does not transfer easily and does not survive personnel changes.&lt;/p&gt;

&lt;p&gt;When a developer learns through the domain model — starting with the core concepts, understanding their responsibilities and relationships — they learn causes. The &lt;em&gt;what&lt;/em&gt; and &lt;em&gt;why&lt;/em&gt; of the business becomes clear before the &lt;em&gt;how&lt;/em&gt;. Onboarding that previously took months can take hours, not because the business became simpler, but because its essential structure was made explicit and navigable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Canonical Truth for the Business Domain
&lt;/h2&gt;

&lt;p&gt;A codebase without a domain model has no authoritative reference for what the business believes. Logic accumulates in transaction scripts, in service classes, in stored procedures, in workflow configurations. It is never gathered in one place where you can ask: is this consistent? Does this conflict with that?&lt;/p&gt;

&lt;p&gt;The rich domain model is that place. It is not documentation in the sense of comments or wikis — those go stale and lie. It is living documentation, expressed in code, that is wrong only when the code is wrong. When two features conflict, the domain model is the referee. When a new requirement arrives, the model is the context in which it is evaluated — is this already expressed somewhere? Does this contradict something that exists?&lt;/p&gt;

&lt;p&gt;Without that context, conflicting logic does not just happen occasionally. It is inevitable. There is no shared reference, so there is no way to prevent divergence. The model prevents it not through process or discipline, but through the simple fact of existing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Belongs in the Domain Model
&lt;/h2&gt;

&lt;p&gt;A common misconception is that domain objects are database rows dressed up with methods. This conflation produces models that are anemic by construction — the shape of the schema becomes the shape of the domain, and the domain becomes a mirror of the persistence layer rather than a representation of the business.&lt;/p&gt;

&lt;p&gt;The domain object is defined by its &lt;em&gt;responsibility&lt;/em&gt;, not by its persistence. Whether it holds state is irrelevant to whether it belongs in the model. What matters is whether it represents a genuine business concept with a defined responsibility.&lt;/p&gt;

&lt;p&gt;Some domain objects have state that should be persisted. In that case, the ORM annotations live on the domain object itself — there is no separate entity class, no parallel representation. The domain object is the single source of truth, and persistence is simply a capability some objects happen to have. There is no ORM object that is not a domain object. If one exists, that is the smell — not a feature. Some will object that this violates persistence ignorance — that the domain should not know about its own storage. But a domain object declaring what it needs is not pollution. It is honesty. The alternative — a parallel entity class that mirrors the domain object field by field — is not cleaner architecture. It is the same information written twice, with an extra layer of indirection between them and nothing gained in return.&lt;/p&gt;

&lt;p&gt;Other domain objects have no persistent state at all. A &lt;code&gt;CurrencyConversion&lt;/code&gt; that owns the rules and cache for converting between currencies is a full citizen of the domain model. An &lt;code&gt;Interaction&lt;/code&gt; that represents a session of intent against the domain — carrying the current user, the transaction boundary, the active roles — is a domain object. Neither has a table. Both have clear, defined responsibilities.&lt;/p&gt;

&lt;p&gt;The question is never "does this have a table?" The question is always "does this represent something real in the business, with a responsibility that can be named?"&lt;/p&gt;




&lt;h2&gt;
  
  
  The Interaction: A Worked Example
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Interaction&lt;/code&gt; deserves particular attention because it illustrates what correct modeling unlocks beyond the obvious.&lt;/p&gt;

&lt;p&gt;Every non-trivial business application has the concept of an interaction: a moment of intent against the domain, initiated by a known user, within a defined transactional boundary, with a lifecycle that has a beginning and an end. This concept exists whether you model it or not. The question is whether it is explicit or scattered across framework configuration, security filters, transaction annotations, and audit log scrapers.&lt;/p&gt;

&lt;p&gt;When modeled explicitly — made available within the execution context, whether via &lt;code&gt;ThreadLocal&lt;/code&gt;, scoped storage, or whatever the runtime demands — &lt;code&gt;Interaction&lt;/code&gt; becomes the natural owner of everything that belongs to that lifecycle. The storage mechanism is an implementation detail. The concept is not.&lt;/p&gt;

&lt;p&gt;During an interaction, any part of the domain can ask &lt;code&gt;Interaction.hasUserRole(CancelOrderRole.class)&lt;/code&gt; — not as a security check imposed from outside, but as a domain question answered where the action is performed. Authentication is resolved before the interaction begins; a valid &lt;code&gt;Interaction&lt;/code&gt; means a valid user. Authorization is expressed where it is enforced.&lt;/p&gt;

&lt;p&gt;At the end of an interaction, deferred actions execute within the same transactional boundary. Emails are sent, events are fired, downstream reactions trigger — and if anything fails, everything rolls back, including the email that had not yet been sent. This guarantee is structurally impossible to achieve with a message broker bolted onto the outside of an application without significant infrastructure overhead. Here it is a natural consequence of the model.&lt;/p&gt;

&lt;p&gt;After the end of an interaction, post-transaction actions execute outside the boundary, intentionally and explicitly. On cleanup, state is torn down predictably — no leaked state between requests.&lt;/p&gt;

&lt;p&gt;The audit trail — who did what, when, and did it succeed — emerges naturally because &lt;code&gt;Interaction&lt;/code&gt; already knows all of it. It is not assembled from logs after the fact.&lt;/p&gt;

&lt;p&gt;None of these capabilities were designed individually. They are all consequences of modeling the right concept. This is what essential complexity, made explicit, produces.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Order: Lifecycle as Domain Responsibility
&lt;/h2&gt;

&lt;p&gt;The same principle applies to any object with a meaningful lifecycle. Consider an &lt;code&gt;Order&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Order&lt;/code&gt; is constructed from an &lt;code&gt;OrderRequest&lt;/code&gt;. In its constructor — or through an &lt;code&gt;assemble()&lt;/code&gt; method called immediately — it validates that all items have prices (failing fast if not), reserves inventory, creates the &lt;code&gt;Invoice&lt;/code&gt;, determines from the request whether fulfillment is pickup or delivery, and if delivery, creates the &lt;code&gt;Shipment&lt;/code&gt; internally. No external coordinator performs these steps. The &lt;code&gt;Order&lt;/code&gt; knows what it means to be an order.&lt;/p&gt;

&lt;p&gt;Once assembled, the &lt;code&gt;Order&lt;/code&gt;'s state gates what is possible. &lt;code&gt;deliver()&lt;/code&gt; is only reachable because &lt;code&gt;assemble()&lt;/code&gt; completed. Anything attached to an order — documents, notes, events — is evaluated against the current state. The object enforces its own rules.&lt;/p&gt;

&lt;p&gt;The lifecycle of the order is expressed in &lt;code&gt;OrderMilestone&lt;/code&gt; objects: &lt;code&gt;created&lt;/code&gt; at &lt;code&gt;LocalDateTime&lt;/code&gt; X, &lt;code&gt;ItemsCompleted&lt;/code&gt; at X+1, &lt;code&gt;Shipped&lt;/code&gt; at X+2. This is not logging in the developer sense. This is the &lt;code&gt;Order&lt;/code&gt; remembering its own history. Audit trails, reporting, and debugging are free consequences of a model that is honest about time.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;OrderService&lt;/code&gt; that knows the steps. There is no &lt;code&gt;OrderProcessor&lt;/code&gt; that coordinates the flow. What is often called orchestration is simply the &lt;code&gt;Order&lt;/code&gt;'s own behavior, waiting to be claimed.&lt;/p&gt;




&lt;h2&gt;
  
  
  There Is No Such Thing as Orchestration
&lt;/h2&gt;

&lt;p&gt;"Orchestration" is a concept that appears when objects are not carrying enough responsibility. The argument is that some flows are too complex to live in any single object, that something external must coordinate. But this argument always rests on the same foundation: the objects being coordinated are anemic. They cannot coordinate themselves because they hold no behavior.&lt;/p&gt;

&lt;p&gt;The stronger claim is this: orchestration is a business process, and every business process has an owner. The moment you ask "whose responsibility is this flow?" the answer is always a named thing in the business. Named things in the business belong in the domain model.&lt;/p&gt;

&lt;p&gt;If the checkout flow belongs to &lt;code&gt;Order&lt;/code&gt;, there is no orchestration — only an object doing its job. If a more complex cross-domain process exists, the business has a name for it. That name is your object.&lt;/p&gt;

&lt;p&gt;The workflow engine question resolves the same way. A workflow engine is infrastructure for implementing an unmodelled requirement. It allows a business process to be encoded without ever being understood. The process runs, tickets close, and the pressure to model never arrives. Meanwhile the process becomes invisible — it lives in configuration, not in the domain, and the model no longer reflects reality.&lt;/p&gt;

&lt;p&gt;By making the process explicit in the model, you force the understanding upfront. Traceability, accountability, and auditability are not bolted on afterward — they are natural consequences of a process that is owned and expressed. And the model becomes resistant to casual change. A workflow engine can be reconfigured quietly. A domain object that explicitly models a process requires intentional change. You must touch the model. That is not a constraint — it is a feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture That Emerges
&lt;/h2&gt;

&lt;p&gt;When the domain model is honest and complete, the architecture that surrounds it becomes remarkably simple.&lt;/p&gt;

&lt;p&gt;The domain is the center. Everything else is translation. An adapter takes an external signal — an HTTP request, a queue message, a UI event, a file drop — translates it into something the domain understands, and translates the response back. Whether that adapter is called a web service, a UI connector, or a queue client is an implementation detail. Its functional purpose is always the same: adapt an external request to the domain, and an answer from the domain to the outside world.&lt;/p&gt;

&lt;p&gt;This framing eliminates the need for many patterns that exist only because the domain is not carrying its weight. There is no need for a dependency injection container to wire together a domain that is self-contained. There is no need for a repository pattern when persistence is an annotation on the domain object that requires it. There is no layered architecture to enforce when the boundary between domain and adapter is conceptual and obvious.&lt;/p&gt;

&lt;p&gt;The complexity budget is spent entirely on essential complexity, because there is nowhere for accidental complexity to hide. Every technology choice can be evaluated against a single question: does a domain object require this? If not, it has no business being there. The domain model is not just a design tool — it is the justifier for every architectural decision, the brake on over-engineering, and the answer to YAGNI grounded not in gut feel but in domain reasoning.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Principle Underneath
&lt;/h2&gt;

&lt;p&gt;There is a principle that connects everything above:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The ease of implementing something without modeling it is proportional to the hidden cost of never having modeled it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every approach that starts from &lt;em&gt;how&lt;/em&gt; rather than &lt;em&gt;what&lt;/em&gt; — procedural scripts, transaction-script architectures, use-case driven development — shares this characteristic. The requirement is the input, the implementation is the output, and the domain never appears. Each new requirement starts from scratch, because there is no accumulated understanding to build on. The codebase grows. The knowledge does not.&lt;/p&gt;

&lt;p&gt;A rich domain model inverts this entirely. The domain is the input. Requirements are queries against that understanding. New requirements find their place in something that already exists — or reveal, through the friction of not fitting, that the domain needs to grow. Either way, understanding accumulates. The model becomes more true over time, not less.&lt;/p&gt;

&lt;p&gt;That is what a rich domain model is. Not a pattern. Not a layer. A discipline of making the essential complexity of a business explicit, owned, and honest — and letting everything else follow from that.&lt;/p&gt;







&lt;h2&gt;
  
  
  Sidebar: On AI-Assisted Development
&lt;/h2&gt;

&lt;p&gt;AI is genuinely useful in a domain-centric codebase — for implementing adapters, generating boilerplate, and accelerating everything that surrounds the model. It pattern-matches well against known structures, and once the domain is understood, there is plenty of that work to do.&lt;/p&gt;

&lt;p&gt;Domain modeling is a different activity. It requires understanding what the business actually is — not just what a ticket describes. It requires recognizing when a concept is missing, resisting the obvious implementation in favor of the correct abstraction, and making judgment calls about responsibility that have no objectively correct answer. AI has no access to the lived understanding that produces those judgments.&lt;/p&gt;

&lt;p&gt;The most useful role for AI in a modeling context is as a mirror — a Socratic partner for stress-testing a hypothesis about a concept's responsibility or boundary. It surfaces objections, identifies gaps, and forces precision. That is valuable. But the modeling itself remains a human activity, and the discipline of doing it remains more important in an AI-assisted world, not less. Without the model, AI produces procedural code at unprecedented speed — and accumulates the hidden cost of unmodeled requirements faster than any previous approach.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sidebar: On Practical Effects
&lt;/h2&gt;

&lt;p&gt;The common perception is that a rich domain model requires heavy upfront investment — that you must design everything before writing any code, and that this slows delivery. In practice the opposite is true, and the gap becomes visible quickly.&lt;/p&gt;

&lt;p&gt;Early in a project, a team building a rich domain model is establishing core concepts and their responsibilities. This feels slower than a team wiring up framework configuration and generating boilerplate. But by the time the first meaningful features are being built, the domain team is adding behavior to objects that already understand the business. New requirements find their place. The model tells you where things belong. The other team is asking "where does this code go?" for every new feature — and the answers are becoming less consistent, not more.&lt;/p&gt;

&lt;p&gt;The acceleration compounds. Maintenance is cheaper because the model is the documentation — it cannot go stale, because it is the code. Debugging is faster because the model expresses business intent, not just technical state. The difference between "the Order refused shipment because it was already delivered" and "some process node returned an unexpected status" is the difference between understanding and archaeology.&lt;/p&gt;

&lt;p&gt;The people costs tell the same story. Onboarding a developer onto a well-modeled domain takes hours, not months. The knowledge is in the model, not in the heads of the people who built it. That is not just an efficiency gain — it is a risk reduction. The bus factor of an application with an explicit domain model is structurally higher than one without.&lt;/p&gt;

&lt;p&gt;The cost of not modeling is real, large, and almost never measured — because there is no comparable version of the same application where it was modeled. You cannot see the cost of understanding you never accumulated. You only feel it, gradually, in every feature that takes longer than it should, every bug that touches more than it should, and every developer who leaves taking knowledge that was never made explicit.&lt;/p&gt;







&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;p&gt;This article is part of a series on domain-centric thinking.&lt;/p&gt;

&lt;p&gt;If this raised the question of &lt;em&gt;how to start modeling&lt;/em&gt; — how to discover the actors, assign the roles, and run a discovery session before a line of code is written — that is covered in &lt;a href="https://blog.leonpennings.com/rich-domain-models-start-with-what-is-not-what-happens" rel="noopener noreferrer"&gt;[Rich Domain Models: Start with What Is, Not What Happens]&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to see a domain model grow through concrete examples — how a real model evolves as understanding deepens, and what it means to let a new requirement reshape the model rather than just add to it — that is covered in &lt;a href="https://blog.leonpennings.com/rich-domain-modelling-a-library-story" rel="noopener noreferrer"&gt;[Rich Domain Models: A Library Story]&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The concepts in this article reflect practical experience building domain-centric applications. The&lt;/em&gt; &lt;code&gt;Interaction&lt;/code&gt; &lt;em&gt;pattern described has been in production use since 2009.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ddd</category>
      <category>softwaredevelopment</category>
      <category>java</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Architecture Tax — Why Enterprise Software Is Expensive, and Why AI Won't Fix It</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Mon, 18 May 2026 07:38:59 +0000</pubDate>
      <link>https://forem.com/leonpennings/the-architecture-tax-why-enterprise-software-is-expensive-and-why-ai-wont-fix-it-57ek</link>
      <guid>https://forem.com/leonpennings/the-architecture-tax-why-enterprise-software-is-expensive-and-why-ai-wont-fix-it-57ek</guid>
      <description>&lt;h2&gt;
  
  
  The story the industry tells
&lt;/h2&gt;

&lt;p&gt;Enterprise software is expensive. It requires large teams, significant infrastructure, complex deployment pipelines, and sustained operational effort. Requirements that sound simple take weeks. Systems that should be stable require constant attention. The codebase that was coherent at year one is opaque by year four. New developers take months to become productive. Changes that touch multiple parts of the system require coordination that absorbs more time than the implementation itself.&lt;/p&gt;

&lt;p&gt;This is treated as a given. Enterprise software is complex, therefore it costs what it costs. The architecture — microservices, distributed infrastructure, containerised deployments, orchestration layers — is presented as the response to that complexity. Sophisticated problems require sophisticated solutions.&lt;/p&gt;

&lt;p&gt;The argument this article makes is the opposite.&lt;/p&gt;

&lt;p&gt;Most of what the industry calls the cost of enterprise software is not the cost of the domain. It is the cost of workarounds for a missing domain model — compounded over years, normalised by the fact that every team around you is paying the same price and calling it inevitable. The architecture is not the response to the complexity. In most cases, it is the cause of it.&lt;/p&gt;

&lt;p&gt;And the reason this remains invisible is that the alternative was never built. You cannot compare your system to the system that does not exist. So the costs accumulate, get attributed to the nature of enterprise software, and become the baseline against which all future decisions are made.&lt;/p&gt;

&lt;p&gt;This article is about what is actually in that price tag, and what it would cost without it.&lt;/p&gt;




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

&lt;p&gt;When a team starts building a system, the code is small. The domain is not yet fully understood, but the surface area is manageable. A developer can hold the whole thing in their head. A new feature means adding a function. The system works. Nobody is in pain.&lt;/p&gt;

&lt;p&gt;Three years later, the same team — or more likely, a partially replaced team — is asking a different question. Not "does this work?" but "where does this live?" Where does the discount calculation happen? Who owns the rule that a cancelled order cannot be reinstated after shipment? If we change how rush orders are priced, how many places do we need to touch, and how many of those will we miss?&lt;/p&gt;

&lt;p&gt;These are not questions about the business domain. The business domain has not become harder. An order is still an order. The questions are about the system — specifically, about where the system chose to put things, and whether that choice was made deliberately or simply accumulated over time.&lt;/p&gt;

&lt;p&gt;This is the context problem. It is the root cause of most of the complexity that teams eventually reach for distributed architectures to solve. And it has nothing to do with the scale or ambition of the domain. It is a structural property of how the code was organised from the beginning.&lt;/p&gt;

&lt;p&gt;Context, in the sense used here, has a specific meaning. It is not a folder, a module name, or a service boundary. It is the answer to a structural question: &lt;em&gt;given a concept in the domain, is there one authoritative location where all rules governing that concept are defined and enforced?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A concept has a context when the answer is yes. It does not have a context when the answer is "it depends" — or "mostly here, but also there, and that other place handles the exception."&lt;/p&gt;

&lt;p&gt;The distinction matters because systems do not stay small. Rules accumulate. Exceptions are added. Behaviour that was simple in year one becomes conditional in year two and contradictory in year three. In a system with clear context ownership, that accumulation is manageable — the rules are in one place, contradiction is visible, and the design either holds or signals clearly that it needs to change. In a system without context ownership, accumulation is invisible until it becomes crisis.&lt;/p&gt;




&lt;h2&gt;
  
  
  Object orientation was supposed to solve this
&lt;/h2&gt;

&lt;p&gt;The context problem is not new. It is precisely the problem that object-oriented programming was designed to address.&lt;/p&gt;

&lt;p&gt;Object orientation, in its original conception, was not about classes, inheritance hierarchies, or design patterns. It was about a single structural idea: that data and the rules governing that data belong together, in one place, unreachable from outside except through defined behaviour. An object is not a container for data with methods attached. It is a context — a thing that knows its own state, enforces its own rules, and decides what to do when asked. The outside world cannot manipulate its internals. It can only send messages.&lt;/p&gt;

&lt;p&gt;This is context ownership as a structural property of the code. Logic cannot drift to wherever it is convenient to put it, because the object's state is private. The rule that a shipped order cannot be cancelled does not live in a service method that someone has to know to call. It lives on the order itself, enforced by the fact that the order's status cannot be changed except through the order's own behaviour. It is not a convention. It is a constraint.&lt;/p&gt;

&lt;p&gt;This is what object orientation was for.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Java enterprise actually practises
&lt;/h2&gt;

&lt;p&gt;The dominant pattern in Java enterprise development — and in enterprise development more broadly — looks like this:&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;Order&lt;/code&gt; entity holds fields annotated for persistence. Its fields are private, which gives the appearance of encapsulation. An &lt;code&gt;OrderService&lt;/code&gt; contains the business logic — the methods that create, modify, and query orders. An &lt;code&gt;OrderRepository&lt;/code&gt; handles the database interaction. Data transfer objects carry information between layers.&lt;/p&gt;

&lt;p&gt;This pattern is widely understood to be object-oriented. It uses objects. It has private fields. It has classes with clear names and single responsibilities. Senior developers teach it. Frameworks are built around it. It is the default.&lt;/p&gt;

&lt;p&gt;It is procedural programming.&lt;/p&gt;

&lt;p&gt;The test is not whether the code uses classes. The test is whether data and the rules governing that data are in the same place. In the service-DTO-repository pattern, they are not. The &lt;code&gt;Order&lt;/code&gt; entity holds data. The &lt;code&gt;OrderService&lt;/code&gt; holds logic. The logic is separated from the data it governs. That is the definition of procedural code — regardless of the language, regardless of the annotations, regardless of the private keyword on the fields.&lt;/p&gt;

&lt;p&gt;The private fields are not encapsulation in any meaningful sense. Encapsulation means the object protects its own invariants. Nothing outside can put it in an invalid state. But if &lt;code&gt;OrderService&lt;/code&gt; loads an &lt;code&gt;Order&lt;/code&gt;, inspects its fields, and decides what to do — the private keyword is decoration. The order is a struct. The service is a function that operates on it. The fact that both are expressed as classes changes nothing about the structure.&lt;/p&gt;

&lt;p&gt;A senior developer once described object orientation as "just using a lot of objects." In the Spring ecosystem, that description is accidentally accurate. The objects are present. The orientation — the structural commitment to context ownership — is not.&lt;/p&gt;

&lt;p&gt;This matters because it means most teams believe they are already doing what a rich domain model offers. The gap between what they believe and what is actually true is where the context problem silently grows — invisible, until it becomes the thing that makes the system expensive.&lt;/p&gt;




&lt;h2&gt;
  
  
  How a procedural system rots
&lt;/h2&gt;

&lt;p&gt;The rot does not happen at once. It has a characteristic progression that is worth tracing, because understanding the mechanism is what makes the solution legible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Year one.&lt;/strong&gt; The system is small. The team is mostly the original team. The rules fit in one or two services. &lt;code&gt;OrderService&lt;/code&gt; is coherent because it is young and the domain is still understood by everyone who touches it. Velocity is high. The architecture feels like a good decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Year two.&lt;/strong&gt; The product grows. New rules are added. The team adds members who know the services they own but not the full picture. A pricing exception is added in &lt;code&gt;OrderService&lt;/code&gt; because that is where the original pricing logic lives. A second exception is added in &lt;code&gt;PricingService&lt;/code&gt; because by then the first developer has left and the new one reasonably concluded that pricing rules belong in the pricing service. Both are correct by local reasoning. Neither is aware of the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Year three.&lt;/strong&gt; The team is running two integration tests that cover the same scenario and produce different results depending on which code path is invoked. A bug report arrives: under certain conditions, the price shown to the customer differs from the price on the invoice. Three services are involved in producing those two numbers. The fix requires coordinating changes across all three, understanding the original intent of logic nobody wrote, and ensuring that the correction does not break the scenarios the divergent logic was accidentally handling correctly.&lt;/p&gt;

&lt;p&gt;This is not a failure of discipline. The developers are competent. It is the structural consequence of a system that provided no home for rules — so rules went wherever they were needed, and the system slowly became a map of historical decisions rather than a coherent model of the domain.&lt;/p&gt;

&lt;p&gt;No amount of discipline permanently solves a structural problem. Discipline degrades over time and team turnover. Structure does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rich domain model as structural answer
&lt;/h2&gt;

&lt;p&gt;A rich domain model addresses the context problem through structure, not discipline.&lt;/p&gt;

&lt;p&gt;The principle is simple: an object owns the rules that govern its own state, and state changes happen only through that object's behaviour. An &lt;code&gt;Order&lt;/code&gt; does not have its price calculated by a service. An &lt;code&gt;Order&lt;/code&gt; knows its price — it is a property of the order, derived from the order's own data and the rules encoded in the order's own methods. The service does not reach in and manipulate the order's internals. It asks the order to do something, and the order either does it according to its rules, or refuses.&lt;/p&gt;

&lt;p&gt;Consider an order system modelled this way:&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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&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;OrderLine&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lines&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="nc"&gt;OrderStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ShippingMethod&lt;/span&gt; &lt;span class="n"&gt;shippingMethod&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;Money&lt;/span&gt; &lt;span class="nf"&gt;calculatePrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&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;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OrderLine:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Money:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;add&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;shippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;confirm&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DRAFT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Only draft orders can be confirmed."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFIRMED&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SHIPPED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Shipped orders cannot be cancelled."&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CANCELLED&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;The rule that a shipped order cannot be cancelled lives on the &lt;code&gt;Order&lt;/code&gt;. Not in &lt;code&gt;OrderService&lt;/code&gt;, not in a validator upstream, not in a flag checked somewhere in the call chain. It lives in the only place it could coherently live: the object that owns the concept. A developer three years from now, touching this code for the first time, cannot accidentally bypass that rule — not because the system trusts their discipline, but because the structure does not give them a way to.&lt;/p&gt;

&lt;p&gt;The service that orchestrates this is correspondingly simple:&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="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reserve&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database transaction is the failure boundary. If anything fails, nothing happened. There are no compensating calls, no saga steps, no partial states to reconcile. The infrastructure serves the domain. The domain is not distorted to accommodate the infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design pressure as a feature
&lt;/h2&gt;

&lt;p&gt;There is a property of the rich domain model that is easy to overlook: it makes bad design visible before it becomes operational pain.&lt;/p&gt;

&lt;p&gt;When a new rule is added that does not fit cleanly — when a developer sits down to implement something and cannot find a natural home for it in the model — that is not an inconvenience. It is a signal. The model is telling you that either the rule is being misunderstood, or the model needs to evolve to accommodate a concept it does not yet represent.&lt;/p&gt;

&lt;p&gt;In a procedural system, that signal does not fire. The developer adds a condition to an existing service method, or adds a new service if the feature is large enough. The rule is implemented. It works. The fact that it created divergence from an existing rule, or that it sits awkwardly between two existing concepts, is not visible until months later when something breaks in a way that requires archaeology to understand.&lt;/p&gt;

&lt;p&gt;The rich model converts architectural drift from a silent accumulation into an explicit design question. That question is not always comfortable. But discomfort at design time costs a discussion. Discomfort at runtime costs an incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  The business changed. As it always does.
&lt;/h2&gt;

&lt;p&gt;The system above handles standard orders. The domain is coherent. The rules are clear. Now the business introduces a new requirement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rush orders.&lt;/strong&gt; A customer can request expedited fulfilment. This attracts a surcharge — the order price increases by fifteen percent, and the shipping method is upgraded to express.&lt;/p&gt;

&lt;p&gt;In a procedural system, this requires touching multiple places. The pricing calculation needs a condition. The shipping assignment needs a condition. If those live in different services, both need to change, both need to be deployed, and the rule "rush orders cost fifteen percent more and ship express" exists nowhere as a statement. It exists as a set of conditional branches distributed across the system.&lt;/p&gt;

&lt;p&gt;In the rich domain model, the question the implementation forces you to answer is: &lt;em&gt;what is a rush order?&lt;/em&gt; Is it a type of order? A property? Does it affect the order itself or its fulfilment? Answering that question is the design. And the answer produces something like:&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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&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;OrderLine&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lines&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;rush&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;ShippingMethod&lt;/span&gt; &lt;span class="n"&gt;shippingMethod&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&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="n"&gt;request&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="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isRush&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shippingMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rush&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;ShippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;EXPRESS&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ShippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;STANDARD&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;Money&lt;/span&gt; &lt;span class="nf"&gt;calculatePrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&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;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OrderLine:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Money:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;withShipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&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;rush&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;withShipping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiplyBy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;withShipping&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;The rule lives on the &lt;code&gt;Order&lt;/code&gt;. It cannot live anywhere else. Every developer who touches order pricing in the future will find it here, because there is only one place to look.&lt;/p&gt;




&lt;h2&gt;
  
  
  The business changed again.
&lt;/h2&gt;

&lt;p&gt;Three weeks after the rush order feature ships, a new requirement arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VIP customers do not pay the rush surcharge.&lt;/strong&gt; The expedited shipping still applies — VIPs get the faster fulfilment — but the fifteen percent price increase is waived as a benefit of their status.&lt;/p&gt;

&lt;p&gt;This requirement is three sentences of business logic. What it does to a system without context ownership is disproportionate to its size.&lt;/p&gt;

&lt;p&gt;In a procedural system, the question is: &lt;em&gt;where does this condition go?&lt;/em&gt; The rush surcharge is currently in — actually, let us retrace that. The original pricing was in &lt;code&gt;OrderService&lt;/code&gt;. The rush surcharge was added in &lt;code&gt;PricingService&lt;/code&gt; because that seemed more appropriate for a pricing concern. The VIP status lives in &lt;code&gt;CustomerService&lt;/code&gt;. A rule that says "apply the surcharge unless the customer is a VIP" now requires either a call from &lt;code&gt;PricingService&lt;/code&gt; to &lt;code&gt;CustomerService&lt;/code&gt; — coupling two services that were not coupled before — or an orchestration layer that assembles the inputs before calling either, or a flag passed through the call chain from wherever the customer is known to wherever the pricing happens, leaking context across layers that should not share it.&lt;/p&gt;

&lt;p&gt;Each of these is a workaround. Each adds a seam. And each seam is a place where, two years from now, someone adds another condition, and the question "what does this order actually cost?" requires reading four services to answer.&lt;/p&gt;

&lt;p&gt;In the rich domain model, the question is different and better: &lt;em&gt;who owns the rule that VIP customers are exempt from the rush surcharge?&lt;/em&gt; Is it the Order? The Customer? A pricing policy?&lt;/p&gt;

&lt;p&gt;This is a domain design question. It has a defensible answer:&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;public&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="nf"&gt;calculatePrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&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;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OrderLine:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Money:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;withShipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rush&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="na"&gt;isVip&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;withShipping&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiplyBy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.15&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;withShipping&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;The rule is in one place. It reads as a statement of business intent. It is testable in isolation. When the next requirement arrives — "VIP customers also get free express shipping on rush orders over two hundred euros" — the developer knows exactly where to go, and the existing logic tells them exactly what the current rules are.&lt;/p&gt;

&lt;p&gt;If the pricing logic grows complex enough, the model signals it:&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;public&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="nf"&gt;calculatePrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lines&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;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OrderLine:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lineTotal&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reduce&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ZERO&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;Money:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="n"&gt;withShipping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shippingMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PricingPolicy&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="na"&gt;apply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;withShipping&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&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;The complexity of &lt;code&gt;calculatePrice&lt;/code&gt; has surfaced a new concept: a &lt;code&gt;PricingPolicy&lt;/code&gt;. Not because a framework required it, not because a service boundary forced it, but because the model told you that pricing rules had become rich enough to deserve their own home. This is design evolution driven by the domain — the right kind of complexity, appearing at the right time, for the right reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  The distributed workaround
&lt;/h2&gt;

&lt;p&gt;Teams that build procedural systems eventually hit the context problem at scale. Logic is spread across a growing codebase with no clear ownership. Rules diverge. The system becomes expensive to change. The industry's standard response is to enforce context through service boundaries. Order rules live in the Order service. Pricing rules live in the Pricing service. The boundary makes it structurally difficult for one service to reach into another's domain.&lt;/p&gt;

&lt;p&gt;This is attempting, through infrastructure, to solve a problem that a domain model solves through structure.&lt;/p&gt;

&lt;p&gt;The intuition is understandable. The result is a workaround that costs more than the problem it replaces.&lt;/p&gt;

&lt;p&gt;Consider what the VIP rush exemption requires in a distributed system. The Order service needs to price a rush order for a VIP customer. It cannot reach into the Pricing service's data — that violates the boundary. So it calls the Pricing service. But the Pricing service needs to know whether the customer is a VIP — and the Customer service owns that. Now the services are coupled in ways the original boundary was meant to prevent, or an orchestration layer is required to assemble inputs before calling either service, or an event-driven flow is constructed in which services react to each other asynchronously — introducing eventual consistency, message ordering concerns, and a debugging surface that spans multiple log streams.&lt;/p&gt;

&lt;p&gt;And this is before considering what happens when the action fails halfway through.&lt;/p&gt;

&lt;p&gt;In a monolith with a rich domain model, failure costs a database rollback. One word. The action either completed or it did not. There is no intermediate state. There is no question of what to clean up.&lt;/p&gt;

&lt;p&gt;In the distributed system, there is no transaction. If the order is created but the pricing service fails before responding, the system is in a partial state. That partial state must be resolved — not by the database, which knows nothing about it, but by compensating logic: a designed, implemented, tested, and maintained sequence of calls that undoes the steps that completed before the failure. For four services, the failure paths grow as O(n²). Each compensation is a domain operation that must be reachable, idempotent, and tested both in isolation and in combination.&lt;/p&gt;

&lt;p&gt;Before any of this business logic runs, the infrastructure required to support it exists permanently: a message broker, a saga framework or hand-rolled saga state table, distributed tracing with correlation IDs propagated through every service and every event envelope, an idempotency layer in every service because message brokers guarantee at-least-once delivery, API contracts and versioning because a breaking schema change is a production incident in every downstream service, and per-service CI/CD pipelines, databases, and operational overhead — multiplied by the number of services.&lt;/p&gt;

&lt;p&gt;None of this delivers business value. All of it exists solely to reconstruct, at permanent cost, the properties that a single database transaction provided for free: atomicity, consistency, rollback on failure, and a single coherent answer to what just happened.&lt;/p&gt;

&lt;p&gt;The VIP rush exemption — three sentences of business requirement — now requires coordinating across three services, with asynchronous event flows, compensating transactions, and a debugging surface that no single developer can hold in their head.&lt;/p&gt;

&lt;p&gt;The Russian space program used a pencil.&lt;/p&gt;




&lt;h2&gt;
  
  
  The refactorability that distribution destroys
&lt;/h2&gt;

&lt;p&gt;There is a cost of microservices that receives less attention than sagas and eventual consistency, but which compounds more severely over time: the loss of refactorability.&lt;/p&gt;

&lt;p&gt;In a rich domain model, a refactoring is a restructuring of code within a coherent boundary. If &lt;code&gt;PricingPolicy&lt;/code&gt; needs to become its own concept, the compiler identifies every place that needs to change. You make the changes, run the tests, deploy. The refactoring is complete.&lt;/p&gt;

&lt;p&gt;In a distributed system, a refactoring that touches a service contract is a migration. The event schema consumed by downstream services cannot simply change — it requires a versioning strategy, a migration window, a period of running old and new schemas simultaneously, and coordination across teams who own the downstream consumers. The boundary introduced to enforce ownership has become a fossilised contract. The ownership is preserved. The ability to evolve is not.&lt;/p&gt;

&lt;p&gt;This is the trade that distribution forces: you gain enforcement of service boundaries, and you lose the ability to change them cheaply. In a domain that is still being understood — which is most domains, for most of their lifetime — that trade is almost always wrong. The boundaries drawn at year one reflect year-one understanding. The domain will teach you things in year two that make those boundaries look naive. In a monolith with a rich domain model, you redraw the boundary and the compiler helps you. In a distributed system, you live with it, or you pay the migration cost. Most teams live with it. The boundaries fossilise. The system carries the imprint of how the domain was understood at its beginning, permanently.&lt;/p&gt;




&lt;h2&gt;
  
  
  When distribution is genuinely warranted
&lt;/h2&gt;

&lt;p&gt;Distribution has legitimate use cases. They share a common property: they are external constraints on the system, not assessments of the current domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proven, asymmetric load.&lt;/strong&gt; When one component has a demonstrably different scaling profile — proven by measurement under real conditions, not anticipated in theory — isolating it may be warranted. The question is not "could this theoretically need more scale?" It is "is this the measured bottleneck today, and does the cost of isolation exceed the cost of scaling the whole?" In most systems, no individual component is the bottleneck. The constraint is the atomic action as a whole. Scaling the whole is cheaper and simpler than the industry assumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Physical or regulatory constraints.&lt;/strong&gt; When data must remain within a specific jurisdiction by law, geographic distribution is warranted. The right approach is to deploy a complete instance of the domain within that boundary — not to split the domain action across a jurisdictional boundary. The atomic action stays atomic. The domain model stays unified. What changes is the deployment target, not the architecture.&lt;/p&gt;

&lt;p&gt;Notice what is absent from this list: &lt;em&gt;domain concepts that currently appear independent.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Independence is a present-tense assessment of a future-tense system. Two concepts that have no transactional relationship today may acquire one tomorrow when a requirement arrives that neither anticipated. A recommendation engine and a payment processor appear independent until the business introduces a rule that links them. When that happens in a rich domain model, you answer a design question. When it happens in a distributed system, you face a migration — or you violate the boundary with a coupling that was supposed to be impossible, and accumulate the technical debt of a boundary that no longer reflects reality.&lt;/p&gt;

&lt;p&gt;Distribution should be warranted by constraints that are immune to domain evolution. Load and regulatory geography qualify. Current domain independence does not. It is a prediction dressed as a structural justification, and systems that are built on predictions about domain shape tend to look naive by the time they are old enough to evaluate.&lt;/p&gt;




&lt;h2&gt;
  
  
  The modelling capability problem
&lt;/h2&gt;

&lt;p&gt;A rich domain model does not build itself. It requires developers who can model — who can look at a domain, identify the concepts, understand their rules, and express those rules in objects that own them. This is a different skill from implementing features in a service layer. It is rarer, harder to teach, and not well served by the frameworks and patterns that dominate enterprise Java development.&lt;/p&gt;

&lt;p&gt;This is worth stating honestly, because it is the most common objection to everything argued above. "In theory, yes — in practice, we don't have the developers who can do this."&lt;/p&gt;

&lt;p&gt;The objection is real. But it is also a consequence of the same feedback loop. The industry has spent two decades building curricula, frameworks, and hiring pipelines around the service-DTO-repository pattern. Developers trained on Spring Boot are trained to think in services and data flows, not in domain concepts and object behaviour. The modelling skill atrophied because the dominant patterns did not require it — and then its absence became a justification for patterns that do not require it.&lt;/p&gt;

&lt;p&gt;The distributed architecture does not require modelling capability. It requires operational capability — the ability to manage brokers, sagas, contracts, and deployment pipelines. Those skills are available. They are well-documented. They are what the frameworks teach. So the distributed system gets built, not because it is the right architecture, but because it is the one the available skills support.&lt;/p&gt;

&lt;p&gt;What the industry normalised as "enterprise development" is, in significant part, the consequence of this skills gap and the infrastructure that grew up around it. The expensive architecture is the one that does not require the harder skill. The cheaper architecture — cheaper in every long-term dimension — requires developers who can model. Cultivating that capability is a different investment from buying more infrastructure. But it is the one with the compounding return.&lt;/p&gt;




&lt;h2&gt;
  
  
  But AI will fix this
&lt;/h2&gt;

&lt;p&gt;The most current version of the objection to everything argued above is not about developer skill. It is about AI coding tools. The argument runs: with AI assistance, the cost of writing procedural code drops dramatically. Features are generated in minutes. Boilerplate disappears. The velocity problem that made structural discipline seem expensive is solved by the tool. So the modelling skill gap does not matter — AI fills it.&lt;/p&gt;

&lt;p&gt;This is a plausible argument for small systems at early stages. It does not survive contact with the actual problem.&lt;/p&gt;

&lt;p&gt;AI coding tools are, in their current form, genuinely impressive at procedural implementation. Describe a feature clearly and the tool produces technically correct, well-structured code, fast. But the tool does not hold the domain. It holds the prompt. It implements what the prompt describes, in whatever pattern the surrounding codebase suggests — which in most enterprise codebases means a service method, a DTO, and a repository call. The implementation is correct with respect to the request. Whether it is consistent with the system's existing rules is a different question, and one the tool is structurally unable to answer reliably.&lt;/p&gt;

&lt;p&gt;The contradiction arrives quietly. In January, a developer prompts: "add a fifteen percent surcharge for rush orders." The AI implements it, correctly, in &lt;code&gt;PricingService&lt;/code&gt;. In March, a different developer prompts: "VIP customers should not pay extra for rush orders." The AI implements that too, correctly, somewhere in the call chain — perhaps in &lt;code&gt;OrderService&lt;/code&gt;, where the customer context is available. Both implementations are technically sound. Neither developer intended a contradiction. The AI had no way to know one existed, because the domain has no center. The rule "what does a rush order cost?" is not owned by anything. It is distributed across the history of prompts that touched it.&lt;/p&gt;

&lt;p&gt;In a rich domain model, this contradiction surfaces immediately. Both rules must live on &lt;code&gt;Order&lt;/code&gt;. When the second developer — or the AI they are directing — goes to implement the VIP exemption, the rush surcharge is already there, visible, in the same method. The conflict is structural and immediate. The developer makes a decision. The model is updated. The system reflects the current understanding of the business.&lt;/p&gt;

&lt;p&gt;In a procedural system, the conflict is invisible until a customer receives a price that is neither the intended standard price, nor the intended VIP price, but an artifact of two implementations that never knew about each other.&lt;/p&gt;

&lt;p&gt;There is a counterargument worth taking seriously: AI tools with sufficient codebase context — through large context windows, retrieval-augmented generation, or persistent memory across sessions — could theoretically detect such contradictions before implementing. Some tools already attempt this. The counterargument is real, and it would be wrong to dismiss it entirely.&lt;/p&gt;

&lt;p&gt;But even if the AI detects the contradiction, it cannot resolve it. The question "should VIP customers pay the rush surcharge?" is not answerable by reading the codebase. It is a business decision. The AI can surface the conflict. It cannot determine which rule reflects the current intent of the business, which rule is outdated, or whether both should coexist under different conditions. That requires domain understanding — and domain understanding requires a human with a model, not a tool with a context window.&lt;/p&gt;

&lt;p&gt;What the rich domain model provides is not a barrier to AI assistance. It is the structure that makes AI assistance most effective. When the domain is explicit, concepts are well-named, and rules are owned by the objects they govern, AI-generated code within that model tends to be good — because the model itself provides the context the AI needs to generate correctly. The right place to put a new rule is unambiguous. The existing rules are co-located and readable. The AI operates within a structure that guides it toward coherent output.&lt;/p&gt;

&lt;p&gt;The deeper issue is velocity. Procedural systems accumulate drift gradually, over years, as developers add logic wherever it is convenient. AI-assisted development does not change the direction of that drift. It changes the speed. What used to take three years of incremental addition now takes months of accelerated feature generation. The same structural absence of context ownership, at an order of magnitude higher throughput. The codebase grows faster than any team's ability to understand it, and the AI has no understanding to compensate with — only pattern matching against what is already there.&lt;/p&gt;

&lt;p&gt;AI does not fix the context problem. In a system without a domain model, it compounds it. The same rot, faster. The same contradictions, earlier. The same invisible price tag, arriving sooner.&lt;/p&gt;

&lt;p&gt;What AI changes is the cost of implementation. What it does not change — what nothing changes — is that implementation without structure is the most expensive kind. The structure has to come first. The model has to exist before the tool can be trusted to work within it. AI is a powerful accelerant. The question, as always, is what it is accelerating toward.&lt;/p&gt;




&lt;h2&gt;
  
  
  The invisible price tag
&lt;/h2&gt;

&lt;p&gt;Consider what a mature enterprise system built on microservices actually costs, outside the domain work itself.&lt;/p&gt;

&lt;p&gt;A containerised infrastructure running tens or hundreds of services. An orchestration layer — Kubernetes or equivalent — with its own operational model, upgrade cycle, and expertise requirement. A message broker cluster maintained for high availability. A distributed tracing stack. A log aggregation platform, because individual service logs are unreadable without one. A schema registry and contract testing infrastructure. Per-service CI/CD pipelines, each with its own configuration, deployment windows, and rollback strategy. An on-call rotation that covers distributed failure modes — partial outages, broker lag, compensation failures — that do not exist in a single-process system. A platform or infrastructure team whose entire function is to keep the operational substrate running.&lt;/p&gt;

&lt;p&gt;None of this is the domain. None of it delivers business value. All of it is the permanent operational cost of workarounds for missing context ownership.&lt;/p&gt;

&lt;p&gt;Now consider the same domain in a well-modelled monolith. A small number of deployable artefacts — perhaps one, perhaps a handful if genuine load asymmetry has been measured and justified. A relational database. A load balancer. Standard application monitoring. A CI/CD pipeline that deploys the whole. An on-call rotation that reads stack traces. The failure modes are the domain's failure modes, not the infrastructure's.&lt;/p&gt;

&lt;p&gt;The difference in team size, infrastructure cost, and operational overhead is not the cost of enterprise software. It is the cost of the workaround. The domain is the same. The business rules are the same. The problem being solved is the same. What differs is whether the system paid for a domain model or paid for the infrastructure required to simulate one.&lt;/p&gt;

&lt;p&gt;This difference is invisible in most organisations because the alternative was never built. The costs of the distributed system accumulate, get attributed to the scale and complexity of the enterprise domain, and become the benchmark against which new decisions are made. The next system is also built with microservices, because that is what enterprise software costs — and the incomparability between what was built and what could have been built means the attribution is never seriously questioned.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the rich domain model actually gives enterprise software
&lt;/h2&gt;

&lt;p&gt;The argument for the rich domain model in large enterprise systems is not that it is elegant or theoretically correct. It is that it is the mechanism by which enterprise software remains manageable over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oversight.&lt;/strong&gt; When every rule about an order lives on &lt;code&gt;Order&lt;/code&gt;, a developer can understand order behaviour by reading one place. Not by reconstructing a distributed flow across services, event schemas, and asynchronous reactions. One place. This is not a convenience — it is what makes oversight possible as the system grows. Without it, understanding the system requires understanding its history, because the structure no longer maps to the domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Insight.&lt;/strong&gt; A rich domain model makes the domain legible to the team. The concepts are explicit. The rules are expressed in the language of the domain, not buried in service method conditionals and event handler logic. A new developer can read the model and understand the business. A non-technical stakeholder can, with modest translation, verify that the model reflects their understanding. That legibility is not incidental — it is the mechanism by which teams catch misunderstandings before they become bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simplicity under growth.&lt;/strong&gt; A procedural system grows by addition — new services, new methods, new conditions. A rich domain model grows by evolution — concepts become richer, responsibilities shift, new objects emerge when the design signals they are needed. Evolution is guided by the model. Addition is guided by expediency. Over five years, the difference in the resulting codebase is not marginal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preserved optionality.&lt;/strong&gt; A well-modelled domain in a single deployable can be split later, when measurement proves a specific boundary is warranted. The model already knows its own concepts — the split follows the domain's natural lines, guided by evidence. A distributed system cannot be reassembled cheaply once contracts have fossilised and team ownership has hardened around service lines. The simple starting point preserves optionality. The complex starting point spends it immediately, in exchange for flexibility that may never be needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  First principles
&lt;/h2&gt;

&lt;p&gt;There is nothing novel in the argument this article makes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Structure your thinking before you structure your infrastructure.&lt;/em&gt; The question of where a rule lives is a question about the domain. Answer it in the domain — in the model, in the objects that own the concepts — before reaching for any infrastructure to enforce it. Infrastructure that enforces a boundary you have not yet thought through will enforce it permanently and expensively.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The location of a rule is part of the design.&lt;/em&gt; A rule in the right place is findable, testable, and changeable. A rule in the place that was convenient to add it becomes a historical artefact, discoverable only by reading the history of the system.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Complexity introduced to compensate for missing structure is the most expensive kind.&lt;/em&gt; It does not reduce over time. It compounds. Every saga that exists because a transaction boundary was removed, every contract that fossilises a year-one boundary decision, every service that owns zero domain concepts but exists to coordinate between services that do — these are permanent operational costs, paid every day, for the lifetime of the system.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What the industry calls the cost of enterprise software is largely the cost of not modelling.&lt;/em&gt; The infrastructure, the teams, the operational overhead — these are not the price of scale or complexity. They are the price of workarounds for a missing domain model, normalised by the fact that everyone around you is paying the same price and the alternative was never built to compare against.&lt;/p&gt;

&lt;p&gt;The rich domain model is not a technique for senior engineers on greenfield systems. It is the thing that makes enterprise software manageable at all — the only mechanism that preserves oversight, insight, and simplicity as a system grows. The alternative is the same complexity, without the structure to contain it, with an expensive distributed scaffolding erected around it to simulate the containment the model would have provided for free.&lt;/p&gt;

&lt;p&gt;Build the model. Let the model tell you where the rules live, when the design needs to evolve, and when — if measurement ever demands it — a boundary has genuinely earned the right to become a service.&lt;/p&gt;

&lt;p&gt;The model will not mislead you. The path of least resistance will.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>microservices</category>
      <category>distributedsystems</category>
      <category>oop</category>
    </item>
    <item>
      <title>Engineering a UI for a Java Backend: Maintainability, Longevity, and Why the Answer Might Surprise You</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Wed, 13 May 2026 07:59:01 +0000</pubDate>
      <link>https://forem.com/leonpennings/engineering-a-ui-for-a-java-backend-maintainability-longevity-and-why-the-answer-might-surprise-3m7p</link>
      <guid>https://forem.com/leonpennings/engineering-a-ui-for-a-java-backend-maintainability-longevity-and-why-the-answer-might-surprise-3m7p</guid>
      <description>&lt;p&gt;Most teams pick a UI framework the same way they pick a restaurant — by what is popular right now, what colleagues recommend, or what appeared at the top of a search result. This article takes a different approach: establish what a well-engineered UI for a Java backend actually needs to be, from first principles, and then see what framework honestly satisfies those requirements. The conclusion may not be what you expect.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: Where the Client Lives
&lt;/h2&gt;

&lt;p&gt;Before requirements, one distinction that frames everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side rendering:&lt;/strong&gt; the client lives on the server. The server maintains state, computes views, and pushes HTML to the browser. The browser is a display terminal. Every interaction is a round-trip. Network interruptions break the experience. Horizontal scaling requires session affinity or replication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fat client:&lt;/strong&gt; the client lives in the browser. It holds its own state, manages its own behaviour, and calls the server only when it needs data or needs to record an action. Server calls are as simple as API calls. The server is stateless. Network interruptions are survivable. Any server instance handles any request.&lt;/p&gt;

&lt;p&gt;This distinction is not a stylistic preference. It determines where state lives, how the system scales, how resilient the user experience is to infrastructure events, and what the server is actually responsible for. Everything that follows builds on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: The Requirements
&lt;/h2&gt;

&lt;p&gt;These requirements are not Java-specific preferences. They describe what any disciplined engineering team should want from a UI layer, regardless of backend language. Java is the context. The principles are universal.&lt;/p&gt;

&lt;p&gt;The architecture has three distinct layers, each with different skill requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Skills Required&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform / component&lt;/td&gt;
&lt;td&gt;Defines HTML structure, CSS, GWT wrappers&lt;/td&gt;
&lt;td&gt;Semantic HTML, CSS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Communication infrastructure&lt;/td&gt;
&lt;td&gt;Communication between browser and server&lt;/td&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature development&lt;/td&gt;
&lt;td&gt;Views, interactions, domain behaviour&lt;/td&gt;
&lt;td&gt;Java only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The requirements below apply to the architecture as a whole. The skill boundary is explicit: HTML and CSS expertise is required at the component layer, and only there. Feature developers — the majority of the team, doing the majority of the work — operate entirely in Java.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Frontend-Requirement-Down Design
&lt;/h3&gt;

&lt;p&gt;The UI should be designed from what the user needs to accomplish, not from what the backend domain model happens to look like. User interactions frequently span multiple backend domain objects. Designing upward from DTOs or entity shapes produces interfaces that reflect implementation details rather than user intent. The frontend is a peer application with its own concerns — not a projection of the server model.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Browser is the Client's Home
&lt;/h3&gt;

&lt;p&gt;The client must live in the browser. A fat client holds its own state, survives server restarts and transient network interruptions, and communicates with the server only when necessary. Client-side state is typed, structured, and available across the full session — without cookies, without server-side session objects, without distributed session infrastructure. The server is stateless. Scaling follows directly. This is not a performance preference — it is an architectural correctness preference with operational consequences that compound over the lifetime of the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Compile-Time Validation over Runtime Discovery
&lt;/h3&gt;

&lt;p&gt;Structural integration errors — type mismatches, missing handler implementations, incorrect data shapes, gaps between UI and backend contracts — should fail at build time rather than in browser execution. If the Maven build passes, the integration is correct. Treating the browser as the place where structural errors are discovered is an avoidable cost in debugging time, deployment cycles, and user impact.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Minimal Boilerplate per Feature
&lt;/h3&gt;

&lt;p&gt;Adding a new feature — a new view, a new action, a new data field — should require changes in the minimum number of places, ideally one. The codebase structure should guide the developer to the correct location and pattern. Architectural decisions should not be reopened on every addition.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. 100% Ownership of Components — No Escape Hatches
&lt;/h3&gt;

&lt;p&gt;Component frameworks typically define generic components covering the majority of use cases, then offer escape hatches for the rest. This is presented as flexibility. In practice it is a structural liability: escape hatches couple the project to framework internals, and framework upgrade cycles risk breaking those couplings. Long-term maintainability improves substantially when the project owns its rendered HTML and component contracts completely, so the question of escaping the framework never arises.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Semantic HTML is Non-Negotiable — and Must Be Owned
&lt;/h3&gt;

&lt;p&gt;The browser is a world of HTML. Producing semantically correct, standards-compliant HTML should be an explicit engineering goal — not an afterthought, not something delegated to a third-party framework's component library.&lt;/p&gt;

&lt;p&gt;Adopting a framework's component library because "we are not HTML/CSS experts" trades a knowledge gap for a control gap. The framework's HTML is a black box. When it changes its DOM structure, CSS breaks. When it revises class naming conventions, the project adapts. The project is permanently downstream of someone else's HTML decisions, on someone else's release cycle.&lt;/p&gt;

&lt;p&gt;The correct response is to own the HTML. The investment is made once: define each component in clean, semantically correct HTML. The resulting HTML belongs to the project. It cannot be broken by a third-party upgrade. The component HTML should remain clear and concise — obvious to anyone who opens the file — so that maintenance is equally obvious.&lt;/p&gt;

&lt;p&gt;The CSS for each component lives in the same file used to define the component's HTML. One source of truth. No indirection. No ambiguity about which styles apply to which structure. When a component needs to change, HTML and CSS are reviewed together. One CSS file styles the entire application. Component class names are functional and identifiable — they reflect what the component &lt;em&gt;is&lt;/em&gt;, not what it looks like. CSS can evolve entirely independently of Java code. A designer can restyle the full application by modifying CSS alone, without touching a single Java class.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Any Java Developer Can Build Application Features — No JavaScript Ecosystem Expertise Required
&lt;/h3&gt;

&lt;p&gt;Any Java developer should be able to build application features within this UI architecture without requiring JavaScript, CSS, or HTML knowledge. Not a full-stack developer. Not a Java developer who also knows a JS framework. Any Java developer.&lt;/p&gt;

&lt;p&gt;The developer base for Java is large. The developer base for Java developers who are also proficient in modern JavaScript, CSS architecture, and semantic HTML is substantially smaller. A framework requiring that intersection creates a staffing constraint that compounds as the team changes over time.&lt;/p&gt;

&lt;p&gt;UI engineering involves more than syntax — interaction design, state modelling, async behaviour, information hierarchy. These remain the developer's responsibility. What this architecture removes is the requirement to acquire a second language ecosystem to express them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Why These Are the Right Requirements
&lt;/h2&gt;

&lt;p&gt;Each requirement is independently justifiable. Together they reinforce each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frontend-requirement-down&lt;/strong&gt; is product thinking applied to architecture. The backend serves the frontend; the frontend serves the user. Reversing this dependency produces interfaces that feel like database forms — and that break whenever the domain model evolves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fat client as the client's home&lt;/strong&gt; reflects what the browser is: a capable, stable application runtime. Treating it as a display terminal forces server infrastructure to compensate for what the client could handle locally — state management, session continuity, resilience to transient failures. These become server problems when they could be client responsibilities, solved more cheaply, closer to the user, without cross-request infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compile-time validation&lt;/strong&gt; is the highest-leverage quality tool available to the team. Every structural error that escapes the build and reaches the browser costs more to find and fix by a significant margin. The compiler is free at runtime. Moving validation earlier is always the better trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100% component ownership&lt;/strong&gt; is the only durable resolution to the escape hatch problem. Partial ownership — using a framework's components for most cases — means living with the framework's HTML decisions, its upgrade cycle, and its constraints indefinitely. Full ownership means none of that. The project defines the components. The project owns the HTML.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Owning semantic HTML&lt;/strong&gt; is not idealism — it is engineering discipline. HTML is the foundation of everything the browser renders. Teams that do not own their HTML foundation do not fully control their accessibility, CSS architecture, DOM structure, or maintenance costs. A shared component library means this investment is made once and leveraged across every application in the organisation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large accessible developer base&lt;/strong&gt; recognises that sustainable software is built by teams over time. An architecture requiring rare skill intersections is a staffing risk. Reducing the entry requirement for feature development to "knows Java" is a durable organisational advantage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: Why Popular Alternatives Fall Short
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;React / TS&lt;/th&gt;
&lt;th&gt;Thymeleaf / HTMX&lt;/th&gt;
&lt;th&gt;Vaadin (Flow)&lt;/th&gt;
&lt;th&gt;Required from the architecture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compile-time structural validation&lt;/td&gt;
&lt;td&gt;Partial — no unified cross-language bridge&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client lives in the browser&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single language — Java&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100% component ownership&lt;/td&gt;
&lt;td&gt;Possible, rarely achieved&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;By construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No JS ecosystem expertise needed&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stateless server&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (standard architecture)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML ownership by construction&lt;/td&gt;
&lt;td&gt;Possible, not guaranteed&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  JavaScript Frameworks (React, Angular, Vue, Svelte)
&lt;/h3&gt;

&lt;p&gt;These frameworks can build any UI a browser can render. The evaluation here is not about output capability — it is about architectural fit for a Java backend team.&lt;/p&gt;

&lt;p&gt;A Java team adopting a JavaScript framework acquires a second language, a second type system, a second build toolchain, and a second ecosystem to maintain in parallel with the Java backend. The type systems do not share a validation boundary: TypeScript validates the client, Java validates the server, and structural mismatches between them surface at runtime. Shared contracts must be maintained in two places by two compilers.&lt;/p&gt;

&lt;p&gt;Component ownership is theoretically possible in these frameworks but structurally not guaranteed. A disciplined team can own their HTML in React. Most teams, in practice, adopt component ecosystems that control the DOM on their behalf — trading ownership for convenience and inheriting the maintenance consequences. The architecture described in this article makes full HTML ownership the default, not the exception.&lt;/p&gt;

&lt;p&gt;Framework churn is a real cost. The JavaScript ecosystem changes significantly on a multi-year cycle. Architectural commitments made today carry implicit future migration costs. These are difficult to quantify at decision time and easy to underestimate.&lt;/p&gt;

&lt;p&gt;For a Java backend team building domain applications, the trade-offs do not stack up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server-Side Rendering (Thymeleaf, Spring MVC, JSP, HTMX)
&lt;/h3&gt;

&lt;p&gt;The client lives on the server. Every interaction is a round-trip. State requires server-side session management. Horizontal scaling requires session affinity or replication. Template expressions are strings — a renamed Java method leaves a broken template the build cannot detect. Dynamic behaviour requires JavaScript added on top, reintroducing a dependency without the benefits of a proper fat client.&lt;/p&gt;

&lt;p&gt;Reasonable choices for content sites and simple form-based applications. Not suited for the class of domain application this article addresses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vaadin
&lt;/h3&gt;

&lt;p&gt;Targets the same use case as this architecture. In its standard configuration, recent Vaadin versions run server-side UI logic over a persistent WebSocket connection, which reintroduces server state and makes server restarts visible to users. Every UI interaction crosses the network. The fat client advantage — local state, local computation, resilience — is surrendered. Earlier Vaadin versions used GWT as their client-side foundation, which is architecturally much closer to what this article describes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: What the Architecture Needs — and What Delivers It
&lt;/h2&gt;

&lt;p&gt;The requirements converge on a specific model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A &lt;strong&gt;Java application&lt;/strong&gt; that runs in the browser&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Compiled by a &lt;strong&gt;Java toolchain&lt;/strong&gt; into a browser-executable artifact&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;With &lt;strong&gt;full ownership of HTML output&lt;/strong&gt; through a project-defined component library&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Communicating with the Java backend through a &lt;strong&gt;typed, compiler-validated protocol&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Built and verified by a &lt;strong&gt;single Maven build&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not a description of a framework. It is a description of a &lt;strong&gt;compiler that targets the browser runtime&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The mental model is exact:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Java → JVM bytecode → runs on Linux, Windows or any JVM host&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java → browser-executable artifact → runs in any browser&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The browser is a runtime environment, just as the JVM is a runtime environment. The developer writes Java. The compiler bridges the language to the runtime. The output format — bytecode or JavaScript — is an implementation detail, not a concern of the developer. This repositions browser compilation from exotic to normal. It is the same problem Java solved for heterogeneous server environments, applied to a new runtime target.&lt;/p&gt;

&lt;p&gt;Once this model is understood, the selection question becomes concrete: what exists that actually delivers it?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GWT — the Google Web Toolkit — is the only mature, production-proven option for Java.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GWT compiles Java to JavaScript. It has done so since 2006, at Google scale. Its type system is Java's type system. Its build integration is Maven. Its module boundaries are compiler constraints — client-only code cannot be invoked on the server; server-only code is not compiled into the client artifact.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Critical Distinction: GWT as Compiler vs GWT as Component Framework
&lt;/h3&gt;

&lt;p&gt;This distinction is architectural, not rhetorical.&lt;/p&gt;

&lt;p&gt;GWT used with JSON communication and its built-in widget library is, in practice, just another web framework. The compiler provides Java syntax, but the architecture is conventional: a third-party component library controls the HTML, communication is untyped, and the project is downstream of GWT's component ecosystem. In this mode GWT offers limited advantage and inherits familiar maintenance liabilities.&lt;/p&gt;

&lt;p&gt;GWT used as a pure compiler — with a project-owned component library and a typed communication protocol — is a fundamentally different thing. The HTML belongs to the project. The CSS belongs to the project. The communication contracts are validated by the Java compiler. GWT provides one thing: the ability to write Java that runs in the browser. Everything else is owned by the project.&lt;/p&gt;

&lt;p&gt;This distinction also resolves the "GWT is dead" criticism at a structural level. If GWT's component ecosystem were abandoned tomorrow, a project using GWT as a pure compiler would be unaffected. The compiler is the only dependency — and compilers are among the most stable software artifacts in existence. Stability is not abandonment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: The Component Library — HTML Owned, CSS Independent, Java Exposed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Semantic HTML Defined Once, Owned Completely
&lt;/h3&gt;

&lt;p&gt;HTML and CSS specialists define every component from scratch. This is a one-time investment — made properly, by people with the relevant expertise — that benefits every subsequent line of feature code written against it. The library covers the full UI vocabulary: root layout, navigation, header, footer, main content area, tables, lists, description lists, forms, dialogs, buttons, selects, confirmation prompts, composite panels.&lt;/p&gt;

&lt;p&gt;Each component is clean, semantic HTML. The structure of a table is &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;tbody&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt;. Navigation is &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;. A description list is &lt;code&gt;&amp;lt;dl&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;dt&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;dd&amp;gt;&lt;/code&gt;. The HTML communicates intent to the browser, to assistive technologies, and to any developer who opens the file. It should remain clear and concise — maintenance should be obvious from inspection.&lt;/p&gt;

&lt;p&gt;The CSS for each component lives alongside its HTML definition. One source of truth. A developer maintaining a component sees structure and styling together. One CSS file styles the entire application. Class names are functional — &lt;code&gt;transfer-table&lt;/code&gt;, not &lt;code&gt;blue-bordered-grid&lt;/code&gt;. Visual redesign is a CSS concern. It requires no Java changes and no recompile of application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  GWT Wraps Each Component in a Typed Java Class
&lt;/h3&gt;

&lt;p&gt;For each HTML component definition, a GWT Java class emits the correct HTML structure and exposes a typed Java API: builder methods, typed parameters, event handlers — all in Java, all compiler-validated.&lt;/p&gt;

&lt;p&gt;Building a wrapper requires knowing what an HTML tag is and when to use it — not CSS, not JavaScript, not layout theory. GWT provides the primitives: set a tag name, compose child elements, assign a class attribute. The wrapper author works in Java, guided by the HTML definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Above the wrapper layer, feature developers never write HTML. They never reference a CSS class name. They never open a stylesheet.&lt;/strong&gt; They instantiate typed Java classes. The HTML is an implementation detail of the wrapper. The CSS is an implementation detail of the stylesheet. Neither is visible, relevant, or accessible to feature developers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Libraries Across the Organisation
&lt;/h3&gt;

&lt;p&gt;The component library is a standalone Maven artifact. Multiple applications can depend on it. Every application in the portfolio gets uniform, semantically correct, standards-compliant HTML automatically. Each application maintains its own CSS where visual design differs — or shares it where it does not.&lt;/p&gt;

&lt;p&gt;HTML standards adherence is guaranteed across the portfolio by one maintained library, not by discipline in each project. Accessibility improvements propagate from one place to every application on the next build. When semantic best practices evolve, one wrapper update benefits all consumers.&lt;/p&gt;

&lt;p&gt;A strict separation is enforced by construction: component styling lives in the library, screen-level code lives in the application. A developer building a screen cannot accidentally mix component-level styling concerns into application code because they never touch CSS at all. This is the kind of separation that is hard to achieve through convention and trivial to achieve through architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Maintenance Cycle Reframed
&lt;/h3&gt;

&lt;p&gt;Conventional framework maintenance involves upgrading versions, adapting to API deprecations, resolving conflicts between framework changes and application code, and re-learning patterns the framework revised.&lt;/p&gt;

&lt;p&gt;In this architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Visual redesign:&lt;/strong&gt; update the CSS file. No Java touched. No application recompile.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HTML structure change:&lt;/strong&gt; update one wrapper class. Application code above it is untouched.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;New component:&lt;/strong&gt; define the HTML, write the wrapper. Immediately available to all feature developers as a typed Java class.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GWT compiler update:&lt;/strong&gt; affects only the compiler, not the component API or application code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dependency on GWT is a dependency on a compiler. Compiler interfaces are more stable than component framework APIs. The upgrade cost is proportional to what actually changed — not to what the framework decided to revise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 7: The Communication Architecture — One Pattern, Always
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Command as the Unit of Interaction
&lt;/h3&gt;

&lt;p&gt;Every client-server interaction is a Command — a Java object in the GWT shared package, serializable over GWT-RPC, carrying both the request parameters and, on return, the result. A single RPC endpoint receives all Commands and routes them to Visitor handlers. No servlet proliferation. No REST design decisions. No JSON schema. No API documentation to keep synchronised with implementation.&lt;/p&gt;

&lt;p&gt;A Command carries its request parameters to the server. The Visitor populates the result on the same Command object. The Command returns to the client. The same type throughout. The compiler validates the entire round trip.&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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;IntendedTransferDetails&lt;/span&gt; &lt;span class="n"&gt;intendedTransferDetails&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;//for serializable purposes&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt;&lt;span class="o"&gt;(){}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setIntendedTransferDetails&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IntendedTransferDetails&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;intendedTransferDetails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;details&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;IntendedTransferDetails&lt;/span&gt; &lt;span class="nf"&gt;getIntendedTransferDetails&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;intendedTransferDetails&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;Used on the client:&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transferId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CommandResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@Override&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GetIntendedTransferDetailsCommand&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;            &lt;span class="n"&gt;panel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIntendedTransferDetails&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getWidget&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;reload&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;Any Java developer reads this and understands it immediately. Create a Command with parameters. Execute it asynchronously. The result arrives back on the same Command object. Call whatever you need. No HTTP verbs. No JSON mapping. No async framework to learn. Java objects, Java callbacks, Java types throughout.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Server-Side Lifecycle
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Command arrives at single RPC endpoint
  → Extract session UUID from Command
  → Load and validate UserSession from database
  → Set user context in ThreadLocal       (Interaction begins / transaction opens)
  → Route to Visitor handler by Command type
  → Visitor executes domain logic
  → Visitor populates result on Command
  → Command returned to client
  → ThreadLocal cleared                   (Interaction ends / transaction closes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Interaction scope is the transaction scope. Every Command is exactly one Interaction, one transaction boundary, one security check. This is structural — it cannot be accidentally skipped. Server-side input validation applies as in any server-side system; the Interaction boundary enforces scope, not content correctness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security as a Type Property
&lt;/h3&gt;

&lt;p&gt;Commands requiring elevated privileges implement marker interfaces — &lt;code&gt;RequiresAdministrator&lt;/code&gt;, for example. The infrastructure checks for these before routing to any application code. Security is declarative, compile-time visible, and cannot be bypassed from application code. No annotation processing, no AOP, no filter chain configuration. Java interfaces and a single infrastructure check. Every privileged Command in the codebase is identifiable by a type search.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding a Feature
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Add a Command class in the shared package&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add a Visitor implementation on the server&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Call the Command from the client&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No servlet registration. No routing configuration. No JSON schema. No API documentation update. The type system connects Command to handler. The compiler verifies the connection. Maven validates the whole.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 8: Object-Oriented UI — A Natural Consequence
&lt;/h2&gt;

&lt;p&gt;GWT enables a pattern that most frontend architectures make difficult or impossible: applying standard object-oriented principles directly to UI objects. This is not a requirement of the architecture — it is a possibility it unlocks, and one worth examining because it illustrates how far the "just Java" principle extends when taken seriously.&lt;/p&gt;

&lt;p&gt;In most frontend architectures, data and behaviour are separated by design. A data object carries fields. Separate components, controllers, reducers, or stores manage what happens when the user interacts with that data. The data object is inert — it knows nothing of its own presentation or behaviour.&lt;/p&gt;

&lt;p&gt;In a Java fat client, this separation is a choice, not a constraint. A domain summary object can carry both its data and its behaviour, exactly as a well-designed Java object does in any other context.&lt;/p&gt;

&lt;p&gt;The same class that defines how a &lt;code&gt;TransferSummary&lt;/code&gt; appears in a table also defines what happens when the user clicks a row: which popup appears, which actions are offered, which Commands are issued for each action, which dialogs are composed for data entry. All co-located. All in Java. The object is alive in browser memory — it holds its full operational context, not just its display data.&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;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OnResult&lt;/span&gt; &lt;span class="n"&gt;onResult&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Popup&lt;/span&gt; &lt;span class="n"&gt;popup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Popup&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;getSummarizedSummary&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;popup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addPopupButton&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"View details"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;showTransferDetails&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;popup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addPopupButton&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Edit transaction"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GetEditableTransferDetailsCommand&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sourcePersonId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serviceProviderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTransferDetails&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;edit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onResult&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;popup&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;show&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;strong&gt;Adding a field means editing one class.&lt;/strong&gt; Add the field. Add the table header column. Add the table row cell. One place, one commit, compiler-validated. Compare this to a conventional layered approach: add to backend DTO, update TypeScript interface, update table component, update API response mapper, update state store, update tests per layer. Multiple locations, across potential team boundaries, where a mismatch at any point is a runtime surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conditional UI behaviour is conditional Java.&lt;/strong&gt; Whether to show a "Remove agreement" button is &lt;code&gt;if (agreements.size() &amp;gt; 0)&lt;/code&gt; — a domain condition expressed directly, not a separate UI state flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No DTO duplication.&lt;/strong&gt; There is no parallel UI model that mirrors the domain object. The domain object &lt;em&gt;is&lt;/em&gt; the UI object. The object carried to the client is the object that renders, the object that acts, the object that issues Commands. One model, one place, no synchronisation required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Summary objects carry more than they display.&lt;/strong&gt; A summary may show five fields in a table but carry twelve. The hidden fields are operational context — entity identifiers, related references, state flags — that drive popup actions and Command parameters. This is only possible because the fat client keeps the full object in browser memory. No round-trip to reconstruct context. No hidden state pushed into the URL.&lt;/p&gt;

&lt;p&gt;This is basic encapsulation applied consistently. It is not a novel pattern. It is OO design working exactly as intended, in a context where most architectures actively prevent it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 9: Testing in a Compiler-Validated Architecture
&lt;/h2&gt;

&lt;p&gt;Compiler validation eliminates a specific class of error: structural integration failures. Type mismatches, missing Visitor implementations, incorrect method signatures, RPC serialization failures — these do not reach runtime in a correctly built system.&lt;/p&gt;

&lt;p&gt;This does not replace testing. It removes the need for a category of test.&lt;/p&gt;

&lt;p&gt;Domain logic, workflow correctness, UX behaviour, edge cases in user interactions, and business rule validation all require tests. The compiler is not a substitute for verifying that the system does the right thing — it is a guarantee that the system does not break structurally. These are different concerns and both matter.&lt;/p&gt;

&lt;p&gt;In practice this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unit tests&lt;/strong&gt; cover domain logic and Visitor behaviour — pure Java, fast, no browser required. A unittest that checks that all Commands have 1 corresponding Visitor implementation ensures all commands can be executed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Integration tests&lt;/strong&gt; cover workflow correctness and Command/Visitor round trips&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The compiler covers&lt;/strong&gt; structural integration: type contracts, module boundaries, serialization correctness&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a test suite that is smaller, faster, and more focused than one that must also catch structural integration failures at runtime.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 10: Simplicity as an Economic Argument
&lt;/h2&gt;

&lt;p&gt;The requirements above are engineering arguments. They have a direct economic translation.&lt;/p&gt;

&lt;p&gt;Getting something to work is the scope for prototypes. Building so that maintainability and cost are optimal is the scope for production code. Almost any framework clears the first bar. Very few clear the second consistently over time. The economic argument for this architecture is entirely a production-scope argument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implementation speed.&lt;/strong&gt; A feature developer adds a panel by writing a Command, a Visitor, and composing typed Java components. No context switch, no second toolchain, no JSON mapping, no parallel type maintenance. The pattern is always the same. A developer who has built one feature understands the pattern for all subsequent features. Onboarding is measured in hours, not weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenance cost.&lt;/strong&gt; The dominant long-term cost in software is not building features — it is maintaining them. In this architecture, maintenance is localised by construction. A field change is one class. A visual redesign is CSS. An HTML structure update is one wrapper. There is no architectural archaeology to determine where a change belongs. Changes do not ripple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrade cycle cost.&lt;/strong&gt; Framework upgrade cost in JavaScript-heavy projects is a recurring drain on development capacity. Major upgrades require rework proportional to how deeply the framework is woven into the application. In this architecture the upgrade cycle is: update CSS when design standards evolve, update wrapper classes when HTML best practices change, update the GWT compiler when a new version is available. Application code is untouched by all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payload and runtime.&lt;/strong&gt; A mature production application with 3,000–4,000 UI classes produces 15–25MB of compiled, obfuscated output. This figure covers all application logic, all UI behaviour, and all dynamically generated HTML — the base HTML page is a minimal shell with an empty body; everything visible is generated by the compiled client. On modern connection speeds, for domain applications used by authenticated users, this is paid once and cached. It is not a meaningful operational concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organisational scale.&lt;/strong&gt; A shared component library amortises the HTML investment across every application in the portfolio. Accessibility improvements, semantic updates, and visual redesigns propagate from one place. Teams across multiple projects work from the same HTML foundation without coordinating on it. The per-application maintenance cost trends toward the cost of application logic alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team composition.&lt;/strong&gt; Because any Java developer can build UI features, the team does not maintain a specialist frontend/backend split with the communication overhead that implies. Junior developers contribute from day one. Senior developers are not bottlenecked on UI concerns. The team required to maintain and extend the system is smaller and easier to staff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future safety.&lt;/strong&gt; HTML and CSS have been backward-compatible for thirty years. An architecture founded on semantic HTML and a Java compiler is not a bet on a framework's commercial continuity — it is a bet on the web platform itself. The compiler-plus-owned-library combination means no part of the stack is dependent on a third party making the right product decisions.&lt;/p&gt;

&lt;p&gt;Correctness and economy point in the same direction. That is a consequence of building from first principles rather than from accumulated convention.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 11: Addressing the Criticisms
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "GWT is abandoned / dead"
&lt;/h3&gt;

&lt;p&gt;GWT 2.13.0 was released February 11, 2026. GWT 2.12.2 in March 2025. 2.12.1 in November 2024. 2.12.0 in October 2024. This is an active project with a consistent release cadence. Version 2.13 removed legacy IE polyfills, modernised project samples to Maven multi-module structure, added JFR events for compiler observability, delivered the largest JRE emulation improvements since 2.9.0, and added support for Jakarta Servlet APIs — meaning this stack runs cleanly on Spring Boot 3 and modern Jakarta EE servers.&lt;/p&gt;

&lt;p&gt;Structurally: GWT's JRE emulation has been progressively migrated to JsInterop to converge with J2CL, Google's next-generation Java-to-browser compiler. The API surface — Elemental2, JsInterop annotations, jsinterop-base — is shared between them. J2CL is Google's internal compiler for production-scale web applications today; GWT remains the most stable, Maven-integrated distribution for enterprise teams building on this model.&lt;/p&gt;

&lt;p&gt;More fundamentally: this architecture depends on GWT as a compiler, not as a component framework. The project-owned HTML, CSS, and communication infrastructure are independent of GWT's component ecosystem entirely. The compiler is the only dependency — and compilers are among the most stable artifacts in software. The Java compiler's core behaviour has not changed in years. Stability is not abandonment.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Compile times are too long"
&lt;/h3&gt;

&lt;p&gt;In a mature production application with 3,000–4,000 UI classes, compile times on modern hardware run between one and two minutes. Compile time is a function of permutation count — one permutation per browser per locale combination. With a single RPC endpoint, no code splitting, and a controlled locale set, permutation count is minimal. GWT 2.13.0 added JFR events specifically for compiler observability, making it straightforward to profile and address any compile-time concern. This criticism has most force for large multi-permutation applications. It does not apply here.&lt;/p&gt;

&lt;h3&gt;
  
  
  "The widget library is inadequate"
&lt;/h3&gt;

&lt;p&gt;Correct — and beside the point. This architecture does not use GWT's widget library. The HTML/CSS component library is defined by the project and owned by the project. The quality of GWT's built-in widgets is irrelevant.&lt;/p&gt;

&lt;h3&gt;
  
  
  "You still need to know HTML"
&lt;/h3&gt;

&lt;p&gt;At the component wrapper layer, the author needs to know what an HTML tag is and when to use it. Above that layer, feature developers work entirely in Java. The HTML knowledge required is modest, applied once per new component type, and confined to the component library team. It is explicitly not a feature development concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  "There is no defined development approach in GWT"
&lt;/h3&gt;

&lt;p&gt;This article defines one. Command/Visitor for all communication. Project-owned component wrappers for all HTML output. Domain objects carrying their own UI behaviour. Maven as the single build and validation step. The criticism applies to teams using GWT without architectural intent. The architecture is the answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 12: When This Architecture Is Not the Right Choice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Public-facing, SEO-critical sites&lt;/strong&gt; are a different problem domain. This architecture delivers a minimal HTML shell and populates the body dynamically. For tools used by people to get work done, that is the right trade-off. For sites whose success depends on search engine indexing of content or on first-render performance for anonymous users, use server-side rendering. These are different problems and should be solved with different tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams without Java as their primary language&lt;/strong&gt; will find less leverage here. The architecture's value comes from keeping Java developers in Java. A team already fluent in TypeScript and React is not gaining that advantage — they would be acquiring a new tool rather than deepening an existing strength.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep JavaScript ecosystem integration&lt;/strong&gt; — complex native browser API interop, third-party JavaScript widgets with no Java wrapper, WebGL pipelines — may add friction at the GWT boundary. GWT provides JsInterop for these scenarios, but it requires the component layer author to understand that boundary explicitly.&lt;/p&gt;

&lt;p&gt;Stating these boundaries is scope clarity, not concession. This architecture is designed for domain applications: business tools, dashboards, admin interfaces, internal platforms, data-intensive workflows. For that class of application, it is the strongest available option.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Evaluated from first principles — not by popularity, not by ecosystem size, not by recency — the architecture described here is the most coherent, most maintainable, and most economically sound approach to UI development for Java backend domain applications.&lt;/p&gt;

&lt;p&gt;The key insight is not about GWT specifically. It is about where the client should live, what the build should guarantee, and what the team should need to know. The client lives in the browser — fully, not as a thin view over server state. The build guarantees structural correctness — by construction, not by convention. Feature developers work in Java — without HTML, CSS, or JavaScript knowledge, as a daily reality, not an aspiration.&lt;/p&gt;

&lt;p&gt;GWT, used as a compiler combined with a project-owned component library, is the only mature option that delivers this model. The compiler provides the Java-to-browser bridge. The component library provides the HTML ownership. Together they provide something no JavaScript framework and no server-side rendering approach offers in this combination: a complete, type-safe, Java-only feature development experience for domain application UI, where the web platform is the foundation and no third party controls the HTML.&lt;/p&gt;

&lt;p&gt;The criticisms dissolve on contact with the architecture: compile times are fast at the permutation counts this setup requires; the widget library is irrelevant because it is not used; abandonment misreads compiler stability as stagnation; and the HTML boundary is exactly as thin as it needs to be — confined to the component layer, invisible above it.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Any Java developer builds UI features from day one&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Maven build is the integration test&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Adding a feature is one Command, one Visitor — the same pattern, always, guided by the type system&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Visual evolution is CSS. HTML evolution is a wrapper update. Application code is untouched by both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The client lives in the browser. The server is stateless. Scaling follows directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Component ownership is total. The HTML is yours. The CSS is yours. No escape hatches, because there is nothing to escape from.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Implementation is faster. Maintenance is cheaper. Teams stay smaller. Upgrade costs are minimal. The foundation is the web platform itself.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no simpler, no more maintainable, no more economically defensible UI architecture for Java backend domain applications — when GWT is understood for what it is: a compiler that makes the browser a first-class Java runtime target, combined with the discipline to own everything above it.&lt;/p&gt;

</description>
      <category>java</category>
      <category>frontend</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Parts in transit - Why most distributed systems are prematurely complex</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Sun, 10 May 2026 20:29:58 +0000</pubDate>
      <link>https://forem.com/leonpennings/parts-in-transit-why-most-distributed-systems-are-prematurely-complex-378e</link>
      <guid>https://forem.com/leonpennings/parts-in-transit-why-most-distributed-systems-are-prematurely-complex-378e</guid>
      <description>&lt;h2&gt;
  
  
  The incomparability problem
&lt;/h2&gt;

&lt;p&gt;Here is a question that has no clean answer.&lt;/p&gt;

&lt;p&gt;How do you know whether the architecture you chose was the right one?&lt;/p&gt;

&lt;p&gt;Not right in the sense of working — most systems work, eventually, after enough effort. Right in the sense of optimal. Right in the sense that the complexity you introduced was warranted by the problem you were solving, and that a simpler approach would have cost more rather than less.&lt;/p&gt;

&lt;p&gt;The honest answer, in most cases, is that you cannot know. Because the alternative was never built.&lt;/p&gt;

&lt;p&gt;This is not a gap in the data. It is the mechanism of the problem. Most systems are built only once. There is no second system built with different assumptions, run for five years, and compared on total cost of ownership, ease of change, and operational stability. The counterfactual does not exist. Therefore the cost of the wrong choice — if it was the wrong choice — is permanently invisible.&lt;/p&gt;

&lt;p&gt;None of this is to say that distributed systems cannot work. Many organisations have made them function, sometimes at considerable scale — usually through exceptional engineering discipline, strong platform investment, and genuine operational maturity. The question is different: how much of the total effort, over years, went into managing the consequences of the distribution itself, rather than advancing the domain? And would a simpler boundary choice have delivered more value with less sustained overhead? The counterfactual remains hard to prove, which is precisely why we need sharper prospective indicators.&lt;/p&gt;

&lt;p&gt;And here is what makes the problem genuinely difficult: the entire industry tends to converge on the same patterns at the same time. When every team uses a similar stack, incurs similar coordination overhead, and grows to a similar size — those costs stop being visible as costs. They become the definition of what software costs. Normal and wasteful become indistinguishable.&lt;/p&gt;

&lt;p&gt;So the question sharpens. If we cannot compare architectures retrospectively, is there anything we can measure prospectively — before five years have passed — that gives us a leading indicator of whether we are building something appropriately simple, or something unnecessarily complex?&lt;/p&gt;

&lt;p&gt;There is. And it comes from an unlikely place.&lt;/p&gt;




&lt;h2&gt;
  
  
  The warehouse and the system boundary
&lt;/h2&gt;

&lt;p&gt;Consider an order fulfilment operation. An order arrives. A picker walks to the rack holding the product, picks it, and places it on the assembly line. Routine.&lt;/p&gt;

&lt;p&gt;Now consider what happens when that order is cancelled.&lt;/p&gt;

&lt;p&gt;If the picker has not yet left the rack, cancellation is a system operation. One record updated. The state change is contained. The cost is negligible and the outcome is certain.&lt;/p&gt;

&lt;p&gt;If the picker is already walking the floor — part in hand, mid-transit — the picture changes entirely. The picker must be located and reached. The instruction must be communicated and confirmed. The picker turns around, returns the part, re-shelves it in the correct position, and logs the return. The assembly line must be told the part is not coming and adjust accordingly. Each of those steps can fail. Each failure requires its own recovery. If the picker has already placed the part on the line, someone else must retrieve it, the line has already reacted to its arrival, and the cleanup compounds further.&lt;/p&gt;

&lt;p&gt;The correction costs more than the original action. Not marginally more — multiplicatively more. More people, more coordination, more opportunity for secondary failure, and a system left in a state requiring verification before it can be trusted again.&lt;/p&gt;

&lt;p&gt;This is the principle that makes architectural cost measurable before a system is built:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;As long as domain actions happen within a single system boundary, the cost of failure is a rollback. The moment actions propagate outside that boundary, the cost of failure becomes coordination.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is not a preference. It is a structural property of distributed systems, and it applies regardless of how well the coordination is engineered. You can manage the cost with better tooling. You cannot eliminate it. It is inherent to the boundary crossing.&lt;/p&gt;

&lt;p&gt;The warehouse makes this visible in a way that software obscures. In the warehouse, you can see the picker walking. You can see the empty rack. You can see the stalled line. The cost of the part in transit is physically apparent. In software, the equivalent states — the uncommitted saga step, the unacknowledged event, the stalled compensating transaction — are invisible unless you built dedicated instrumentation to see them. The cost is identical. The visibility is not. That invisibility is precisely why the cost became acceptable.&lt;/p&gt;

&lt;p&gt;The well-run warehouse minimises the time parts spend in transit, because parts in transit are the expensive state. The leading indicator of a well-designed system is the same: how much of the domain work happens within a single rollback boundary, and how much crosses outside it?&lt;/p&gt;

&lt;p&gt;Rollbackability — the degree to which a failed action can be fully undone by the system without external coordination — is a concrete, prospective benchmark for simplicity. If you are designing a system and the failure path requires coordinating compensation across multiple services, you have already committed to a significant and permanent cost. The question is whether the benefit justified it.&lt;/p&gt;

&lt;p&gt;In most cases, that question was never asked.&lt;/p&gt;




&lt;h2&gt;
  
  
  A concrete example: order creation
&lt;/h2&gt;

&lt;p&gt;Take a canonical domain flow: an order is created, inventory is reserved, an invoice is generated, a shipment is planned. Four concepts. One business action. It either succeeds completely or it does not happen.&lt;/p&gt;

&lt;p&gt;In a monolith with a well-modelled domain, this is the entirety of the orchestration:&lt;/p&gt;

&lt;p&gt;java&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="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;Inventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reserve&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="nc"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Invoice&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="nc"&gt;Shipment&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Shipment&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&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="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipment&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;The database transaction is the system boundary. If anything fails, nothing happened. The domain concepts — Order, Inventory, Invoice, Shipment — do the work. The technology serves them. Rollbackability is total. The failure path costs nothing beyond the failed attempt itself.&lt;/p&gt;

&lt;p&gt;This example is deliberately straightforward — but the principle holds as domain complexity increases. In fact, the more complex the domain, the more important it becomes that the infrastructure does not add noise. A complex financial workflow with regulatory holds is hard enough to reason about correctly without the additional burden of distributed coordination, partial failure states, and eventual consistency layered on top of it.&lt;/p&gt;

&lt;p&gt;Now split those four concepts across four services. The business requirement has not changed by a single word. What changes is everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The infrastructure required before writing a line of business logic
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A message broker.&lt;/strong&gt; Services cannot call each other synchronously if you want any resilience. Kafka or RabbitMQ: a three-node production cluster, topic design, schema registry, retention policies, consumer group monitoring, and a local development environment every developer must run and maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saga infrastructure.&lt;/strong&gt; There is no transaction. Coordination must be made durable — if the orchestrator crashes mid-flow, it must resume from the correct step. This means a saga framework (Axon, Temporal, AWS Step Functions — each a substantial system with its own operational model and learning curve) or a hand-rolled saga state table with step tracking and a crash recovery process. Either way, there is now a fifth service whose entire existence is accidental complexity. It owns no domain concept. It exists solely because the transaction boundary was removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed tracing.&lt;/strong&gt; Four services produce four independent log streams with no shared identity unless you build one. Jaeger or Zipkin for the trace infrastructure. Every service propagates a correlation ID in HTTP headers, event envelopes, and log output. A log aggregation stack on top, because reconstructing an incident across four separate log streams without tooling is not a debugging workflow — it is an archaeology project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency handling — in every service.&lt;/strong&gt; Message brokers guarantee at-least-once delivery. The same event will arrive twice. Every consumer must handle this without creating two invoices or two shipments. An idempotency key strategy per event type. A deduplication store — typically a processed-events table — checked on every inbound message. This is not a framework you install. It is code you write, in every service, correctly, and maintain forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compensating transactions — per failure path.&lt;/strong&gt; The rollback equivalent. Designed, coded, tested, and maintained per service per failure scenario. For four services the paths are: inventory fails — cancel order; invoice fails — release inventory, cancel order; shipping fails — void invoice, release inventory, cancel order. Each compensation is a domain operation that must exist, be reachable, be idempotent, and be tested both in isolation and in combination. The failure paths grow as O(n²) with the number of services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API contracts and versioning.&lt;/strong&gt; In a monolith, a method signature change is a compiler error caught before deployment. Across services it is a potential production incident. OpenAPI specifications or event schemas in the schema registry. A versioning strategy for deploying new service versions while old ones are still running. Consumer-driven contract tests — an entirely new test layer that did not exist before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-service operational overhead — multiplied by four.&lt;/strong&gt; Each service needs its own CI/CD pipeline, its own database (shared databases between services defeat the architectural purpose), its own health checks, its own deployment configuration, its own secret management, and its own database migration strategy.&lt;/p&gt;

&lt;p&gt;None of this is business logic. All of it requires expertise to operate correctly. In practice it means a platform or infrastructure team to own the broker and deployment infrastructure, application developers who understand distributed systems failure modes rather than just domain logic, and an ongoing operational load that scales with the number of services — not with the complexity of the domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cost, made visible
&lt;/h2&gt;

&lt;p&gt;The following table makes the prospective cost explicit — before the first line of business logic is written, and before five years have passed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Monolith&lt;/th&gt;
&lt;th&gt;Microservices&lt;/th&gt;
&lt;th&gt;What the split actually costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Atomicity and failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback on failure&lt;/td&gt;
&lt;td&gt;Database transaction. One word.&lt;/td&gt;
&lt;td&gt;Saga pattern. Hundreds of lines.&lt;/td&gt;
&lt;td&gt;Design, code, and test a compensating action per service per failure path. O(n²) paths for n services.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial failure state&lt;/td&gt;
&lt;td&gt;Impossible. Transaction is atomic.&lt;/td&gt;
&lt;td&gt;Permanent possibility. Must be designed around.&lt;/td&gt;
&lt;td&gt;Order exists, invoice does not. Every consumer of your data now reasons about completeness. Forever.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency&lt;/td&gt;
&lt;td&gt;Immediate. Guaranteed.&lt;/td&gt;
&lt;td&gt;Eventual. A property you live with.&lt;/td&gt;
&lt;td&gt;Not solvable with better tooling. A structural consequence of the boundary choice.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Infrastructure before business logic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message broker&lt;/td&gt;
&lt;td&gt;None.&lt;/td&gt;
&lt;td&gt;Kafka or RabbitMQ. 3-node cluster.&lt;/td&gt;
&lt;td&gt;Topic design, schema registry, retention policy, consumer group monitoring, local dev setup.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saga / orchestration&lt;/td&gt;
&lt;td&gt;None.&lt;/td&gt;
&lt;td&gt;Axon / Temporal / hand-rolled plus a fifth service.&lt;/td&gt;
&lt;td&gt;Durable saga state, crash recovery, step tracking. An entire service that owns zero domain concepts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distributed tracing&lt;/td&gt;
&lt;td&gt;One stack trace.&lt;/td&gt;
&lt;td&gt;Jaeger / Zipkin plus correlation IDs everywhere.&lt;/td&gt;
&lt;td&gt;Every service propagates trace IDs in headers, event envelopes, and log output. Log aggregation stack on top.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;td&gt;N/A. Methods are naturally idempotent.&lt;/td&gt;
&lt;td&gt;Required in every service. Always.&lt;/td&gt;
&lt;td&gt;Deduplication store per service. Idempotency key strategy per event. Written, maintained, tested forever.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API contracts&lt;/td&gt;
&lt;td&gt;Compiler. Free.&lt;/td&gt;
&lt;td&gt;OpenAPI / schema registry plus versioning strategy.&lt;/td&gt;
&lt;td&gt;Consumer-driven contract tests. A breaking change is a production incident. Another test layer that did not exist.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-service operational overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD pipelines&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4+&lt;/td&gt;
&lt;td&gt;Independent versioning, deployment windows, rollback strategies. Coordination overhead on every release.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;4+&lt;/td&gt;
&lt;td&gt;Independent migration strategies per service. Schema changes coordinated across deployment boundaries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local dev environment&lt;/td&gt;
&lt;td&gt;One process.&lt;/td&gt;
&lt;td&gt;4+ services plus broker plus docker-compose.&lt;/td&gt;
&lt;td&gt;Onboarding measured in days not hours. Partial environments produce integration bugs that only appear in the full stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Debuggability and sustainability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debug a production failure&lt;/td&gt;
&lt;td&gt;One stack trace. One log stream.&lt;/td&gt;
&lt;td&gt;Reconstruct a timeline across 4+ log streams.&lt;/td&gt;
&lt;td&gt;Clock skew between services. Correlation IDs that were not propagated. Broker lag that shifted event order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bug surface&lt;/td&gt;
&lt;td&gt;Domain complexity only.&lt;/td&gt;
&lt;td&gt;Domain multiplied by accidental complexity.&lt;/td&gt;
&lt;td&gt;Each async handoff is a new class of timing bug. Compensating paths run rarely, are tested inadequately, and fail in production.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codebase legibility&lt;/td&gt;
&lt;td&gt;Domain is the code.&lt;/td&gt;
&lt;td&gt;Domain distributed across event schemas and API contracts.&lt;/td&gt;
&lt;td&gt;"What does order creation actually do?" has no single answer. The behaviour is implicit in subscriptions across four codebases.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance cost over time&lt;/td&gt;
&lt;td&gt;Proportional to domain complexity.&lt;/td&gt;
&lt;td&gt;Domain plus accidental complexity.&lt;/td&gt;
&lt;td&gt;Accidental complexity does not reduce over time. Services accumulate. Contracts fossilise. Framework versions break. Teams leave.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scaling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unit of scale&lt;/td&gt;
&lt;td&gt;The atomic action. Run more instances.&lt;/td&gt;
&lt;td&gt;Individual steps — which are not the bottleneck.&lt;/td&gt;
&lt;td&gt;Invoice creation and shipment planning are simple writes. They are not traffic hotspots. The decomposition solves a problem that does not exist.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure to scale&lt;/td&gt;
&lt;td&gt;Load balancer plus N identical instances.&lt;/td&gt;
&lt;td&gt;Everything above, multiplied.&lt;/td&gt;
&lt;td&gt;All the saga, broker, and tracing infrastructure exists solely to reconstruct what the database transaction provided for free.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The scaling argument that is rarely examined closely
&lt;/h2&gt;

&lt;p&gt;The case for microservices typically rests on scalability. You can scale the parts that need scaling independently, rather than scaling everything together.&lt;/p&gt;

&lt;p&gt;This sounds rational until you ask what actually needs scaling.&lt;/p&gt;

&lt;p&gt;In an order creation flow, the bottleneck is almost never the invoice logic or the shipment record creation. These are simple writes that happen once per order. The thing that needs scaling is the number of concurrent orders being created — the atomic action as a whole.&lt;/p&gt;

&lt;p&gt;Scaling the atomic action requires a load balancer and N identical instances of one deployed artefact. Each instance connects to one database. The database handles concurrent transactions reliably, as it has for decades. The infrastructure cost is a fraction of the distributed alternative. The operational complexity is a fraction. The failure surface is a fraction.&lt;/p&gt;

&lt;p&gt;A well-modelled core domain is not large. This is not an aspiration — it is what remains when accidental complexity is removed. The essential logic of order-to-shipment fits comfortably in one process, understood by one team. What makes codebases large is not the domain. It is frameworks imposing their structure on domain code, duplication caused by unclear boundaries, accidental complexity accreting around poor models, and boilerplate generated by architectural patterns that do not fit the problem.&lt;/p&gt;

&lt;p&gt;Strip those out and the core is small, fast to deploy, cheap to run, and trivially scalable as a unit.&lt;/p&gt;

&lt;p&gt;The industry asked "how do we scale the parts?" before asking whether the parts needed to be separate. It then built an entire ecosystem of frameworks, patterns, and operational infrastructure to answer the first question — all solving a decomposition problem that, in most cases, did not need to exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  When distribution is the right answer — and when the arguments do not hold
&lt;/h2&gt;

&lt;p&gt;Distribution has genuine use cases. They are narrower than the industry's adoption rate suggests, and several of the most commonly cited justifications do not survive close examination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Physical and regulatory constraints
&lt;/h3&gt;

&lt;p&gt;The standard argument: if data must live in a specific jurisdiction for regulatory reasons, you need a distributed architecture.&lt;/p&gt;

&lt;p&gt;The better answer: replicate the full domain logic into that regulatory cell. The atomic action stays atomic. The cell — with its own deployment, its own database, its own complete stack — is the unit of distribution. What you do not do is split the domain action across a jurisdictional boundary, routing parts of it between regions. That creates the coordination cost of distribution without the isolation that justified it. The constraint is geographic. The solution is geographic deployment of the whole, not decomposition of the parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Independent scaling profiles
&lt;/h3&gt;

&lt;p&gt;The standard argument: if one component needs more scale than others, separating it avoids scaling everything unnecessarily.&lt;/p&gt;

&lt;p&gt;The better answer: the cost of splitting a single component out of an otherwise coherent domain action is large, fixed, and permanent — as the table above makes clear. The question is not only "does this component need more scale?" but "does the benefit of isolating its scale exceed the full coordination cost of the split?" In most cases it does not, because the component that appears to need independent scaling is rarely the actual bottleneck under measurement, and because scaling the whole is cheaper than the industry assumes. If there is no compelling reason not to scale everything, scale everything. Simplicity requires a reason to abandon it, not a reason to adopt it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Organisational boundaries
&lt;/h3&gt;

&lt;p&gt;The standard argument: Conway's Law — systems tend to mirror the communication structures of the organisations that build them. If teams are separated, align the architecture accordingly.&lt;/p&gt;

&lt;p&gt;Conway's Law is a useful observation in retrospect. It describes what tends to happen when architecture is not deliberately managed. It is not a prescription, and it should never be used as one. Using it as a justification for a service boundary is encoding organisational structure permanently into the system — and paying the technical cost of that boundary in every sprint, by every developer, for the lifetime of the product.&lt;/p&gt;

&lt;p&gt;The cost of an artificially introduced service boundary compounds over years. The cost of reorganising a team is paid once. The engineering should define the ideal architecture with as few compromises as possible. The organisation should be arranged to serve that architecture, not the other way around. This pays dividends — perhaps not in year one, but reliably by year five, and every year thereafter. Teams that succeed with microservices often do so despite the architecture, through heroic platform investment and operational discipline. The patterns can be made to work. The deeper question is whether they were the right starting point for the domain in front of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Genuinely independent domain concepts
&lt;/h3&gt;

&lt;p&gt;This is the one case where distribution has a legitimate technical argument — and even here, the bar should be high.&lt;/p&gt;

&lt;p&gt;Domain concepts are genuinely independent when they have no transactional relationship with each other. Not merely different in name or ownership, but different in the sense that one completing or failing has no bearing on the integrity of the other. A recommendation engine and a payment processor are genuinely independent. An order and its invoice are not.&lt;/p&gt;

&lt;p&gt;The strongest version of this argument comes from systems with a fundamentally asymmetric workload — a platform where reads vastly outnumber writes, where the read path has no transactional requirement, and where the scale difference between the two is large and proven. A social platform where the overwhelming majority of requests are reads with no transactional requirements is a system where isolating the read path separates two genuinely different kinds of work with different resource profiles and different failure tolerances.&lt;/p&gt;

&lt;p&gt;But this is a workload argument supported by measurement, not an architectural principle applied by default. It applies to a small fraction of the systems that have adopted microservices, and it should be reached by evidence, not anticipated in advance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three tests before splitting a boundary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The rollback test.&lt;/strong&gt; If this action fails halfway through, what does recovery cost? If the answer is a database rollback, the action belongs inside a single boundary. If the answer is a coordinated sequence of compensating calls across multiple services, each of which can itself fail, ask whether that coordination cost was consciously accepted — or simply inherited from a pattern that was never examined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scaling test.&lt;/strong&gt; Which specific step in this action is the measured bottleneck under current or near-term load? Not the theoretical bottleneck. The step that is demonstrably the constraint today, under real conditions. If the answer is none of them individually, the action does not need decomposition. It needs more instances of the whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The standup test.&lt;/strong&gt; In the daily standup, what language does the team use? If the items are about services, pipelines, brokers, schemas, and migrations — the team is working on accidental complexity. If the items are about domain concepts — what an order means, who owns a responsibility, what a rule actually requires — the team is working on the right problems. You do not need a cost model to apply this test. You need one conversation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measuring it in a system you already have
&lt;/h2&gt;

&lt;p&gt;If these tests apply prospectively, they also apply to systems already in production. A short audit reveals more than any architecture review.&lt;/p&gt;

&lt;p&gt;Count the sagas. How many business capabilities require a saga or orchestrator to complete? Each one is a boundary crossing that converted a rollback into a coordination problem. The number tells you how much of the domain is currently in transit.&lt;/p&gt;

&lt;p&gt;Measure the standup ratio. Over two weeks, track how many standup items are about infrastructure, services, pipelines, and schemas versus domain concepts, rules, and business questions. The ratio is a direct reading of how much of the team's daily energy is absorbed by accidental complexity.&lt;/p&gt;

&lt;p&gt;Trace a failure end to end. Pick a recent production incident. Count the number of log streams, services, and correlation IDs required to reconstruct what happened. That reconstruction cost — in time, in tooling, in expertise — is paid on every incident. It is the maintenance tax of the boundary choices made at design time.&lt;/p&gt;

&lt;p&gt;Apply the migration heuristic. A well-modelled monolith can be split later, when measurement proves a specific boundary is warranted. A distributed system can rarely be reassembled cheaply once the boundaries have fossilised into contracts, event schemas, and separate team ownership. Optionality has value. The simpler starting point preserves it. The complex starting point spends it immediately, in exchange for flexibility that may never be needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  First principles
&lt;/h2&gt;

&lt;p&gt;There is nothing novel in the argument this article makes. It is an application of principles that engineering has held for as long as engineering has existed.&lt;/p&gt;

&lt;p&gt;Minimise the moving parts. Every component that can fail will eventually fail. Every interface between components is a surface for misunderstanding, for version drift, for timing errors that only appear under conditions nobody anticipated. The system with fewer moving parts is not the primitive system — it is the disciplined one.&lt;/p&gt;

&lt;p&gt;Solve the problem in front of you. The system that is over-engineered for scale it has not reached, for distribution it does not need, for independence that its domain does not have — that system is not prepared for the future. It is burdened by it. It is paying, today and every day, for problems it may never have.&lt;/p&gt;

&lt;p&gt;Prefer reversibility. The decision that can be undone when it proves wrong is worth more than the decision that cannot, regardless of how confident you are at the time. A monolith that can be split later, when the evidence demands it, is a better starting point than a distributed system that cannot be reassembled after the evidence proves the split was premature.&lt;/p&gt;

&lt;p&gt;Measure before you commit. The incomparability problem — the fact that the alternative architecture was never built, so its cost can never be directly compared — cannot be fully solved. But its worst effects can be mitigated by demanding evidence before committing to complexity: evidence of the scaling requirement, evidence of the domain independence, evidence that the coordination cost is worth the benefit it buys.&lt;/p&gt;

&lt;p&gt;The software industry has a habit of adopting solutions before fully understanding the problems they were designed to solve, and then normalising the cost of those solutions until the cost becomes invisible. The distributed systems patterns that dominate today were developed by organisations with genuine physical distribution requirements, at a scale that a small fraction of systems ever reach. They solved real problems. They are also expensive, complex, and failure-prone in ways that compound over time and rarely appear on the original architectural diagram.&lt;/p&gt;

&lt;p&gt;The question to ask, before any architectural decision, is not "how do others solve this?" It is "what does this problem actually require?" Start from first principles. Follow the cost. Build the simplest thing that genuinely solves the problem in front of you. Treat every boundary crossing — every point where a database rollback becomes a distributed coordination problem — as a commitment with a known, permanent price tag.&lt;/p&gt;

&lt;p&gt;Because it will cost exactly that. Invisibly, continuously, and for as long as the system runs.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>java</category>
      <category>microservices</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>AI can build anything except an understanding of what you are building</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Mon, 04 May 2026 08:28:51 +0000</pubDate>
      <link>https://forem.com/leonpennings/ai-can-build-anything-except-an-understanding-of-what-you-are-building-30le</link>
      <guid>https://forem.com/leonpennings/ai-can-build-anything-except-an-understanding-of-what-you-are-building-30le</guid>
      <description>&lt;p&gt;There is a distinction in software development that the industry has spent twenty years pretending doesn't exist. It is the distinction between building software and understanding what you are building. The first is implementation. The second is engineering. They are not the same thing, they do not require the same skills, and conflating them is the single most expensive mistake a development organisation can make.&lt;/p&gt;

&lt;p&gt;The mistake is now being turbocharged by AI. But to understand why, you first need to understand what was already broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part One: The 85% Nobody Talks About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two Kinds of Work
&lt;/h3&gt;

&lt;p&gt;Ask most developers how long it takes to build a feature and they will give you an implementation estimate. How long to write the code, wire up the endpoints, get the tests green. That estimate — the part where fingers meet keyboard — accounts for roughly 10 to 15 percent of what building good software actually requires.&lt;/p&gt;

&lt;p&gt;The other 85 to 90 percent is structuring. Understanding what the system is. Identifying where things belong, not just where they are needed. Naming the concepts correctly. Finding the natural boundaries in the domain. Modelling the business so that the code expresses it rather than merely approximating it.&lt;/p&gt;

&lt;p&gt;This is the work that determines whether a system is maintainable in year five, extensible in year seven, or being quietly replaced shortly after.&lt;/p&gt;

&lt;p&gt;Most systems are being replaced by year seven. The 85% was skipped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three Approaches, One Honest Assessment
&lt;/h3&gt;

&lt;p&gt;There are essentially three ways to approach building a system, and only one of them qualifies as engineering.&lt;/p&gt;

&lt;p&gt;The first is upfront design. You model the domain completely before writing code. The risk is rigidity — the model is fixed before the code has had a chance to reveal its gaps. Reality has a way of not fitting the diagram.&lt;/p&gt;

&lt;p&gt;The second is evolutionary modelling. You begin with a hypothesis about the domain and use code as a feedback instrument. The model and the implementation refine each other continuously. An hour into implementation the starting model may have changed dramatically — a new concept discovered, a responsibility reassigned, a boundary redrawn. That is not failure. That is the process working. The model remains the authority throughout, but it is a living authority — responsive and correctable, never frozen.&lt;/p&gt;

&lt;p&gt;The third approach is template filling. You select a framework. You receive a user story, which functions as a work order. You find the place in the template where this kind of story goes. You implement it there. You close the story.&lt;/p&gt;

&lt;p&gt;There is no model in this process. There is no conceptual centre. The framework is the authority, and the code documents what the framework was configured to do. Frameworks turned engineers into assembly line workers, and the Singleton Paradox — the impossibility of comparing the system that was built using approach A against the system using approach B that was never built — hid the cost. This is not a different kind of design. It is the absence of design, wearing design's clothes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Model as Construction Tool, Discovery Tool, and Filter
&lt;/h3&gt;

&lt;p&gt;The perception is that domain modelling is slow — that it delays visible output while the team thinks instead of ships. The reality is the opposite.&lt;/p&gt;

&lt;p&gt;A domain modelling session is twenty to thirty minutes at a whiteboard, followed by code that shapes the actual business interactions. This is not a prototype or a spike. It is production code — the domain coming into existence, business logic finding its natural form. By the end of the first day there is working code that expresses what the business does. The template developer, meanwhile, is configuring YAML, wiring injections, setting up repositories. The motion looks productive. Not a line of it describes the business.&lt;/p&gt;

&lt;p&gt;This is the construction side of what a domain model does. But it has two further functions that are equally important.&lt;/p&gt;

&lt;p&gt;It is a discovery tool. When implementation is hard — when a concept resists being placed, when a responsibility has no natural home — that difficulty is information. The model is telling you something is missing, or something is wrong. A trial-and-error developer experiences this friction as a local problem to solve locally. A modelling developer experiences it as the domain asking to be understood more precisely. The response is not a workaround. It is a model refinement.&lt;/p&gt;

&lt;p&gt;It is also a filter. If a behaviour cannot be fitted naturally into the model — if no object has a clear reason to own it, if it contradicts what the model already captures — that resistance is a signal. Either the model needs a new concept, or the behaviour itself does not belong in the system. The model's inability to absorb something cleanly is not a failure of the model. It is the model doing its job, filtering out accidental complexity dressed as a requirement. If you cannot fit behaviour into the model, you probably do not need it.&lt;/p&gt;

&lt;p&gt;A domain model is simultaneously the thing you build with, the instrument that tells you what you are missing, and the filter that tells you what does not belong. The industry has largely stopped building them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Essential Complexity vs Accidental Complexity in Code
&lt;/h3&gt;

&lt;p&gt;The essential/accidental distinction from Fred Brooks is not just an architectural principle. It applies at the level of every object, every responsibility, every line of code — and getting it right at that level is what separates systems that age well from systems that don't.&lt;/p&gt;

&lt;p&gt;Consider a practical example. When building a system that communicates with external services, the essential complexity is what those communications are — what a request contains, what posting requires, what the business needs to express. The accidental complexity is how those communications happen — the transport protocol, the connection handling, the session management, the specific library in use this year.&lt;/p&gt;

&lt;p&gt;Model the responsibilities first. A client object owns the mechanics of communication. A request abstraction defines what communication content looks like. A posting variant adds what posting specifically requires. These are modelled as business responsibilities, technology agnostic. The how — whether the underlying transport is HTTP, MQ, or a database — sits entirely behind those responsibilities, invisible to everything that depends on them.&lt;/p&gt;

&lt;p&gt;The consequence is significant. The technology can change completely — from web service to message queue to direct database write — without touching a single line of the business logic that constructs and uses those requests. The essential complexity is stable. The accidental complexity is genuinely replaceable.&lt;/p&gt;

&lt;p&gt;This is not an interface trick. It is what happens when you model responsibility first and let technology serve the model, rather than letting technology shape what responsibilities are possible. The difference only becomes visible when the technology needs to change — which it always does, eventually. At that point, a system where accidental complexity was kept genuinely separate from essential complexity absorbs the change quietly. A system where the framework grew roots into the business logic requires the business logic to change when the framework changes. The technology that was supposed to serve the domain ends up constraining it instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The User Story as Work Order
&lt;/h3&gt;

&lt;p&gt;Something specific happened to the user story as agile methodology was industrialised. It began as an invitation — a prompt to have a conversation with a domain expert, to understand a piece of the business well enough to model it. It became a specification. Then a work order. Then a checkbox.&lt;/p&gt;

&lt;p&gt;In its current form the user story arrives at the developer already closed. The conversation with the domain expert happened upstream, in refinement, in planning, in the product owner's head. The developer receives a summary and works from that. The question the developer asks is not "what is this telling me about the domain" but "where in the template does this go."&lt;/p&gt;

&lt;p&gt;The diagnosis is visible in what developers say when asked where the hard part of a system is. A template developer describes framework complexity — which abstraction to use, which pattern applies, how to configure the integration. A modelling developer describes domain complexity — what the business is actually doing here, what concept is missing, what existing object is being asked to carry weight it was not designed for.&lt;/p&gt;

&lt;p&gt;These are not the same question. They do not produce the same system. And over seven years, the difference between the systems they produce is not marginal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Things Belong vs Where They Are Needed
&lt;/h3&gt;

&lt;p&gt;The most consequential difference between template filling and domain modelling is not visible in the first sprint. It becomes visible in maintenance, and it compounds with every passing year.&lt;/p&gt;

&lt;p&gt;A template developer fixes problems where they occur. A table misbehaves on page B, so page B gets adjusted. The fix works. The story is closed. What is not visible is what has just happened structurally: page B now owns part of the table's behaviour. The table behaves one way on page A and another way on page B, and both pages carry part of the responsibility for what the table does. The next developer to touch either page must understand both. Maintenance has doubled, invisibly, for that one component.&lt;/p&gt;

&lt;p&gt;A modelling developer asks a different question: what owns this behaviour? The answer is the table itself. The table owns its own presentation. The page owns its usage of the table. A fix to the table propagates everywhere the table is used, because behaviour lives in the component, not in the pages that consume it.&lt;/p&gt;

&lt;p&gt;This is not an aesthetic preference. It is the mechanical difference between maintenance costs that stay flat and maintenance costs that compound.&lt;/p&gt;

&lt;p&gt;Multiply this pattern across a codebase over five years and you have the prototype in production — a system held together with toothpicks, paperclips, and glue, where every workaround is load-bearing and every change requires understanding not what the system is, but what it has become.&lt;/p&gt;

&lt;p&gt;The difference between fixing the problem where it occurs and fixing it where it belongs is the difference between prototype code and production code. At scale, it is the difference between a system that costs the same to maintain in year seven as it did in year one, and a system that is already being rewritten.&lt;/p&gt;

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

&lt;p&gt;There is a specific consequence of building without a domain model that becomes critical at scale. It is underappreciated, and AI makes it significantly worse.&lt;/p&gt;

&lt;p&gt;A domain model is not just a design preference. It is a contradiction-detection mechanism.&lt;/p&gt;

&lt;p&gt;When business logic has a conceptual centre — a well-named domain object that owns its own behaviour — contradicting rules become visible. If two requirements make incompatible demands on the same object, you encounter the conflict when you try to model it. The structure surfaces the problem before it reaches production.&lt;/p&gt;

&lt;p&gt;When business logic is scattered — across service methods, event handlers, configuration files — contradictions are invisible until they collide in production. Two requirements can contradict each other completely and coexist undetected for months, because there is no common reference point that would make the conflict visible. The system implements both rules, resolves the conflict arbitrarily at runtime, and produces behaviour that nobody designed and nobody can explain.&lt;/p&gt;

&lt;p&gt;CQRS, microservices, and event-driven architecture were proposed, in part, as responses to the complexity that accumulates without a domain model. The tragedy is that they add architectural elaboration without supplying the missing conceptual centre. They do not make contradictions visible. They distribute logic across more moving parts, which makes contradictions harder to see, not easier. The problem is obscured by the solution.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part Two: AI Became the Framework
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Same Pattern, Faster
&lt;/h3&gt;

&lt;p&gt;Which brings us to the present moment, and to the claim that AI is transforming software development.&lt;/p&gt;

&lt;p&gt;It is. But not in the way most of the conversation assumes.&lt;/p&gt;

&lt;p&gt;There are two ways to think about AI-assisted development, and they map precisely onto the distinction between design and template filling established in part one.&lt;/p&gt;

&lt;p&gt;AI is revolutionary in the sense that you conceive what you want, express it, and something builds it. The implementation barrier has been dramatically lowered. Code that would have taken days takes minutes. This is real and significant.&lt;/p&gt;

&lt;p&gt;But AI-assisted development is also pure template filling. You are not modelling. You are instructing. The output is code that documents what the prompt said, with AI as the framework. The assembly is faster, the templates are more flexible, the results are more immediately impressive. The absence of a modelling process is identical.&lt;/p&gt;

&lt;p&gt;And it inherits both failure modes simultaneously.&lt;/p&gt;

&lt;p&gt;From upfront design, it inherits rigidity at the point of prompting. The model — such as it is — is fixed in the prompt. The code cannot talk back, because you are not in dialogue with it. You are receiving output. The feedback loop that makes evolutionary modelling work — where implementation friction becomes structural insight — is broken. The AI absorbs the friction. You never feel it. You never learn from it.&lt;/p&gt;

&lt;p&gt;From template filling, it inherits the absence of a conceptual centre. The logic lives in the prompts, scattered and unreconciled, exactly as it lived in the fat services and event handlers before it. Except now it is even less visible, because a service class at least had a name and a location in a codebase. A prompt has neither.&lt;/p&gt;

&lt;p&gt;The framework abstracted the developer from the infrastructure. AI abstracts the developer from the code. Each layer of abstraction makes "it works" faster to achieve and the absence of a domain model harder to see.&lt;/p&gt;

&lt;p&gt;What the industry is currently calling "AI produces spaghetti" is not a new problem. It is framework templating amplified. The spaghetti was already there. AI makes it faster to produce, more voluminous, and more convincing — because it arrives in clean syntax with passing tests. The structural absence underneath looks better than ever.&lt;/p&gt;

&lt;p&gt;AI did not replace the framework. AI became the framework. And it inherited the same problem the framework always had — it can build anything except an understanding of what you are building.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Maintenance Proposition Does Not Hold
&lt;/h3&gt;

&lt;p&gt;The proposition being made for AI-assisted maintenance is that rewrites are now cheap, so structural problems do not accumulate the same way. This deserves examination.&lt;/p&gt;

&lt;p&gt;A rewrite can reproduce the syntax of a system faster than ever before. What it cannot do is verify that the rewrite is correct in the only sense that matters for a business system — that it accurately represents what the business actually does. Correctness here is not syntactic. It is semantic. It requires a reference against which to check the implementation.&lt;/p&gt;

&lt;p&gt;The reference is the domain model. And the domain model is exactly what was never built.&lt;/p&gt;

&lt;p&gt;So the rewrite, however fast, produces new code that implements the same contradictions, the same scattered logic, the same implicit assumptions. It is not a fix. It is a reprint. The toothpicks are replaced with newer toothpicks. The paperclips are shinier. The structure is identical.&lt;/p&gt;

&lt;p&gt;Consider the contradiction problem at scale. Two prompts with conflicting business logic — you will probably spot it. Twenty — possibly. Eighty — almost certainly not. There is no structure that makes the contradiction visible. A rewrite from those eighty prompts does not resolve the contradiction. It reproduces it in fresh syntax. And in another cycle, the same conversation about rewriting will begin again, for the same undiagnosed reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Disappears and What Doesn't
&lt;/h3&gt;

&lt;p&gt;Frameworks will likely disappear, and probably sooner than the industry expects. Hibernate exists because writing database session management by hand is tedious and error-prone for humans. AI has no such limitation. It can write the queries, manage the sessions, handle the mapping — contextually, specifically, without a generic abstraction layer designed for every possible use case. The framework was a productivity tool for human limitations. As those limitations are removed, the justification for the framework dissolves. This is not a loss. Frameworks were always accidental complexity — complexity introduced by tools rather than by the problem itself.&lt;/p&gt;

&lt;p&gt;But the domain model does not disappear with the framework. It becomes more critical. Because the framework, for all its costs, at least imposed some structure. Generic, clumsy, domain-agnostic structure — but structure nonetheless. Without it, and without a domain model, the only thing standing between a system and total architectural entropy is the conceptual model in the developer's head.&lt;/p&gt;

&lt;p&gt;Or its absence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Skill That Cannot Be Prompted
&lt;/h3&gt;

&lt;p&gt;The ability to model a domain — to hold a structural representation in your head, refine it through implementation, and express it in code that means something beyond its own execution — does not appear to be a skill that AI can supply or that prompting can replicate.&lt;/p&gt;

&lt;p&gt;It appears to correlate with a specific kind of spatial reasoning: the ability to see a three-dimensional object from its two-dimensional components, to hold structure in the mind and manipulate it without losing the whole. Developers who have this skill behave differently when they encounter implementation friction. Where a template developer sees a local problem to solve locally — a fix applied where the problem occurs rather than where it belongs — a modelling developer sees structural information. The friction is the domain asking to be understood more precisely. The response is not a workaround. It is a model refinement.&lt;/p&gt;

&lt;p&gt;You cannot prompt your way to that response. The prompt eliminates the friction. And the friction was the signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Only Honest Measure
&lt;/h2&gt;

&lt;p&gt;There is a simple diagnostic for whether a system was built or merely assembled. Apply it after seven years.&lt;/p&gt;

&lt;p&gt;Is maintenance getting cheaper or more expensive? A well-modelled system gets cheaper — the model matures, the team internalises it, changes become faster as understanding deepens. A template-filled system gets more expensive, as accidental complexity compounds and each change must navigate the accumulated residue of earlier decisions made without a model.&lt;/p&gt;

&lt;p&gt;Are new requirements getting faster or slower to absorb? A well-modelled domain accelerates — each addition deepens understanding and reveals where the next extension naturally fits. A system without a conceptual centre slows — each requirement negotiates with the existing tangle rather than extending a coherent structure.&lt;/p&gt;

&lt;p&gt;Has the rewrite conversation started?&lt;/p&gt;

&lt;p&gt;The rewrite is not a sign of business ambition or technical progress. It is the bill arriving for the 85% that was skipped. And it will reproduce the conditions that made it necessary, because the organisation never learned what actually went wrong. The diagnosis will be "technical debt" or "legacy architecture." Rarely will it be accurate: no domain model was ever built, and without one, the rewrite begins the same accumulation from sprint one.&lt;/p&gt;

&lt;p&gt;AI makes none of this cheaper in the long run. It makes the first two years cheaper and the subsequent five more expensive, because the prototype is produced faster and looks more convincing, and the discovery that it is a prototype comes later and costs more.&lt;/p&gt;

&lt;p&gt;The 85% cannot be prompted. It cannot be templated. It cannot be abstracted away by a sufficiently powerful framework, however intelligent that framework becomes.&lt;/p&gt;

&lt;p&gt;It requires understanding what you are building.&lt;/p&gt;

&lt;p&gt;That has always been the hard part. It remains the hard part. And the industry's increasing sophistication at avoiding it is not progress.&lt;/p&gt;

&lt;p&gt;It is a more expensive way of arriving at the same rewrite conversation, on roughly the same schedule, having learned roughly the same nothing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>softwareengineering</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to Test Whether Your Software Solution Actually Fits The Problem</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 28 Apr 2026 06:04:11 +0000</pubDate>
      <link>https://forem.com/leonpennings/how-to-test-whether-your-software-solution-actually-fits-the-problem-c85</link>
      <guid>https://forem.com/leonpennings/how-to-test-whether-your-software-solution-actually-fits-the-problem-c85</guid>
      <description>&lt;p&gt;Every application is built once.&lt;/p&gt;

&lt;p&gt;There is no second version of the same system, built with different architectural assumptions, run in parallel for a decade, and then compared on maintenance cost, team size, and requirement absorption speed. The alternative is never built. The counterfactual never exists. This is the Singleton Paradox applied to software: because each system is unique, there is no external reference point against which to judge whether it is a good solution to its problem — or merely the only solution anyone bothered to build.&lt;/p&gt;

&lt;p&gt;This matters more than it might appear. It means that the quality of an architectural decision can never be measured by comparison. You cannot park the well-modeled system next to the poorly-modeled one and read off the difference. The poorly-modeled system is the only one that exists. So when it becomes expensive to maintain, slow to change, and eventually impossible to extend, those outcomes get attributed to the problem — the domain was complex, the requirements changed, the business grew — rather than to the solution. The solution is never put on trial, because there is nothing to try it against.&lt;/p&gt;

&lt;p&gt;The Singleton Paradox does not just make good architecture hard to prove. It makes bad architecture hard to see. The absence of contrast is not neutral. It actively shapes what gets treated as normal. Rising maintenance costs are normal. Growing teams are normal. Slowing feature velocity is normal. Rewrites every seven to ten years are normal. None of this is normal in the sense of being inevitable. All of it is normal in the sense of being what happens when accidental complexity (Fred Brooks' term for the complexity introduced by tools and decisions rather than by the problem itself) compounds over time, and when there is no alternative visible to suggest it could be otherwise.&lt;/p&gt;

&lt;p&gt;This creates a specific and solvable problem. If external comparison is unavailable, the only honest measure of whether a system is a good fit for its problem is internal. Not how it compares to another system that was never built, but how it behaves against time. Does it get easier or harder to operate? Does it get cheaper or more expensive to change? Does it remain stable as the domain evolves, or does it accumulate fragility with each passing year?&lt;/p&gt;

&lt;p&gt;Those questions have answers. And the answers, taken together, constitute the only reliable verdict on whether the solution fit the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Ten-Year Cost Test
&lt;/h2&gt;

&lt;p&gt;That internal measure can be made concrete. The Ten-Year Cost Test is a diagnostic any organisation can apply to its own systems — not a comparison against an alternative that was never built, but a set of questions about whether the current architecture is winning or losing against time. The threshold of ten years is not arbitrary. A system that cannot survive a decade without a rewrite has not been maintained; it has been replaced. And replacement, however it gets framed, is the system announcing that it was not a good fit for the problem it was built to solve.&lt;/p&gt;

&lt;p&gt;The test is simple. After ten years in production, a well-designed system should satisfy all of the following:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenance cost is the same or lower than year one.&lt;/strong&gt; As the domain model matures and the team's understanding deepens, maintenance should become cheaper, not more expensive. The team knows where everything lives. The rules are explicit and localised. A change that took two days in year one should take two hours in year ten, because the model has been refined and the team has internalised it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New requirements are absorbed faster as the system matures.&lt;/strong&gt; A well-modeled domain does not merely keep pace with new understanding — it accelerates. Each addition deepens the team's knowledge of the model and reveals where the next extension naturally fits. When the business learns something new — a new product type, a new regulatory constraint, a new class of customer — the model should be able to absorb it with decreasing effort over time, not constant effort. If absorption speed is flat, the domain model is adequate but not right. If it slows, the model is failing. A well-modeled system gets easier to extend the longer it has been understood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The team size required to maintain it has not grown significantly.&lt;/strong&gt; This is perhaps the most honest measure of architectural health. A system that requires more people every year to maintain the same functionality is a system where accidental complexity is compounding. Each new developer adds coordination overhead. Each new layer of abstraction requires more people to understand it. A well-modeled system with low accidental complexity should be maintainable by a small, stable team indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The application is as stable or more stable than it was initially.&lt;/strong&gt; Stability should increase over time as the model matures and edge cases are understood and handled. If the system becomes less stable over time — more incidents, more unexpected interactions, more fragile integrations — accidental complexity is winning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost of running it has not grown faster than the business it serves.&lt;/strong&gt; Infrastructure costs, operational overhead, and support burden should scale with business growth, not with architectural entropy. A system that costs significantly more to run in year ten than it did in year one, while serving the same number of users, has a structural problem.&lt;/p&gt;

&lt;p&gt;Apply this test honestly to any system you have worked on for more than five years. The results are rarely comfortable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Industry Data Actually Shows
&lt;/h2&gt;

&lt;p&gt;Before examining how the average project scores on this test, an important caveat is necessary. Rigorous longitudinal data comparing domain-first versus framework-first approaches over ten-year periods essentially does not exist in published form. The industry does not measure what it should measure. Deployment frequency, recovery time, and project delivery success rates are tracked. Total cost of ownership relative to architectural approach over a decade is not.&lt;/p&gt;

&lt;p&gt;This absence is itself the Singleton Paradox operating at industry scale. Nobody ran the controlled experiment. Nobody built both versions of the same system and compared them over ten years. So the precise cost differential between approaches is genuinely unknown in the scientific sense — even though the directional evidence is consistent and substantial.&lt;/p&gt;

&lt;p&gt;What does exist:&lt;/p&gt;

&lt;p&gt;The CISQ estimated in 2022 that poor software quality costs US organisations approximately $2.41 trillion annually, with a significant portion attributable to accumulated technical debt. The direction of travel is clear even if the precise attribution to architectural choices is not.&lt;/p&gt;

&lt;p&gt;The Standish Group CHAOS Report has tracked project success rates for decades. Despite continuous evolution of methodology — agile, DevOps, cloud-native — the underlying success rates have not dramatically improved. This implies the problem is structural rather than methodological. Better processes applied to the wrong architecture produce better-managed failure, not success.&lt;/p&gt;

&lt;p&gt;The DORA research — Google's annual State of DevOps reports, now covering over 39,000 professionals — shows a persistently bimodal distribution. The 2024 report found that elite performing teams have change failure rates around 5% and recover from incidents in under an hour. Low performing teams have significantly higher failure rates and recovery times measured in days or weeks. Only 19% of organisations reached elite performance. The low performance cluster, meanwhile, grew from 17% to 25% of respondents between 2023 and 2024. The distribution is not a bell curve. It is two distinct populations. Architecture and approach appear to be the differentiating variable, not team size, budget, or industry.&lt;/p&gt;

&lt;p&gt;Amazon Prime Video published a case study in 2023 describing a 90% infrastructure cost reduction after consolidating a distributed microservices monitoring service into a single process — a result specific to that service, not a platform-wide architectural overhaul, but instructive precisely because the team at Amazon chose to be candid about it. Segment, a data platform company, published a similar account. These are self-selected — organisations that consolidated and saved money are more likely to publish than those that saw no benefit — but they are directionally consistent with the argument being made here.&lt;/p&gt;

&lt;p&gt;A McKinsey and University of Oxford study of more than 5,400 IT projects — conducted in 2012 and still the most comprehensive published dataset of its kind — found that large IT transformation projects run on average 45% over budget, 7% over time, and deliver 56% less value than predicted. That is first delivery. The trajectory over the subsequent decade is harder to find in rigorous published form — which is itself telling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scoring the Average Project
&lt;/h2&gt;

&lt;p&gt;With that context, here is an honest assessment of how the average project scores on each dimension of the Ten-Year Cost Test. These are not precise figures — the data does not support precision — but they represent the consistent direction of the evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maintenance cost rises significantly on the average project.&lt;/strong&gt; Industry estimates consistently place maintenance at 60–80% of total software lifecycle cost, and that proportion grows over time rather than shrinking. On framework-first systems, the annual upgrade cycle alone — broken dependencies, reworked configuration, revalidated integrations — consumes engineering capacity that produces zero business value. In the worst cases, maintenance costs grow 800% or more over a decade, eventually triggering a rewrite. In the best cases — domain-first systems with low accidental complexity — maintenance costs stay flat or fall as the model matures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requirement absorption speed slows materially on the average project.&lt;/strong&gt; In a well-modeled system, new requirements should get faster to implement over time — not slower — as the team's understanding deepens and the model reveals where each extension naturally fits. On the average project, the opposite happens. What starts as a two-week feature becomes a two-month project by year five, as each new requirement must navigate accumulated accidental complexity. In distributed systems, a single business rule change triggers API contract renegotiation, versioning decisions, cross-team coordination, and staged deployments. In the worst cases, the system effectively stops absorbing new requirements — every change becomes a major project and the business routes around the software rather than through it. In the best cases, requirement absorption accelerates as the model matures. Flat speed is a warning sign. Slowing speed is a verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Team size grows on the average project.&lt;/strong&gt; Industry observation consistently shows teams of two to three times the original size by year ten, maintaining the same functional scope. In the worst cases — full microservices architectures with dedicated platform, SRE, and DevOps functions — the team exists primarily to manage its own infrastructure rather than to serve the business. In the best cases, the team stays small and stable. Three developers. Five hundred domain objects. Fifteen years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stability declines on the average project.&lt;/strong&gt; DORA data shows that low-performing teams — the majority — have change failure rates approaching fifty percent and recovery times measured in weeks. Production increasingly becomes the final validation environment because the integrated system only meets real conditions there. In the worst cases, the organisation develops a chronic incident culture where production instability is treated as a fact of life rather than an architectural signal. In the best cases, stability improves over time as the model matures and edge cases are properly handled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running costs grow faster than business value on the average project.&lt;/strong&gt; The shift to cloud computing made infrastructure costs more visible but did not reduce them. Microservices architectures run fifty to two hundred containers where a monolith needs three to five, with corresponding cost differentials. In the worst cases, infrastructure cost grows an order of magnitude while business capability grows modestly. In the best cases, running costs remain proportional to business growth throughout the system's life.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rewrite conversation starts on the average project around year seven.&lt;/strong&gt; In the worst cases, the conversation starts at year three or four — the system has already become unmaintainable before it is fully understood. In the best cases, the conversation never happens. The system absorbs new requirements, accommodates new technology at its boundaries, and continues to serve the business indefinitely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Inverse Is Also True
&lt;/h2&gt;

&lt;p&gt;The data does not merely show that the average project fails the Ten-Year Cost Test. It shows that failure is the expected outcome — so expected that the industry has stopped treating it as failure.&lt;/p&gt;

&lt;p&gt;Rising maintenance costs are attributed to business complexity rather than architectural choices. Growing teams are treated as evidence of business success rather than architectural inefficiency. Slowing requirements are explained by changing priorities rather than accumulated accidental complexity. Declining stability is managed with better monitoring rather than addressed at its source. The rewrite conversation is framed as modernisation rather than recognised as the bill arriving for choices made before the domain was understood.&lt;/p&gt;

&lt;p&gt;This normalisation is the most dangerous consequence of the Singleton Paradox operating at industry scale. When everyone is paying the same inflated price, the inflated price becomes the reference point. The cost of accidental complexity is not visible as a cost. It is visible as &lt;em&gt;the cost of software&lt;/em&gt; — the natural, inevitable, irreducible price of building systems.&lt;/p&gt;

&lt;p&gt;It is not natural. It is not inevitable. It is not irreducible.&lt;/p&gt;

&lt;p&gt;It is the compound interest on a specific set of choices, made consistently, across the industry, before domains are understood. Choices that look like engineering because everyone makes them. Choices that the Singleton Paradox ensures will never be clearly falsified, because the alternative is never built.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rewrite as the Final Verdict
&lt;/h2&gt;

&lt;p&gt;There is one more signal worth examining. It requires no data, no research, no longitudinal study. It is available in almost every organisation that has been running software for more than a decade.&lt;/p&gt;

&lt;p&gt;The rewrite conversation.&lt;/p&gt;

&lt;p&gt;When someone in your organisation argues that the current system cannot support where the business is going — that it needs to be modernised, migrated, rebuilt on a new platform — that system has already announced its verdict on the Ten-Year Cost Test. The rewrite is not a sign of business ambition. It is the bill arriving.&lt;/p&gt;

&lt;p&gt;The tragedy of the rewrite is not its cost, though the cost is substantial — typically measured in millions and years. The tragedy is what happens after. The new system almost always makes the same choices. The same framework is selected before the domain is understood. The same patterns are applied before the business concepts are named. The same accidental complexity is introduced in the first sprint and compounds through the same lifecycle.&lt;/p&gt;

&lt;p&gt;Because the Singleton Paradox means the organisation never learned from the previous system what actually went wrong. The previous system ran in production. The pipeline was green. The architecture was recognised. The failure was economic and temporal — too slow, too expensive, too fragile to change — not functional. And economic, temporal failure is invisible until it isn't. By the time the rewrite conversation starts, the diagnosis is usually "technical debt" or "legacy architecture" or "we outgrew it." Rarely is the diagnosis accurate: accidental complexity was introduced before the domain was understood, and it compounded for seven years.&lt;/p&gt;

&lt;p&gt;So the rewrite reproduces the conditions that made the rewrite necessary. And in another seven to ten years, the conversation starts again.&lt;/p&gt;

&lt;p&gt;A well-modeled system does not generate the rewrite conversation. Not because it is perfect, or because requirements don't change, or because technology doesn't evolve. But because the essential complexity — the domain model — is separable from the accidental concerns around it. Frameworks can be replaced without touching the domain. Infrastructure can evolve without restructuring the business logic. The system adapts because its core is stable, and its core is stable because it correctly reflects the domain rather than the technology choices of the year it was built.&lt;/p&gt;

&lt;p&gt;The Ten-Year Cost Test can be applied to any system. And the rewrite conversation, or its absence, is the most honest result that test can produce.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Uncomfortable Conclusion
&lt;/h2&gt;

&lt;p&gt;The Singleton Paradox means the direct proof will always be unavailable. You cannot park the well-architected system next to the poorly-architected one and read off the difference, because only one of them was ever built. You cannot compare the fifteen-year maintenance cost of a domain-first system against a framework-first system because the framework-first system is the only one that exists.&lt;/p&gt;

&lt;p&gt;What you can do is apply the Ten-Year Cost Test to what you have. Ask honestly whether maintenance is getting cheaper or more expensive. Whether new requirements are getting faster or slower to absorb. Whether the team is staying small or growing to manage complexity. Whether the system is getting more stable or less. Whether running costs are proportional to business growth or running ahead of it.&lt;/p&gt;

&lt;p&gt;And ask whether the rewrite conversation has started.&lt;/p&gt;

&lt;p&gt;The industry data — imprecise as it is, incomplete as it necessarily must be — points consistently in one direction. The average project fails all five dimensions of the test. Maintenance rises. Requirements slow. Teams grow. Stability declines. Costs outpace business value. The rewrite conversation starts around year seven and reproduces the conditions that made it necessary.&lt;/p&gt;

&lt;p&gt;This has happened so consistently, for so long, that it has been normalised into invisibility. The inflated cost has become the reference point. The compounding expense of accidental complexity has become indistinguishable from the natural cost of building software — because no one in the room has ever seen it otherwise.&lt;/p&gt;

&lt;p&gt;The proof that it can be otherwise exists — in systems maintained by small teams in complex domains, absorbing new requirements cleanly, costing the same to run as they did a decade ago. Those systems exist. They simply never get compared to the alternative, because the alternative was never built.&lt;/p&gt;

&lt;p&gt;The absence of that proof in your organisation is not evidence that it is impossible.&lt;/p&gt;

&lt;p&gt;It is evidence of the Singleton Paradox.&lt;/p&gt;

&lt;p&gt;And the Singleton Paradox is not a law of nature.&lt;/p&gt;

&lt;p&gt;It is a consequence of choices. But not random choices — choices made under a specific kind of pressure that has nothing to do with fit. Spring Boot is chosen because the last project used Spring Boot. CQRS is chosen because the architect gave a conference talk on CQRS. Event-driven architecture is chosen because it is what sophisticated teams are supposed to use. These are not engineering decisions. They are career decisions dressed as engineering decisions. No one got fired for choosing the framework everyone else is using. The choice is defensible precisely because it is popular — and because the Singleton Paradox ensures it will never be tested against the alternative, it remains defensible indefinitely, regardless of what it actually costs.&lt;/p&gt;

&lt;p&gt;This is the root cause the industry rarely names. Not incompetence. Not malice. The systematic selection of solutions on the basis of social safety rather than demonstrated fit — in an environment where demonstrated fit is structurally impossible to measure.&lt;/p&gt;

&lt;p&gt;Choices that can be made differently.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>java</category>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>The Underestimated Power of Encapsulation in Software Engineering</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Mon, 27 Apr 2026 08:46:44 +0000</pubDate>
      <link>https://forem.com/leonpennings/the-underestimated-power-of-encapsulation-in-software-engineering-ff2</link>
      <guid>https://forem.com/leonpennings/the-underestimated-power-of-encapsulation-in-software-engineering-ff2</guid>
      <description>&lt;p&gt;Most Java developers today can explain encapsulation. They will tell you it means making fields private and adding getters and setters. They can recite SOLID principles on demand. They know the vocabulary.&lt;/p&gt;

&lt;p&gt;What most of them have never experienced is what genuine object-oriented design actually feels like in practice — and that is the real problem.&lt;/p&gt;

&lt;p&gt;Object-oriented principles did not disappear because of technology hype or the pace of change. They were never properly learned. A generation of developers was trained on frameworks, not on design. They learned Spring before they understood objects. They learned dependency injection before they understood responsibility. They learned how to make things work before they understood how to structure things well.&lt;/p&gt;

&lt;p&gt;The result is an industry where object-oriented vocabulary is used to justify procedural habits. The Interface Segregation Principle — which is fundamentally about keeping responsibilities separate and coherent — gets applied as a rule for how to slice Spring interfaces. Encapsulation becomes a checkbox: private fields, public getters, done. The deeper meaning, and the profound practical value behind it, is lost entirely.&lt;/p&gt;

&lt;p&gt;What dominates instead is procedural programming in disguise. Fat service classes orchestrate anemic data bags. Logic is scattered across layers. Objects exist to hold data, not to own behavior. The goal is implementation — make it work, ship it — not design. Not structure. Not a system that remains small, simple, robust, and maintainable as it grows.&lt;/p&gt;

&lt;p&gt;This article is about what encapsulation actually means, what it actually does, and why practicing it properly changes both the software you build and the way you think about building it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Encapsulation Really Means
&lt;/h2&gt;

&lt;p&gt;Encapsulation means that the "how" stays completely inside the object. Clients see only the "what" — the responsibilities the object fulfills. Nothing about implementation, nothing about mechanism, nothing about technology ever surfaces in the public interface.&lt;/p&gt;

&lt;p&gt;Private fields are the minimum. The real discipline is in the public surface of the object. If a method exposes internal data, leaks a storage detail, or forces the caller to know anything about how the object works internally, encapsulation has already failed — regardless of whether the fields are private.&lt;/p&gt;

&lt;p&gt;This extends to the constructor. A constructor that accepts implementation details — a storage mechanism, an external resource, a configurable strategy — is already exposing the "how." The object must own its implementation completely, from the moment it comes into existence.&lt;/p&gt;

&lt;p&gt;A helpful guiding principle is &lt;strong&gt;"Tell, Don't Ask"&lt;/strong&gt;: tell the object what to do. Do not ask it for its data so that you can make decisions with it elsewhere. When you find yourself pulling data out of an object to decide what to do next, that decision almost certainly belongs inside the object itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cognitive Shift: From Technology to Responsibilities
&lt;/h2&gt;

&lt;p&gt;Encapsulation is more than a coding rule. Practiced properly, it becomes a thinking tool that changes how you model systems from the ground up.&lt;/p&gt;

&lt;p&gt;When you commit to hiding the "how," you are forced to think clearly about the "what." Technical questions — how do I store this, which framework handles this, which layer does this belong to — become the wrong questions. They are about implementation, and implementation is not your concern at this level. The right questions are: what is this object responsible for? What should it be able to do? Which other objects would it naturally talk to?&lt;/p&gt;

&lt;p&gt;In a typical Spring application this shift never happens. Developers think in layers — controller, service, repository — and the central question is always "where does this code go?" That question produces a filing system for procedural code. It does not produce a domain model. The objects that emerge from it are empty by design, because the template has already decided that behavior lives in services, not in objects.&lt;/p&gt;

&lt;p&gt;Asking "whose responsibility is this?" produces something entirely different: a coherent network of objects that each own their behavior completely, and that together tell the story of the domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example: A Well-Encapsulated Document
&lt;/h2&gt;

&lt;p&gt;Consider a compliance-heavy application where documents — PDFs, scanned forms, certificates — play a central role. They get created, stored, retrieved, and checked for compliance throughout the system.&lt;/p&gt;

&lt;p&gt;The typical Spring-influenced approach treats &lt;code&gt;Document&lt;/code&gt; as a data bag:&lt;/p&gt;

&lt;p&gt;java&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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;filePath&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// leaks storage details&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// exposes raw data&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getFilePath&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;filePath&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="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;setFilePath&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&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="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;getContent&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;content&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... more getters and setters&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not an object. It is a struct with ceremony. Every implementation detail is visible and reachable. Logic that belongs to the Document — storage, compliance checking, content retrieval — lives somewhere else, in a service class, spread across layers, written procedurally. Changing the storage mechanism means hunting through the entire codebase because the entire codebase is coupled to the implementation.&lt;/p&gt;

&lt;p&gt;Now consider a Document that actually owns its responsibilities:&lt;/p&gt;

&lt;p&gt;java&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;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Document&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="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&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;String&lt;/span&gt; &lt;span class="n"&gt;name&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;String&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;InputStream&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mimeType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mimeType&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;// becoming a Document includes taking care of its own storage&lt;/span&gt;
        &lt;span class="c1"&gt;// the how is nobody else's business&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;void&lt;/span&gt; &lt;span class="nf"&gt;writeToStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OutputStream&lt;/span&gt; &lt;span class="n"&gt;outputStream&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// retrieves and writes content — fully internal&lt;/span&gt;
        &lt;span class="c1"&gt;// the caller gets their bytes, nothing more&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;boolean&lt;/span&gt; &lt;span class="nf"&gt;isCompliant&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// compliance logic lives here, where it belongs&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;String&lt;/span&gt; &lt;span class="nf"&gt;getName&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;name&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;String&lt;/span&gt; &lt;span class="nf"&gt;getMimeType&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;mimeType&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;The Document figures out its own storage as part of coming into existence. It knows how to give its content back via &lt;code&gt;writeToStream&lt;/code&gt;. It knows whether it is compliant. No file path is exposed. No byte array leaks out. No storage mechanism is visible to anything outside.&lt;/p&gt;

&lt;p&gt;Usage across the system stays clean and expressive:&lt;/p&gt;

&lt;p&gt;java&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="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invoice.pdf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/pdf"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contentStream&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attach&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeToStream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responseStream&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;Transaction&lt;/code&gt; knows it can attach a &lt;code&gt;Document&lt;/code&gt;. It does not know — and has no reason to know — how the document stores itself, where it lives, or how it retrieves its content. The Document figures it out. That is the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters at Scale
&lt;/h2&gt;

&lt;p&gt;The benefits of this discipline are not always obvious on a small codebase. They become impossible to ignore as the system grows — and they show up most clearly when things need to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core logic tells the story of the domain.&lt;/strong&gt; When objects are modeled around responsibilities rather than technical concerns, reading the code means reading the domain. A &lt;code&gt;Transaction&lt;/code&gt; attaches a &lt;code&gt;Document&lt;/code&gt;. A &lt;code&gt;Document&lt;/code&gt; knows whether it is compliant. The objects speak in business terms because they were designed in business terms. There is no framework noise, no layer indirection, no infrastructure vocabulary polluting the domain model. A new developer — or a returning one after six months — can understand what the system does by reading the objects, not by reverse-engineering a tangle of service classes and annotations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framework upgrades become a bounded problem.&lt;/strong&gt; The dominant template in Java development today is well known: logic goes into services, data gets carried by DTOs, persistence is managed by repositories, and domain objects exist mainly to map to database tables. This pattern is taught as architecture. It is actually a prescription for hollowing out the domain. The objects end up empty. The behavior ends up scattered across service classes that have no natural boundary, no clear responsibility, and no reason to stay coherent as the system grows.&lt;/p&gt;

&lt;p&gt;The consequence is that the framework and the domain become inseparable — not because of annotations on classes, but because the logic itself has been relocated into framework-managed components. Services are Spring beans. Transaction boundaries are framework concerns. The business reasoning is hosted inside the framework rather than sitting independently of it. When the framework changes, the logic has to move with it, because the logic lives inside it.&lt;/p&gt;

&lt;p&gt;When domain objects genuinely own their responsibilities, this changes entirely. The core domain is a network of objects talking to each other in business terms, with no knowledge of the framework hosting them. The framework sits at the edges — handling HTTP, managing sessions, coordinating persistence — but it does not host the logic. Upgrading it, replacing it, or restructuring it becomes a bounded problem. The domain does not change because it was never coupled to the framework in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The five to seven year rebuild cycle is not inevitable.&lt;/strong&gt; Most software organisations accept the full rewrite as a fact of life. After a few years, the codebase has become so entangled with its own technology choices that evolution is no longer possible — the only way forward is to start again. This cycle is expensive, disruptive, and demoralising. It is also, in large part, a consequence of building systems where business logic is hosted inside framework components rather than inside the domain itself.&lt;/p&gt;

&lt;p&gt;When the core logic is a network of objects talking to each other in terms of responsibilities, it does not age the same way. The business rules, the domain relationships, the behavioural contracts between objects — these survive. Technology changes around them. The core endures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architectural evolution becomes manageable.&lt;/strong&gt; Moving from a monolith to a distributed architecture, extracting a bounded context, splitting a service — these are genuinely difficult problems when business logic is woven through framework plumbing. When domain objects carry no framework baggage and communicate purely through their responsibilities, the same logic can move between architectural boundaries without fundamental redesign. The objects do not care whether they run in one process or ten. Their responsibilities do not change. Their interfaces do not change. The architecture is a deployment concern, not a domain concern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The less the core depends on frameworks, the longer it survives.&lt;/strong&gt; This is the underlying premise. Frameworks evolve, get replaced, fall out of favour, and eventually die. Business logic, when it is well modelled, does not have the same lifecycle. Keeping them genuinely separate — not just in theory, but in practice, through strict encapsulation — means the thing that actually matters, the domain model, accumulates value over time rather than accumulating debt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The data bag.&lt;/strong&gt; A class whose primary purpose is to hold data with getters and setters is not an object in any meaningful sense. It is a data structure. Logic that should belong to it lives elsewhere, and that scattered logic is the source of most maintenance pain in large Java codebases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leaking constructor.&lt;/strong&gt; A constructor that accepts implementation details — storage strategies, injected resources, configurable mechanisms — is already exposing the "how." This is dependency injection, and despite its near-universal adoption in Java development, it is a direct violation of encapsulation. The object should own its implementation fully. If it needs to talk to an external resource, it does so internally. That is not a variable, not a configuration point, not something the outside world participates in. It is simply what the object does. The widespread embrace of DI as a default pattern reflects an aversion to singletons and a desire for testability — both legitimate concerns — but it solves them at the cost of encapsulation, and that cost is rarely acknowledged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Procedural code in disguise.&lt;/strong&gt; A service class that takes data out of one object, makes decisions about it, and puts results into another object is a procedural function with a class wrapper. The behavior belongs in the objects themselves. The service class is a symptom of objects that do not own their responsibilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SOLID as a technical checklist.&lt;/strong&gt; When principles like Interface Segregation or Single Responsibility are applied to framework configuration and layer boundaries rather than to object design, they produce architectural cargo cult — the appearance of structure without the substance. These principles are about responsibilities and design, not about how to wire up a Spring context.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on Pragmatism
&lt;/h2&gt;

&lt;p&gt;No codebase exists in a vacuum. Frameworks, ORMs, and serialization libraries are part of real-world development, and they sometimes need to know things about your domain objects. This is accidental complexity — the overhead introduced by the tools and environment you work in, as opposed to the essential complexity of the domain itself.&lt;/p&gt;

&lt;p&gt;The key distinction is whether the accidental complexity adapts to the essential, or corrupts it.&lt;/p&gt;

&lt;p&gt;JPA annotations on a domain object are a good example of acceptable accidental complexity. They decorate the object — they tell the framework how to map it — but they do not change what the object does, how it reasons, or how it protects its own state. The domain logic is untouched. If you removed JPA tomorrow, the object would still make complete sense. The essential complexity is intact. Accidental complexity that adapts to essential complexity without reshaping it is always acceptable — and recognising that distinction is itself a design skill.&lt;/p&gt;

&lt;p&gt;The line is crossed when the framework starts dictating structure. A no-argument constructor that leaves the object in an invalid state. A setter that exists purely because the ORM needs to hydrate a field. A transaction boundary that forces business logic to be organised around framework sessions rather than domain responsibilities. At that point the accidental complexity is no longer adapting to the essential — it is reshaping it. The tool is now designing the domain, and the domain is losing its integrity.&lt;/p&gt;

&lt;p&gt;The test is simple: if the accidental complexity were removed, would the core object still be coherent, valid, and complete on its own terms? If yes, the compromise is acceptable. If no, the framework has gone too far and the design needs to push back.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: Encapsulation as a Force Multiplier
&lt;/h2&gt;

&lt;p&gt;True encapsulation is strict. The object alone owns and hides everything about how it works. Clients see only responsibilities. The "how" is nobody else's business.&lt;/p&gt;

&lt;p&gt;Practiced properly, it changes more than the code. It changes how you think about systems. You stop modeling data flow and start modeling behavior. You stop thinking in layers and start thinking in responsibilities. You stop asking "where does this code go?" and start asking "whose job is this?" The software becomes a network of objects that each know their job and do it — completely, independently, and without leaking their secrets.&lt;/p&gt;

&lt;p&gt;That network survives in a way that layered, framework-dependent systems do not. It survives framework upgrades because the framework was never inside it. It survives architectural shifts because the objects carry no architectural assumptions. It survives time because it is organised around the domain — around what the software actually is — rather than around the technology that happens to be running it today.&lt;/p&gt;

&lt;p&gt;Most Java developers today have never worked in a codebase built this way. That is not an accusation — it is a consequence of an industry that taught frameworks before it taught design. But it means that for many, genuinely object-oriented development would feel like a different discipline entirely.&lt;/p&gt;

&lt;p&gt;It is. And it is worth learning.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>springboot</category>
      <category>encapsulation</category>
      <category>java</category>
    </item>
    <item>
      <title>Rich domain modelling: a library story</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Sun, 19 Apr 2026 12:23:04 +0000</pubDate>
      <link>https://forem.com/leonpennings/rich-domain-modelling-a-library-story-1ane</link>
      <guid>https://forem.com/leonpennings/rich-domain-modelling-a-library-story-1ane</guid>
      <description>&lt;p&gt;Most software doesn't have a domain model. It has a database schema, a set of service classes that orchestrate calls to it, and a collection of user stories that have been implemented one by one, each leaving a small deposit of logic somewhere convenient. This works, until it doesn't — until a framework needs replacing, a regulation changes, or someone asks a question the system was never quite designed to answer, and the answer turns out to be scattered across fourteen service methods and three database joins.&lt;/p&gt;

&lt;p&gt;This article is about a different approach, illustrated through a deliberately simple example: a library system. The example is old-fashioned on purpose. The familiarity lets you focus on the reasoning, not the subject matter.&lt;/p&gt;

&lt;p&gt;The core argument is this: a rich domain model is not something you design once at the start of a project and then implement. It is something you grow, continuously, as your understanding of the business deepens. Every requirement, every refinement session, every new user story is not just a work order — it is new information about the domain. The question to ask at each step is not "how do we implement this?" but "does this change what we understand the domain to be?"&lt;/p&gt;

&lt;p&gt;If the answer is yes, the model changes. Not in a future story. Not as tech debt. Now. The implementation timeline is not sacred. The correctness of the domain is. The cost of a misaligned domain compounds over time — it gets into every new feature, every workaround, every "we can't easily change that" conversation. A missed sprint to correct the model is almost always cheaper than six months of working around a wrong abstraction.&lt;/p&gt;

&lt;p&gt;The other side of this is: you only model what you understand. If something is unclear, that is not a reason to guess at an abstraction — it is a reason to ask more. Refinement sessions exist precisely for this. The domain expert knows things the model doesn't yet reflect. The job is to close that gap, incrementally, with each new piece of understanding.&lt;/p&gt;

&lt;p&gt;That is what this article shows. Not a perfect model arrived at in one go, but a model that starts where the knowledge starts, and adapts as the knowledge grows.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;User story 1: "We want to lend out books"&lt;/strong&gt;&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 which shelf, or on loan to whom, from when until when.&lt;/p&gt;

&lt;p&gt;From this, the initial domain objects emerge: &lt;code&gt;Book&lt;/code&gt;, &lt;code&gt;Lender&lt;/code&gt;, and somewhere, the loan dates. And this last point — &lt;em&gt;where&lt;/em&gt; do the loan dates live? — is the first real decision.&lt;/p&gt;

&lt;p&gt;The path of least resistance puts them in &lt;code&gt;Book&lt;/code&gt;. The book knows where it is; if it's on loan, it knows to whom and for how long. It seems natural. But pause here, because this is the decision that will constrain everything that follows.&lt;/p&gt;

&lt;p&gt;Ask a simple domain question: is knowing when it 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. The loan is an event — an agreement between the library and a person, at a point in time, concerning that book. Two different things. Putting loan dates in &lt;code&gt;Book&lt;/code&gt; is the same category of error as storing someone's employment history in their passport: adjacent subjects stitched together because it was convenient.&lt;/p&gt;

&lt;p&gt;There is also a practical problem that makes the conceptual one concrete: a book can be borrowed many times, by different people, at different points in time. A single set of loan fields cannot represent that history without overwriting it. The model isn't just conceptually imprecise — it is structurally incapable of answering basic questions the business will eventually ask.&lt;/p&gt;

&lt;p&gt;The first model, with its warning signs visible:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvgktvmbpk06524mzt6ql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvgktvmbpk06524mzt6ql.png" alt=" " width="667" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Recognising the problem, a &lt;code&gt;Loan&lt;/code&gt; entity is introduced. It points to a book and a lender, and carries its own data: start date, end date, and a return date for when the item actually comes back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk66rjysr8bgcfixvyq8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk66rjysr8bgcfixvyq8c.png" alt=" " width="686" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Book&lt;/code&gt; is clean. Each entity is responsible for what it actually is.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Emergent behaviour: what the model now gives you for free&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is something worth making explicit, because it tends to get overlooked.&lt;/p&gt;

&lt;p&gt;When the domain is modelled correctly, it doesn't just solve the problem at hand — it makes available capabilities that nobody wrote a story for.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;Loan&lt;/code&gt; as a first-class entity, the model now contains the answers to questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;How many times has this book been borrowed in the last year?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is it borrowed back-to-back — should we order a second copy?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which items are overdue right now?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which lender has the most active loans?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No one asked for any of this. And more importantly, no one needs to change the model to support it. These questions are answerable as a natural consequence of the right abstraction — zero additional structural cost. This is what correct domain modelling produces: not just a solution to the stated requirement, but a foundation that doesn't resist future questions.&lt;/p&gt;

&lt;p&gt;The opposite — loan dates buried in &lt;code&gt;Book&lt;/code&gt; — means that every one of those questions requires working around an accidental constraint. The data is there, technically, but it is in the wrong place conceptually, and that mismatch has a cost that accumulates with every new question the business wants to ask.&lt;/p&gt;

&lt;p&gt;A correct abstraction doesn't just solve the current problem. It shapes every solution that follows.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;User story 2: "We also want to lend out DVDs"&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;On most teams, this is treated as a work order. There is now a &lt;code&gt;DVD&lt;/code&gt; entity. Fields are defined — title, director, runtime. The ticket is closed.&lt;/p&gt;

&lt;p&gt;This is precisely the failure mode the introduction described: a user story implemented rather than understood. The arrival of this requirement is not an instruction to add &lt;code&gt;DVD&lt;/code&gt;. It is new information about the domain. And new information about the domain means it is time to re-examine the model.&lt;/p&gt;

&lt;p&gt;The question is not "how do we add DVD?" The question is: &lt;em&gt;was&lt;/em&gt; &lt;code&gt;Book&lt;/code&gt; &lt;em&gt;ever the right abstraction for this domain?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Think about what the lending system actually cares about. It doesn't care that a book has pages or that a DVD has a runtime. From the perspective of the lending domain, both are things that can be borrowed, returned, and tracked. If you add a &lt;code&gt;DVD&lt;/code&gt; entity you are not modelling the lending domain — you are modelling a classification detail that the domain does not act on. And the next story will bring magazines. Then tools. Then a request that breaks the pattern entirely, and by then there are four parallel entity types, duplicated service logic, and a reporting layer full of unions.&lt;/p&gt;

&lt;p&gt;The correct response to this user story is not implementation. It is evaluation. And the evaluation reveals that the concept the domain actually needs is not &lt;code&gt;Book&lt;/code&gt; — it is a lendable item. Something that can be borrowed, regardless of what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Modelling the domain, not the world&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the point where a common objection appears: isn't &lt;code&gt;LendableItem&lt;/code&gt; with a generic attribute collection just an EAV pattern with a different name? Isn't it losing type safety? Isn't it too abstract?&lt;/p&gt;

&lt;p&gt;These are implementation concerns, not domain concerns. And that distinction matters enormously.&lt;/p&gt;

&lt;p&gt;A book and a DVD are genuinely different things in the real world. They have different physical forms, different metadata, different cultural contexts. But the domain model is not a model of the real world. It is a model of how the business operates. And in the lending domain, a book and a DVD are the same thing: an item that can be lent to a person for a period of time, tracked, and returned. The domain acts on that concept. It does not act on the distinction between pages and runtime.&lt;/p&gt;

&lt;p&gt;The risk in domain modelling is not abstraction. The risk is the &lt;em&gt;wrong&lt;/em&gt; abstraction — and the most common wrong abstraction is modelling the real world instead of the business domain. When that happens, the model fills up with concepts that feel correct because they match physical reality, but that the business never actually operates on as distinct things. &lt;code&gt;Book&lt;/code&gt; and &lt;code&gt;DVD&lt;/code&gt; as separate domain entities is that mistake. The library doesn't lend books and DVDs differently. It lends items.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LendableItem&lt;/code&gt; is not generic for the sake of flexibility. It is precise — precisely what the domain requires.&lt;/p&gt;

&lt;p&gt;This is not overengineering. Starting with &lt;code&gt;Book&lt;/code&gt; was correct — at the time, only books existed, and naming the concept after the only known instance of it is entirely reasonable. Good domain modelling does not demand abstraction before there is evidence for it. But when the evidence arrives, the model must respond.&lt;/p&gt;

&lt;p&gt;The revised model:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1sfom6h8ovffenf71uv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1sfom6h8ovffenf71uv.png" alt=" " width="700" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Book&lt;/code&gt; becomes &lt;code&gt;LendableItem&lt;/code&gt;. The type — book, DVD, magazine, whatever comes next — is an &lt;code&gt;ItemType&lt;/code&gt; instance defined in data, not in code. Each &lt;code&gt;ItemType&lt;/code&gt; carries the attribute definitions relevant to it: a book has ISBN and author; a DVD has runtime and director. The &lt;code&gt;LendableItem&lt;/code&gt; holds the attribute values as a key-value collection shaped by the &lt;code&gt;ItemType&lt;/code&gt; — not arbitrary data, but controlled variation. A new lendable type can be defined through the UI, without a software release. The domain absorbs the variation without being touched.&lt;/p&gt;

&lt;p&gt;Notice what also appears here: &lt;code&gt;LendPolicy&lt;/code&gt;. Lending rules — how long something can be borrowed, whether it can be renewed — are not properties of items. They are policies, and policies have their own identity. A 7-day loan period might apply to all DVDs, a 21-day period to most books, and a specific rare edition might carry its own exception — all configurable, without code changes. By modelling &lt;code&gt;LendPolicy&lt;/code&gt; as an entity that &lt;em&gt;points to&lt;/em&gt; items rather than belonging to them, the granularity becomes a business decision. The domain reflects it correctly.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What this example is really about&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three things are worth naming directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The domain is not a one-off.&lt;/strong&gt; The biggest misconception about domain modelling is that it happens at the start of a project, produces a diagram, and is then finished. In practice, a domain model is only as good as the understanding that produced it. Understanding grows — through refinement sessions, through new requirements, through conversations with domain experts who reveal nuance the model doesn't yet capture. Every one of those moments is an opportunity to improve the model. Treating them as implementation tickets instead is how misalignment accumulates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correctness compounds.&lt;/strong&gt; A wrong abstraction doesn't just cause one problem. It causes every problem that grows on top of it. When the framework needs replacing five years from now, the core business logic should be the stable thing — the part that doesn't change because it correctly reflects the domain. If the logic has leaked into service methods, database queries, and framework-specific glue, the framework and the logic are inseparable. A rich domain model is what makes the core of the application resilient to the things around it changing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User stories are input, not instructions.&lt;/strong&gt; "We want to lend DVDs" is not a specification. It is a piece of information about the business. The correct response is to understand what it reveals about the domain, and let that understanding reshape the model if necessary. On teams where user stories are treated purely as work orders, &lt;code&gt;DVD&lt;/code&gt; gets added, the ticket is closed, and the model silently drifts further from reality. On teams where user stories are treated as domain conversations, the arrival of &lt;code&gt;DVD&lt;/code&gt; prompts the question that leads to &lt;code&gt;LendableItem&lt;/code&gt; — and the system becomes more correct, not just more complete.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A note on SOLID&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article has used two principles from SOLID without naming them. It is worth naming them now — not to add jargon, but because these principles are widely known and almost as widely misunderstood, and the library example shows exactly what they were designed for.&lt;/p&gt;

&lt;p&gt;SOLID is a tool for domain modelling. Applied to technical layers — controllers, services, repositories, packages — it is the wrong tool for the job. Not because it produces nothing useful there, but because it is answering questions that belong to a different space. Asking whether your &lt;code&gt;BookService&lt;/code&gt; violates the Single Responsibility Principle is like applying flight-route optimisation to a city street map. You will get answers. They will be coherent. They will just not be answers to the right question. The right question is always about the domain.&lt;/p&gt;

&lt;p&gt;When SOLID is applied only at the technical layer, the domain model is typically left untouched — a set of anemic objects with no real behaviour — while all the interesting decisions accumulate in a service class that nobody can coherently describe the responsibility of. The system is, in a narrow sense, well-structured. It models nothing.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth this produces is worth stating plainly: you can apply SOLID perfectly and still end up with a system that does not model the business. The principles do not tell you what to model. They evaluate whether what you have modelled makes sense. If what you have modelled is technical structure rather than domain concepts, SOLID will faithfully validate that structure — and the domain will remain a mess.&lt;/p&gt;

&lt;p&gt;Applied to the domain, the principles are genuinely illuminating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single Responsibility Principle&lt;/strong&gt; is what drove the &lt;code&gt;Book&lt;/code&gt; → &lt;code&gt;Book&lt;/code&gt; + &lt;code&gt;Loan&lt;/code&gt; split. The question it asks is not "does this class do too many technical things?" It asks: does this concept carry responsibility that belongs to a different concept? A book is not responsible for knowing when it was borrowed. That is the responsibility of the loan event. One domain question, one correct answer, one new entity. Applied at the domain level, SRP produces clean, stable concepts with clear boundaries. Applied only at the technical level, it tends to produce &lt;code&gt;BookHelper&lt;/code&gt;, &lt;code&gt;BookManager&lt;/code&gt;, and &lt;code&gt;BookUtil&lt;/code&gt; — classes that exist to split code rather than to model anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open/Closed Principle&lt;/strong&gt; is what drove the &lt;code&gt;Book&lt;/code&gt; + &lt;code&gt;DVD&lt;/code&gt; → &lt;code&gt;LendableItem&lt;/code&gt; + &lt;code&gt;ItemType&lt;/code&gt; move. The principle says a model should be open for extension but closed for modification. In domain terms: when new kinds of things appear, the model should absorb them without requiring existing concepts to change. A &lt;code&gt;DVD&lt;/code&gt; entity requires a code change and a deployment every time a new item type is introduced. &lt;code&gt;LendableItem&lt;/code&gt; with &lt;code&gt;ItemType&lt;/code&gt; instances defined in data requires neither — the model is extended through configuration. The domain is open for new item types and closed against needing to touch &lt;code&gt;LendableItem&lt;/code&gt; to accommodate them.&lt;/p&gt;

&lt;p&gt;The remaining principles have domain equivalents too. But the point here is not to survey all five — it is to show that SOLID belongs in the domain conversation. Bringing it into the technical conversation is not a sequencing problem — it is a category problem. The principles ask domain questions. Technical layers are not a domain. The questions do not apply. It's like applying makeup to a horse. It works but the results have no benefit.&lt;/p&gt;




&lt;p&gt;The model should always reflect the best current understanding of the domain. When that understanding changes, the model changes with it. Not later. Now.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>ddd</category>
      <category>java</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Software Engineering Is Living The Golden Hammer Antipattern — And Everyone Loves It</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 14 Apr 2026 05:25:25 +0000</pubDate>
      <link>https://forem.com/leonpennings/software-engineering-is-living-the-golden-hammer-antipattern-and-everyone-loves-it-3e83</link>
      <guid>https://forem.com/leonpennings/software-engineering-is-living-the-golden-hammer-antipattern-and-everyone-loves-it-3e83</guid>
      <description>&lt;p&gt;Why the industry simultaneously agrees with Brooks and ignores him — and why it's structured to stay that way&lt;/p&gt;

&lt;p&gt;The Paradox Nobody Talks About&lt;/p&gt;

&lt;p&gt;Ask any experienced software engineer about essential versus accidental complexity. They will nod. Ask them about Brooks' central argument in No Silver Bullet — that the hard part of software is the conceptual work of understanding the problem, not the mechanical work of expressing it in code. They will nod again.&lt;/p&gt;

&lt;p&gt;Then watch what happens when the next project starts.&lt;/p&gt;

&lt;p&gt;Someone opens Spring Initializr. Someone proposes microservices. Someone puts Kubernetes in the architecture diagram before a single domain concept has been named. The technology stack is decided in the first week. The business domain is still being understood in month six.&lt;/p&gt;

&lt;p&gt;Nobody in that room forgot Brooks. The choice was never really about Brooks.&lt;/p&gt;

&lt;p&gt;That is the paradox this essay is about. Not that the industry is ignorant of the problem — but that it is structured to reproduce it perfectly, indefinitely, at enormous and invisible cost.&lt;/p&gt;

&lt;p&gt;What Brooks Actually Said&lt;/p&gt;

&lt;p&gt;In 1975, Frederick Brooks published The Mythical Man-Month, based on his experience managing the development of OS/360 at IBM. The project was late, over budget, and initially didn't work particularly well. Brooks spent the rest of his career trying to understand why.&lt;/p&gt;

&lt;p&gt;The insight most people remember is the coordination problem. Adding people to a late software project makes it later. Nine women cannot make a baby in one month. Communication overhead scales quadratically. You cannot parallelise work that is fundamentally interdependent. Everyone knows this. It shows up in every post-mortem, every engineering blog, every conference talk about why the rewrite took three years instead of six months.&lt;/p&gt;

&lt;p&gt;What people remember less clearly is the deeper argument Brooks made in his 1986 essay No Silver Bullet, later added to the anniversary edition of the book.&lt;/p&gt;

&lt;p&gt;Brooks drew a distinction between two kinds of complexity in software. Essential complexity is inherent to the problem itself — the rules, the relationships, the invariants, the genuine difficulty of the business domain being modelled. Accidental complexity is everything else — the tools, the frameworks, the infrastructure, the deployment machinery, the coordination overhead introduced by the way we choose to build systems.&lt;/p&gt;

&lt;p&gt;His claim was precise and devastating: there is no silver bullet because the hard part of software is essential complexity, and no tool or methodology can compress it. You cannot automate your way out of needing to understand the problem. You cannot framework your way past the conceptual work.&lt;/p&gt;

&lt;p&gt;Then he said something that was either ignored or misunderstood: the industry's persistent belief that the next tool, the next methodology, the next architectural pattern will finally solve the problem of software difficulty is itself the symptom of failing to make this distinction.&lt;/p&gt;

&lt;p&gt;That was 1986. Since then the industry has produced structured programming, object orientation, UML, SOA, agile, microservices, event-driven architecture, CQRS, cloud-native development, and AI-assisted coding.&lt;/p&gt;

&lt;p&gt;Each one arrived as a silver bullet. Each one was greeted with the same enthusiasm. Each one was applied before the domain was understood.&lt;/p&gt;

&lt;p&gt;Brooks' own framework predicted every step of it&lt;/p&gt;

&lt;p&gt;The Golden Hammer The Industry Forgot To Question&lt;/p&gt;

&lt;p&gt;There is a well-known antipattern in software called the golden hammer. It describes the tendency to over-apply a familiar tool regardless of whether it fits the problem. Named after Maslow's observation that if all you have is a hammer, everything looks like a nail.&lt;/p&gt;

&lt;p&gt;The modern software industry does not have one golden hammer. It has a coordinated set of them — and they are chosen as a bundle, before the problem is understood, in almost every project that starts today.&lt;/p&gt;

&lt;p&gt;The bundle looks like this: a popular framework for the application layer, microservices for decomposition, an event-driven or REST-based communication model, a cloud platform for deployment, and Kubernetes for orchestration. The specific tools vary by organisation and year. The pattern does not vary.&lt;/p&gt;

&lt;p&gt;What makes this particular golden hammer different from the textbook antipattern is a crucial property: it is unfalsifiable.&lt;/p&gt;

&lt;p&gt;A normal golden hammer eventually gets retired. Something demonstrates it was the wrong tool — the screw still won't turn, the nail bent, the joint failed. There is a moment of visible failure that creates pressure to reconsider.&lt;/p&gt;

&lt;p&gt;The modern software stack has no such moment. If the system runs in production, the stack gets the credit. If the system struggles — if changes are expensive, if the team grows endlessly, if understanding the codebase requires months of archaeology — the blame goes to requirements changing, team turnover, business complexity, or simply the nature of software. The stack is never in the dock.&lt;/p&gt;

&lt;p&gt;This is not an accident. It is a structural property of how software success is defined. A system running in production passes the only test anyone applies. There is no test for whether it could have been built at a fraction of the cost with a fraction of the complexity. Nobody built that version. Nobody ever does.&lt;/p&gt;

&lt;p&gt;The golden hammer persists not because people are lazy or ignorant — but because the thing that should replace it is invisible to every organisational instrument the industry has built.&lt;/p&gt;

&lt;p&gt;Agile Was The Correction. Then It Was Captured.&lt;/p&gt;

&lt;p&gt;In 2001, the Agile Manifesto proposed something that was, underneath its somewhat vague language, a precise epistemological claim.&lt;/p&gt;

&lt;p&gt;Software development is fundamentally a process of learning. You do not fully understand the domain at the start. You build a version of your understanding, expose it to reality — specifically to the domain experts who live in that business every day — and you refine it. Each iteration is not primarily a delivery mechanism. It is a question: did we understand the domain correctly?&lt;/p&gt;

&lt;p&gt;The working software at the end of a sprint is not the point. It is the test. The test of whether your conceptual model of the business — your understanding of what the domain actually is, what rules govern it, what concepts belong together — corresponds to reality. Domain experts are not approving features. They are stress-testing your model.&lt;/p&gt;

&lt;p&gt;That is what Agile was. A mechanism for continuously refining essential understanding through structured contact with reality.&lt;/p&gt;

&lt;p&gt;That is not what Agile became.&lt;/p&gt;

&lt;p&gt;What Agile became was a process for efficiently transcribing user stories into framework components. Two-week sprints. Velocity points. Definition of done. Backlog refinement. The ceremonies survived. The epistemology was quietly discarded.&lt;/p&gt;

&lt;p&gt;And then CI/CD completed the transformation.&lt;/p&gt;

&lt;p&gt;Continuous integration and continuous deployment are genuinely valuable practices for managing the operational complexity of releasing software. But they introduced a subtle and devastating redefinition of what "production ready" means.&lt;/p&gt;

&lt;p&gt;Before, production readiness was at least nominally connected to domain correctness — does this system correctly implement the business? After, production readiness means the pipeline is green. Tests pass. Build succeeds. Deploy proceeds.&lt;/p&gt;

&lt;p&gt;These are not the same question. A passing test suite validates that the code does what the code was written to do. It says nothing about whether the code was written to do the right thing. Whether the domain concepts are correctly identified. Whether the invariants are correctly enforced. Whether the model reflects the business reality or merely the user story that described one interaction with it.&lt;/p&gt;

&lt;p&gt;You can have one hundred percent test coverage and zero domain correctness. The pipeline will be green. The system will go to production. The retrospective will be positive.&lt;/p&gt;

&lt;p&gt;The feedback loop Agile promised — between domain experts and the conceptual model being built — was replaced by a feedback loop between the code and its own tests. We optimised the loop while removing the thing it was supposed to validate.&lt;/p&gt;

&lt;p&gt;The Sociological Lock-In&lt;/p&gt;

&lt;p&gt;So far this looks like an intellectual failure. Engineers and organisations that know better making choices they shouldn't. A problem of discipline or culture that better education might eventually correct.&lt;/p&gt;

&lt;p&gt;It is not. It is structural. And the structure actively selects against correction.&lt;/p&gt;

&lt;p&gt;Consider how a software project begins. Before a single domain conversation happens, several things must occur. The project must be staffed. That requires a job posting. A job posting requires a technology stack. The project must be estimated. Estimation requires a known architecture. The kickoff deck must be prepared. The kickoff deck needs something in the architecture diagram.&lt;/p&gt;

&lt;p&gt;All of these organisational necessities demand a technology decision at the precise moment when the only intellectually honest answer is: we don't know yet. We haven't understood the domain.&lt;/p&gt;

&lt;p&gt;That answer is organisationally impossible to give. So the stack gets chosen. Not out of ignorance. Not out of laziness. Out of genuine organisational necessity. The machinery of project initiation requires it.&lt;/p&gt;

&lt;p&gt;And once the stack is chosen, it shapes everything that follows. The hiring criteria. The team composition. The onboarding process. The architecture decisions. The decomposition strategy. The system that emerges is not primarily a model of the business domain. It is primarily an expression of the technology choices made before the domain was understood.&lt;/p&gt;

&lt;p&gt;This is not the worst part.&lt;/p&gt;

&lt;p&gt;The worst part is what happens at the hiring stage.&lt;/p&gt;

&lt;p&gt;Conceptual thinking — the ability to reason about what a business concept actually is, what it should own, what it should never be responsible for, where the real boundaries lie — is extremely difficult to assess in an interview. It requires time, domain context, and a level of conversation that most hiring processes cannot accommodate. It does not show up cleanly on a CV.&lt;/p&gt;

&lt;p&gt;Tool fluency shows up immediately. Spring Boot, Kubernetes, Kafka, event-driven architecture — these are expressible, searchable, assessable. You can screen for them in thirty seconds. You can test them in a one-hour technical interview. You can verify them with a take-home assignment.&lt;/p&gt;

&lt;p&gt;So organisations hire for tool fluency. Not because they don't value conceptual thinking. Because tool fluency is what their hiring process can see.&lt;/p&gt;

&lt;p&gt;The consequence is a team that reaches for the familiar tools. The team ships systems using those tools. Those systems run in production. The hiring criteria get validated. The loop closes.&lt;/p&gt;

&lt;p&gt;Engineers who push back on premature technology decisions get filtered out at the CV screen, outvoted in the kickoff meeting, or labelled as impractical idealists who don't understand how real projects work. The selection pressure is quiet, consistent, and almost entirely invisible.&lt;/p&gt;

&lt;p&gt;When everyone hired thinks the same way, the golden hammer stops looking like a hammer. It looks like engineering.&lt;/p&gt;

&lt;p&gt;The Cost Nobody Can See&lt;/p&gt;

&lt;p&gt;Here is the claim that cannot be proven and cannot be dismissed.&lt;/p&gt;

&lt;p&gt;A system built with a full modern distributed stack — framework, microservices, cloud infrastructure, orchestration — could in many cases have been built far more simply, maintained by a fraction of the team, and been more correct, more stable, and more responsive to business change.&lt;/p&gt;

&lt;p&gt;That statement cannot be verified. Because the simpler version was never built. Nobody built it. The team that chose the distributed architecture never built the alternative to compare against. The organisation that approved the budget never saw a competing proposal. The engineers who maintained the system never worked on a well-modelled equivalent.&lt;/p&gt;

&lt;p&gt;This is not a gap in the data. It is the mechanism of the problem.&lt;/p&gt;

&lt;p&gt;Brooks identified it precisely: most systems are built only once. There is no second system built with different assumptions, run for five years, and compared on total cost of ownership, ease of change, and conceptual correctness. The counterfactual does not exist. Therefore the cost of the wrong choice is permanently invisible.&lt;/p&gt;

&lt;p&gt;And here is what makes it truly unfalsifiable: the entire industry is paying the same inflated price. There is no reference point. When every team uses the same stack, incurs the same coordination overhead, grows to the same size, and struggles with the same maintenance costs — those costs stop being visible as costs. They become the definition of what software costs. Normal and wasteful become indistinguishable.&lt;/p&gt;

&lt;p&gt;But the difference is not just in cost. It is in what the work actually consists of every single day.&lt;/p&gt;

&lt;p&gt;In a team organised around accidental complexity, the daily work is about the technology. Configuring services. Connecting components. Managing framework upgrades. Fixing pipeline failures. Debugging integration issues. Updating dependencies. Understanding the codebase means knowing which service owns which endpoint and how the data flows between them. The business domain is somewhere in there, translated into controllers and DTOs and event schemas, but it is not what the day is about.&lt;/p&gt;

&lt;p&gt;In a team organised around essential complexity, the daily work is about the domain. Which concept owns this responsibility. What this rule actually means. What the domain expert said yesterday that changed how they understand the model. The implementation follows from that understanding — and because the model is clear, the implementation is the smaller part of the day, not the larger.&lt;/p&gt;

&lt;p&gt;The difference is visible — immediately and without any instrumentation — in the daily standup.&lt;/p&gt;

&lt;p&gt;In one team, the language is technical. Spring, Kafka, the pipeline, the service, the endpoint, the migration. Progress is reported in terms of tickets and story completion. The word "business" appears occasionally, usually in the phrase "business requirement."&lt;/p&gt;

&lt;p&gt;In the other team, the language is conceptual. The Order, the Invoice, the Payment, what a Shipment is responsible for, whether a Client and a User are really the same thing. Technology appears occasionally, usually briefly, because the implementation of a well-understood concept is rarely the hard part.&lt;/p&gt;

&lt;p&gt;You do not need metrics or cost analyses to know which team is working on the right problems. You need one standup.&lt;/p&gt;

&lt;p&gt;If every item on the standup is about accidental complexity — go back. Ask what the essential complexity actually demands. Then and only then choose the technology that serves it.&lt;/p&gt;

&lt;p&gt;If every garage in the world were built to the standard of a luxury hotel, nobody would know a garage could cost less. The price would simply be what it is. The inflated standard would be the only standard anyone had ever seen.&lt;/p&gt;

&lt;p&gt;That is where the software industry is today. Paying Burj Al Arab prices for a garage that needed to store a jar of paint. And maintaining a universal, genuine, unforced consensus that this is simply what garages cost.&lt;/p&gt;

&lt;p&gt;Two Rules That Cost Nothing&lt;/p&gt;

&lt;p&gt;Most prescriptions for this problem are expensive. Hire differently. Retrain your engineers. Adopt a new methodology. Bring in consultants. Run workshops.&lt;/p&gt;

&lt;p&gt;These are not wrong. But they require budget, time, and organisational will that most teams do not have in the moment a project starts.&lt;/p&gt;

&lt;p&gt;There are two rules that cost nothing, require no external help, and can be applied starting tomorrow.&lt;/p&gt;

&lt;p&gt;Do not choose technology upfront.&lt;/p&gt;

&lt;p&gt;Technology enters the project when the domain demands it, not when the kickoff deck needs an architecture diagram. The first weeks of a project produce domain understanding — what the business actually is, what concepts exist in it, what rules govern them. Technology choices follow from that understanding, added only when essential complexity makes them necessary, and only to the degree that it does.&lt;/p&gt;

&lt;p&gt;This feels impossible in most organisations. The job posting needs a stack. The estimate needs an architecture. The kickoff slide needs something in the boxes.&lt;/p&gt;

&lt;p&gt;Those are real constraints. They are also exactly the organisational machinery that inverts Brooks before the first line of code is written. Recognising that the machinery is the problem is the first step toward not letting it make the decision by default.&lt;/p&gt;

&lt;p&gt;Mandate that standups should be about business concepts only. Never technology.&lt;/p&gt;

&lt;p&gt;This is the litmus test made into a practice. If someone says "I'm working on the Kafka consumer," the immediate question is: what business concept does that serve, and does that business concept actually require it? If the answer is unclear, the technology choice is premature. If the answer is clear, state the business concept first and let the technology be the footnote it should be.&lt;/p&gt;

&lt;p&gt;A standup where every item is about services, frameworks, pipelines, and endpoints is a standup where the team has been captured by accidental complexity. It will feel entirely normal. It will sound like engineering. The terminology will be confident and precise.&lt;/p&gt;

&lt;p&gt;But the business domain — the essential complexity that justifies the system's existence — will be invisible. And a team that cannot talk about the business in its daily standup is a team that is not working on the business. It is working on the technology that was supposed to serve it.&lt;/p&gt;

&lt;p&gt;These two rules do not solve the problem entirely. The sociological pressures remain. The hiring pipelines remain. The organisational machinery remains. But they create two moments — one at the start of a project, one every single day — where the inversion becomes visible. Where someone can point at the standup and say: we have not mentioned a business concept in three days. What are we actually building?&lt;/p&gt;

&lt;p&gt;That question, asked consistently, is more powerful than any methodology.&lt;/p&gt;

&lt;p&gt;Closing&lt;/p&gt;

&lt;p&gt;The most expensive software is the software everyone agrees is fine.&lt;/p&gt;

&lt;p&gt;It runs in production. The pipeline is green. The team is stable. The architecture is recognisable. The job postings write themselves. The onboarding takes three months instead of three days, but that is just how software works. The changes take longer than they should, but the domain is complex. The team keeps growing, but the system keeps growing too. The costs keep rising, but software is expensive.&lt;/p&gt;

&lt;p&gt;None of this is inevitable. All of it is a consequence of a single inversion: accidental complexity chosen before essential complexity is understood. A choice made not out of ignorance, but out of organisational necessity, sociological pressure, and the permanent invisibility of the alternative.&lt;/p&gt;

&lt;p&gt;Brooks saw it in 1975. Named it clearly. Watched the industry quote him extensively and change nothing.&lt;/p&gt;

&lt;p&gt;The golden hammer is not a mistake. It is the product. The template is not a shortcut. It is the destination. The assembly is not the means. It has become the craft.&lt;/p&gt;

&lt;p&gt;Two rules. No technology upfront. Standups about the business only.&lt;/p&gt;

&lt;p&gt;They will feel radical. They are just Brooks, applied.&lt;/p&gt;

&lt;p&gt;Everyone agrees with Brooks.&lt;/p&gt;

&lt;p&gt;Then the next project starts.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>java</category>
      <category>architecture</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Fast Onboarding of Software Engineers: The Two Learning Modes</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Fri, 10 Apr 2026 11:43:42 +0000</pubDate>
      <link>https://forem.com/leonpennings/fast-onboarding-of-software-engineers-the-two-learning-modes-52ge</link>
      <guid>https://forem.com/leonpennings/fast-onboarding-of-software-engineers-the-two-learning-modes-52ge</guid>
      <description>&lt;p&gt;There is a persistent belief in software organizations that standardizing on a single framework — Spring Boot being the popular example — makes developers interchangeable across teams. If every system is built the same way, engineers can move between projects with minimal friction.&lt;/p&gt;

&lt;p&gt;It sounds efficient. It feels scalable. It is also largely wrong — and understanding why reveals something important about how developers actually learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Ways to Learn a Codebase
&lt;/h2&gt;

&lt;p&gt;There are fundamentally two modes through which a developer can come to understand a system.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;comprehension-based learning&lt;/strong&gt;. The developer is walked through the core domain concepts — typically in a whiteboard session — and can then trace those exact concepts in the code. The system is legible. Understanding precedes execution.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;execution-based learning&lt;/strong&gt;. The developer runs the system, breaks it, watches it, traces calls through layers. Understanding is assembled gradually from observed behavior. This is the default mode for procedural and layered architectures.&lt;/p&gt;

&lt;p&gt;The practical consequence of this difference is not marginal. Comprehension-based onboarding can bring a developer to effective contribution within &lt;strong&gt;hours to days&lt;/strong&gt;. Execution-based onboarding routinely takes &lt;strong&gt;weeks to months&lt;/strong&gt; before a developer can contribute without close supervision.&lt;/p&gt;

&lt;p&gt;That gap is not a matter of individual ability. It is a structural property of the codebase.&lt;/p&gt;

&lt;p&gt;Pair programming, shadowing, and extensive debugging sessions are not learning strategies in this context. They are compensations — workarounds for the absence of anything readable at the conceptual level. Organizations that rely on them have simply normalized the cost of an illegible system.&lt;/p&gt;

&lt;p&gt;Framework standardization does nothing to change this. Recognizing a controller is not the same as understanding why the endpoint exists, what constraints govern it, or what invariants must never be broken. That knowledge lives in the domain — and in most codebases, it lives nowhere at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comprehension-Based Onboarding Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;In a well-crafted domain model, onboarding follows a different rhythm entirely.&lt;/p&gt;

&lt;p&gt;A developer new to the system joins a whiteboard session — 30 to 60 minutes — where the core domain concepts are walked through. They then open the codebase and can trace those exact concepts in the code. Within an hour, the relationships between concepts — what governs what, what depends on what, what is allowed and what is forbidden — form a coherent picture. By the end of the first day, they can participate meaningfully in design discussions, and in many cases begin implementing new functionality.&lt;/p&gt;

&lt;p&gt;This is not aspirational. It is the direct consequence of a system that makes its intent legible.&lt;/p&gt;

&lt;p&gt;The critical insight is this: new functionality must be grounded in what a system &lt;em&gt;does&lt;/em&gt;, not in how it is written. A developer who understands the domain can reason about where new behavior belongs, what rules it must respect, and how it connects to existing concepts — without having traced a single execution path. That is what makes hours-to-days contribution possible. Without it, the developer has no foundation to build from, and execution-based exploration begins — with all the time cost that entails.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Principles, Not Seniority
&lt;/h2&gt;

&lt;p&gt;This is not about experience level. A junior developer who thinks from first principles — who reasons about what a system &lt;em&gt;is&lt;/em&gt; and what it must never do before asking how it runs — will orient just as quickly as a senior. First-principles thinking is a mode, not a career stage. It is the ability to think and talk in concepts and responsibilities, to ask the right questions before reaching for the debugger.&lt;/p&gt;

&lt;p&gt;Execution-based systems actively disadvantage this kind of thinking. There is nothing to reason from. The only available strategy is empirical — run it, break it, observe. That favors pattern recognition over understanding, and accumulated exposure over insight. It rewards engineers who are good at navigating complexity rather than those who are good at resolving it.&lt;/p&gt;

&lt;p&gt;Over time this has consequences beyond onboarding. The system comes to be understood only by those who have been exposed to it long enough — and institutional knowledge becomes a function of tenure rather than clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most Systems Make This Impossible
&lt;/h2&gt;

&lt;p&gt;The root causes of slow onboarding are almost never the framework. They are structural.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implicit domain knowledge.&lt;/strong&gt; Critical business rules are undocumented and embedded in conditionals, naming conventions, and historical decisions nobody questions anymore. New engineers are forced into archaeology before they can contribute. Every answer is buried somewhere in the execution history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fragmented business logic.&lt;/strong&gt; When behavior is spread across controllers, services, and repositories, there is no single place to understand what the system enforces. Every answer requires assembling fragments from multiple layers — which means execution-based exploration is the only path available, regardless of how familiar the framework feels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflow-centric design.&lt;/strong&gt; Systems modeled around flows — requests, events, pipelines — force developers to reconstruct intent from execution paths. The &lt;em&gt;what&lt;/em&gt; is buried inside the &lt;em&gt;how&lt;/em&gt;. Reading the code tells you what happens; it does not tell you why, or what must never happen.&lt;/p&gt;

&lt;p&gt;These are not framework problems. A Spring Boot application can have a rich domain model. It rarely does, because framework-driven thinking optimizes for &lt;em&gt;how we build&lt;/em&gt; rather than &lt;em&gt;what we model&lt;/em&gt; — and that trade-off silently pushes onboarding from days into months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Domain Model as a Table of Context
&lt;/h2&gt;

&lt;p&gt;A well-structured domain model acts as a compression mechanism for complexity. Core concepts are named clearly. Invariants are enforced in one place. Relationships are explicit.&lt;/p&gt;

&lt;p&gt;This gives the codebase something more useful than a table of contents. It provides a &lt;strong&gt;table of context&lt;/strong&gt;: each concept is not just listed but anchored in meaning and relationship. A new developer does not navigate files — they navigate intent. And navigating intent is something a first-principles thinker can do quickly, regardless of how many years they have been writing code.&lt;/p&gt;

&lt;p&gt;For this to work, the code must speak the language of the business. If stakeholders say &lt;em&gt;DocumentRequest&lt;/em&gt;, the code should not say &lt;em&gt;PayloadDTO&lt;/em&gt;. When the language of the domain is reflected faithfully in the implementation, onboarding becomes a translation exercise rather than a decoding one. Translation is fast. Decoding is slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Useful Side Effect: Simpler Code
&lt;/h2&gt;

&lt;p&gt;Systems with rich domain models tend to produce surprisingly simple implementations. This is not accidental.&lt;/p&gt;

&lt;p&gt;When complexity is resolved at the conceptual level — when the model clearly captures what is allowed, what is forbidden, and where behavior belongs — it does not accumulate elsewhere. There is less need for elaborate orchestration, framework configuration, or infrastructure glue.&lt;/p&gt;

&lt;p&gt;In contrast, systems that lack a strong domain model push complexity into the gaps: coordination logic spreads across components, edge cases get patched rather than modeled, and understanding the system requires tracing runtime behavior rather than reading domain logic. Infrastructure complexity grows not because it is necessary but because the domain complexity has nowhere else to go. This is precisely what makes execution-based onboarding so expensive — the system keeps revealing new layers of implicit complexity the longer you look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Role of Frameworks, Properly Understood
&lt;/h2&gt;

&lt;p&gt;Frameworks are not without value in onboarding. They reduce setup friction, provide familiar scaffolding, and standardize infrastructure concerns. A developer who knows Spring Boot will navigate a Spring Boot project faster than a complete stranger would.&lt;/p&gt;

&lt;p&gt;But this is surface-layer familiarity. It accelerates the first few hours. It does not touch the weeks that follow.&lt;/p&gt;

&lt;p&gt;Onboarding has two layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface layer&lt;/strong&gt; — framework, build tools, deployment setup, API conventions. Fast to learn, low in durable value. Framework standardization helps here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep layer&lt;/strong&gt; — domain concepts, object responsibilities, business rules, architectural boundaries. This is where the weeks-to-months cost lives. Framework standardization does nothing here.&lt;/p&gt;

&lt;p&gt;Most organizations optimize the surface layer because it is visible and measurable. They neglect the deep layer, absorb the onboarding cost as a fact of life, and attribute slow ramp-up to individual developers rather than to the structure of their systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The question of onboarding speed is ultimately a question of legibility.&lt;/p&gt;

&lt;p&gt;A codebase with a well-crafted domain model is legible. A single whiteboard session on the core concepts is enough to orient a developer — because when they open the codebase, those exact concepts are right there, named and structured as explained. The session and the code reinforce each other. From that foundation, a first-principles thinker — junior or senior — can form a complete picture and begin making meaningful contributions within hours to days.&lt;/p&gt;

&lt;p&gt;A codebase without one is an execution environment. You learn it by running it, breaking it, and asking the person who wrote it. That process takes weeks. Often months. And it repeats itself every time a new engineer joins.&lt;/p&gt;

&lt;p&gt;If you want engineers to move between projects effectively, do not standardize the tools. The tools are not the barrier.&lt;/p&gt;

&lt;p&gt;Standardize the clarity of the domain. Make systems understandable rather than developers interchangeable.&lt;/p&gt;

&lt;p&gt;That is the real multiplier.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>java</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>When Distribution Becomes a Substitute for Design — and Fails</title>
      <dc:creator>Leon Pennings</dc:creator>
      <pubDate>Tue, 07 Apr 2026 08:41:51 +0000</pubDate>
      <link>https://forem.com/leonpennings/when-distribution-becomes-a-substitute-for-design-and-fails-5gec</link>
      <guid>https://forem.com/leonpennings/when-distribution-becomes-a-substitute-for-design-and-fails-5gec</guid>
      <description>&lt;p&gt;A lot of modern software architecture—microservices, event-driven systems, CQRS—is not born from deeply understanding the domain. It is what teams reach for when the existing application has become a mess: nobody really knows what’s happening where anymore, behavior is unpredictable, and making changes feels risky and expensive. Instead of asking “What does this concept actually mean and where does it truly belong?”, they ask “How do we split this?”&lt;/p&gt;

&lt;p&gt;That is where a lot of modern architecture begins.&lt;br&gt;&lt;br&gt;
Not in necessity.&lt;br&gt;&lt;br&gt;
Not in insight.&lt;br&gt;&lt;br&gt;
But in the growing discomfort of trying to manage software that was never modeled well in the first place.&lt;/p&gt;

&lt;p&gt;And because the resulting system still runs in production, the cost of that move often remains invisible for years.&lt;/p&gt;

&lt;p&gt;That is one of the most expensive traps in software.&lt;/p&gt;




&lt;h2&gt;
  
  
  Framework Fluency Is Not Software Design
&lt;/h2&gt;

&lt;p&gt;A lot of developers today are highly fluent in frameworks.&lt;br&gt;&lt;br&gt;
They know how to build controllers, services, repositories, DTOs, entities, integrations, and configuration.&lt;/p&gt;

&lt;p&gt;From the outside, that often looks like competence.&lt;/p&gt;

&lt;p&gt;But that kind of fluency can be deeply misleading.&lt;/p&gt;

&lt;p&gt;Because building software out of familiar framework-shaped parts is not the same thing as designing software well.&lt;/p&gt;

&lt;p&gt;The real questions are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;What is the actual business concept here?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What belongs together?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What behavior is intrinsic to the domain?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What is a real boundary, and what is just an implementation detail?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What rules should be explicit in the model rather than implied by orchestration?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real domain modeling is not about applying a catalog of patterns. It is the disciplined, often uncomfortable work of discovering what belongs together, what behavior is intrinsic, and expressing those concepts as clearly and cohesively as possible—whether that lives in modules, functions, or simple objects. The goal is conceptual integrity, not architectural ceremony.&lt;/p&gt;

&lt;p&gt;Without those questions, software tends to take on a very predictable shape: fat service classes, anemic entities, persistence-first design, procedural workflows, business logic smeared across layers.&lt;/p&gt;

&lt;p&gt;The code works. The endpoints return data. The database persists state.&lt;/p&gt;

&lt;p&gt;But the system has not really been designed.&lt;br&gt;&lt;br&gt;
It has been assembled.&lt;/p&gt;

&lt;p&gt;And that difference matters far more than most teams realize.&lt;/p&gt;




&lt;h2&gt;
  
  
  Weak Models Create Cognitive Overload
&lt;/h2&gt;

&lt;p&gt;The cost of poor design does not usually show up immediately. At first, the system still feels manageable. A few controllers. A few services. A few repositories. Everything is still “clean.”&lt;/p&gt;

&lt;p&gt;But over time, something starts to happen. Business rules accumulate. Exceptions pile up. New requirements interact with old assumptions. Concepts that looked simple turn out to be related in ways the software never captured.&lt;/p&gt;

&lt;p&gt;And because there is no strong domain model holding those concepts together, the complexity has nowhere coherent to go. So it leaks—into service methods, orchestration flows, integration glue, persistence logic, special-case conditionals, “helper” abstractions, and coordination code.&lt;/p&gt;

&lt;p&gt;At that point, the team starts feeling something very real:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Nobody understands the whole thing anymore.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that is the crucial moment.&lt;/p&gt;

&lt;p&gt;Because once a system becomes cognitively overwhelming, the team has two options:&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A
&lt;/h3&gt;

&lt;p&gt;Reduce the complexity by improving the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B
&lt;/h3&gt;

&lt;p&gt;Reduce the &lt;em&gt;scope&lt;/em&gt; of the confusion by splitting it apart.&lt;/p&gt;

&lt;p&gt;A lot of teams choose Option B.&lt;/p&gt;




&lt;h2&gt;
  
  
  Distribution Becomes Compensation
&lt;/h2&gt;

&lt;p&gt;This is where architecture often stops being a design choice and starts becoming a coping mechanism.&lt;/p&gt;

&lt;p&gt;When the internal model is weak, teams still need some way to create order. And distribution gives them one.&lt;/p&gt;

&lt;p&gt;So they introduce microservices, event-driven architecture, CQRS, separate read models, ownership boundaries, queues, and asynchronous coordination.&lt;/p&gt;

&lt;p&gt;Distribution, CQRS, and event-driven architecture can have legitimate uses in rare cases of extreme scale or unavoidable organizational boundaries. But in the vast majority of systems, they are not introduced because the domain demands them. They are introduced because the internal model is too weak to provide clarity. What looks like sophisticated architecture is often just confusion hiding behind cleaner service boundaries.&lt;/p&gt;

&lt;p&gt;What they are really doing is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;They are trying to create externally, through distribution, the boundaries they failed to create internally, through design.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that can work. At least for a while.&lt;/p&gt;

&lt;p&gt;A smaller service &lt;em&gt;does&lt;/em&gt; feel easier to understand than a large monolith. A separate read model &lt;em&gt;does&lt;/em&gt; reduce some friction. A queue &lt;em&gt;does&lt;/em&gt; create some local decoupling.&lt;/p&gt;

&lt;p&gt;But none of that means the software has become conceptually better. It often just means the confusion has been sliced into smaller containers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Local Clarity Comes at a Global Cost
&lt;/h2&gt;

&lt;p&gt;That trade is where the real damage happens.&lt;/p&gt;

&lt;p&gt;Because distribution absolutely can create local context. A team can say, “This service owns billing.” And that does help.&lt;/p&gt;

&lt;p&gt;But it is a much weaker form of clarity than a real domain model. A service boundary can tell you &lt;strong&gt;where code lives&lt;/strong&gt;. A good model can tell you what something &lt;em&gt;is&lt;/em&gt;, what it &lt;em&gt;means&lt;/em&gt;, what rules govern it, what its lifecycle is, and what relationships are essential.&lt;/p&gt;

&lt;p&gt;Those are very different levels of understanding.&lt;/p&gt;

&lt;p&gt;And when teams use distribution to manufacture context, they often gain short-term manageability at the cost of long-term agility. Because now the system starts paying the distribution tax: network failure, eventual consistency, contract drift, duplicated concepts, duplicated logic, coordination overhead, deployment complexity, operational burden, and fractured causality.&lt;/p&gt;

&lt;p&gt;And perhaps most importantly: &lt;strong&gt;lost refactorability&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When the model is strong and cohesive, changing your mind usually means a local refactor—sometimes even a delightful collapse of concepts. When boundaries have been hardened into services, the same insight triggers contracts, versioning, migration scripts, and cross-team coordination. The cost of learning is no longer paid in thought, but in infrastructure and politics.&lt;/p&gt;

&lt;p&gt;And in software, changing your mind is not a failure. It is the job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Cost Is Paid When the Business Learns Something New
&lt;/h2&gt;

&lt;p&gt;This is where badly structured software reveals itself. Not when it is first deployed. Not when the first endpoints work. Not when the dashboards are green. But when the business itself becomes better understood.&lt;/p&gt;

&lt;p&gt;Because that is what always happens. Sooner or later, the business learns: these two concepts are actually one thing, this workflow was modeled incorrectly, this rule has important exceptions, this distinction is more important than we thought, or this process should not exist at all.&lt;/p&gt;

&lt;p&gt;That is normal. That is what software is supposed to accommodate.&lt;/p&gt;

&lt;p&gt;A coherent domain model makes that kind of change survivable. A fragmented, distributed, weakly modeled system makes it expensive.&lt;/p&gt;

&lt;p&gt;Note that “coherent domain model” here does not mean the tactical patterns that became associated with DDD—entities, repositories, aggregates, and the rest. Those often added their own accidental complexity. Real modeling is simpler and deeper: it is the ongoing work of refining ubiquitous language and discovering natural conceptual boundaries so that new business insight can be absorbed with minimal violence to the existing code.&lt;/p&gt;

&lt;p&gt;Because now the insight has to travel through APIs, queues, read models, event contracts, deployment boundaries, ownership lines, duplicated rules, and partial consistency guarantees. What should have been a conceptual refactor becomes a cross-system negotiation.&lt;/p&gt;

&lt;p&gt;And that is where the bill arrives. Not because the domain was inherently impossible. But because the architecture froze yesterday’s misunderstandings into today’s structure.&lt;/p&gt;

&lt;p&gt;That is one of the worst things software can do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This So Often Goes Unnoticed
&lt;/h2&gt;

&lt;p&gt;The most dangerous part is that this kind of architecture often looks successful. The system runs. Users use it. The company makes money. So the architecture gets treated as validated.&lt;/p&gt;

&lt;p&gt;But “it works” is one of the weakest standards in software. A system running in production proves only that it is viable enough to survive. It does &lt;strong&gt;not&lt;/strong&gt; prove that it is cheap to change, conceptually sound, structurally coherent, or good at absorbing new understanding.&lt;/p&gt;

&lt;p&gt;Most teams never get to experience how different software feels when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Concepts have a single, obvious home instead of being smeared across services&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Rules are explicit and enforceable rather than scattered in orchestration and glue code&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;New business understanding leads to a clean refactor instead of distributed coordination&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The system invites insight instead of resisting change&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that contrast, the pain of weak modeling hidden behind distribution gets normalized as “just how complex software is.”&lt;/p&gt;

&lt;p&gt;Often, it is not. Often, it is just the cost of weak design hidden behind architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;Much of today’s distributed architecture is not the result of domain insight. It is compensation for the conceptual clarity that was never built into the model. By reaching for separation instead of deeper understanding, teams gain local manageability at the expense of long-term coherence and cheap evolution.&lt;/p&gt;

&lt;p&gt;The problem is that the original lack of clarity doesn’t disappear — it just gets distributed. In the end, the same confusion that made the monolith unmaintainable will make the distributed system fail just as hard, only now it’s far more expensive and painful to fix.&lt;/p&gt;

&lt;p&gt;This is why so much “sophisticated” architecture is, in truth, just sophisticated coping.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>java</category>
      <category>microservices</category>
      <category>cqrs</category>
    </item>
  </channel>
</rss>
