The Architecture Tax — Why Enterprise Software Is Expensive, and Why AI Won't Fix It

I specialize in developing object-oriented java applications that aligns with business objectives, using Domain-Driven Design principles to ensure technical decisions drive tangible value. By focussing on a deep understanding of the business domain, I craft solutions that solve real problems while maximizing ROI. My approach evaluates the cost/profit ratio of every decision—only implementing technologies when the benefits outweigh the costs. I’ve been called in to revive stalled projects and address challenges where others have struggled. My focus is on creating software that not only meets but exceeds business expectations. Whether working with legacy systems or modern frameworks, I select the right technologies to maximize value—not just follow trends. I believe software should be a strategic asset, and this mindset guides every decision I make in development.
The story the industry tells
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.
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.
The argument this article makes is the opposite.
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.
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.
This article is about what is actually in that price tag, and what it would cost without it.
The context problem
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.
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?
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.
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.
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: given a concept in the domain, is there one authoritative location where all rules governing that concept are defined and enforced?
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."
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.
Object orientation was supposed to solve this
The context problem is not new. It is precisely the problem that object-oriented programming was designed to address.
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.
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.
This is what object orientation was for.
What Java enterprise actually practises
The dominant pattern in Java enterprise development — and in enterprise development more broadly — looks like this:
An Order entity holds fields annotated for persistence. Its fields are private, which gives the appearance of encapsulation. An OrderService contains the business logic — the methods that create, modify, and query orders. An OrderRepository handles the database interaction. Data transfer objects carry information between layers.
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.
It is procedural programming.
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 Order entity holds data. The OrderService 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.
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 OrderService loads an Order, 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.
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.
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.
How a procedural system rots
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.
Year one. The system is small. The team is mostly the original team. The rules fit in one or two services. OrderService 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.
Year two. 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 OrderService because that is where the original pricing logic lives. A second exception is added in PricingService 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.
Year three. 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.
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.
No amount of discipline permanently solves a structural problem. Discipline degrades over time and team turnover. Structure does not.
The rich domain model as structural answer
A rich domain model addresses the context problem through structure, not discipline.
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 Order does not have its price calculated by a service. An Order 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.
Consider an order system modelled this way:
public class Order {
private final List<OrderLine> lines;
private final Customer customer;
private OrderStatus status;
private ShippingMethod shippingMethod;
public Money calculatePrice() {
Money base = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
return shippingMethod.applyTo(base);
}
public void confirm() {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Only draft orders can be confirmed.");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Shipped orders cannot be cancelled.");
}
this.status = OrderStatus.CANCELLED;
}
}
The rule that a shipped order cannot be cancelled lives on the Order. Not in OrderService, 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.
The service that orchestrates this is correspondingly simple:
@Transactional
public OrderConfirmation createOrder(OrderRequest request) {
Order order = new Order(request);
inventory.reserve(order);
return OrderConfirmation.of(order);
}
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.
Design pressure as a feature
There is a property of the rich domain model that is easy to overlook: it makes bad design visible before it becomes operational pain.
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.
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.
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.
The business changed. As it always does.
The system above handles standard orders. The domain is coherent. The rules are clear. Now the business introduces a new requirement.
Rush orders. 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.
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.
In the rich domain model, the question the implementation forces you to answer is: what is a rush order? 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:
public class Order {
private final List<OrderLine> lines;
private final Customer customer;
private final boolean rush;
private final ShippingMethod shippingMethod;
public Order(OrderRequest request) {
this.lines = request.lines();
this.customer = request.customer();
this.rush = request.isRush();
this.shippingMethod = rush
? ShippingMethod.EXPRESS
: ShippingMethod.STANDARD;
}
public Money calculatePrice() {
Money base = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
Money withShipping = shippingMethod.applyTo(base);
return rush
? withShipping.multiplyBy(1.15)
: withShipping;
}
}
The rule lives on the Order. 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.
The business changed again.
Three weeks after the rush order feature ships, a new requirement arrives.
VIP customers do not pay the rush surcharge. The expedited shipping still applies — VIPs get the faster fulfilment — but the fifteen percent price increase is waived as a benefit of their status.
This requirement is three sentences of business logic. What it does to a system without context ownership is disproportionate to its size.
In a procedural system, the question is: where does this condition go? The rush surcharge is currently in — actually, let us retrace that. The original pricing was in OrderService. The rush surcharge was added in PricingService because that seemed more appropriate for a pricing concern. The VIP status lives in CustomerService. A rule that says "apply the surcharge unless the customer is a VIP" now requires either a call from PricingService to CustomerService — 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.
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.
In the rich domain model, the question is different and better: who owns the rule that VIP customers are exempt from the rush surcharge? Is it the Order? The Customer? A pricing policy?
This is a domain design question. It has a defensible answer:
public Money calculatePrice() {
Money base = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
Money withShipping = shippingMethod.applyTo(base);
if (rush && !customer.isVip()) {
return withShipping.multiplyBy(1.15);
}
return withShipping;
}
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.
If the pricing logic grows complex enough, the model signals it:
public Money calculatePrice() {
Money base = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
Money withShipping = shippingMethod.applyTo(base);
return PricingPolicy.forCustomer(customer).apply(withShipping, this);
}
The complexity of calculatePrice has surfaced a new concept: a PricingPolicy. 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.
The distributed workaround
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.
This is attempting, through infrastructure, to solve a problem that a domain model solves through structure.
The intuition is understandable. The result is a workaround that costs more than the problem it replaces.
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.
And this is before considering what happens when the action fails halfway through.
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.
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.
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.
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.
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.
The Russian space program used a pencil.
The refactorability that distribution destroys
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.
In a rich domain model, a refactoring is a restructuring of code within a coherent boundary. If PricingPolicy 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.
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.
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.
When distribution is genuinely warranted
Distribution has legitimate use cases. They share a common property: they are external constraints on the system, not assessments of the current domain.
Proven, asymmetric load. 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.
Physical or regulatory constraints. 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.
Notice what is absent from this list: domain concepts that currently appear independent.
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.
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.
The modelling capability problem
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.
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."
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.
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.
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.
But AI will fix this
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.
This is a plausible argument for small systems at early stages. It does not survive contact with the actual problem.
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.
The contradiction arrives quietly. In January, a developer prompts: "add a fifteen percent surcharge for rush orders." The AI implements it, correctly, in PricingService. 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 OrderService, 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.
In a rich domain model, this contradiction surfaces immediately. Both rules must live on Order. 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.
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.
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.
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.
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.
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.
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.
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.
The invisible price tag
Consider what a mature enterprise system built on microservices actually costs, outside the domain work itself.
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.
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.
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.
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.
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.
What the rich domain model actually gives enterprise software
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.
Oversight. When every rule about an order lives on Order, 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.
Insight. 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.
Simplicity under growth. 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.
Preserved optionality. 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.
First principles
There is nothing novel in the argument this article makes.
Structure your thinking before you structure your infrastructure. 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.
The location of a rule is part of the design. 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.
Complexity introduced to compensate for missing structure is the most expensive kind. 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.
What the industry calls the cost of enterprise software is largely the cost of not modelling. 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.
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.
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.
The model will not mislead you. The path of least resistance will.





