Sponsor: Do you build complex software systems? See how NServiceBus makes it easier to design, build, and manage software systems that use message queues to achieve loose coupling. Get started for free.
You can follow every enterprise best practice. You can use Clean Architecture, some type of layered architecture, event-driven architecture, microservices, or whatever else is popular.
It can still end up in the same place.
You have a system that is really hard to change.
YouTube
Check out my YouTube channel, where I post all kinds of content on Software Architecture & Design, including this video showing everything in this post.
When you do make a change, you are afraid you are going to break something. Eventually, the same comment keeps coming up:
We would be better off rewriting this whole thing.
The codebase might not even look that bad. It might be organized and relatively easy to follow. Except it is not easy to follow when you actually need to change it. That is when everything becomes convoluted and complicated.
One of the reasons is probably one of these five architectural decisions. In each case, you are making an expensive decision without having enough information.
That is the common thread between all five. You are making expensive decisions before you truly understand the business or the problems you are trying to solve. Rather than solving an actual problem, you are adding technical nonsense as a solution to a problem you might not even have.
The shift is in the question.
It is not:
What architecture should we use? Should we use Clean Architecture or event driven architecture?
The question should be:
What do we understand about the problem that justifies the architectural decision we are about to make?
1. Choosing an Architecture Before Understanding the Domain
The first bad decision is choosing your architecture, tooling, frameworks, and infrastructure without really understanding the business domain.
If the start of a project conversation is about how you need microservices, event sourcing, CQRS, Kafka, and Kubernetes, but nobody can explain the business processes or workflows, you are doing it backwards.
Can anybody explain the constraints? What consistency issues might exist? What are the possible failure modes? Which parts of the system change frequently? Where are delays acceptable?
If nobody can answer those questions, why are you already deciding on the solution?
Architectural patterns and tooling are not a bingo card. They are not a checklist. Every one of them comes with tradeoffs and added complexity.

You want independent deployability? Now you have distributed operations.
You want to scale parts of the system independently? Now you have network failures to deal with.
You want team autonomy, with different teams managing their own services and deployments? You are going to have cross service and cross team communication. Even if that communication is asynchronous and uses events, there is still a contract that needs to be managed.
You want isolation between boundaries? You are going to have consistency challenges across those boundaries.
There is always a tradeoff. There is always added complexity. You might be getting a solution, but that solution has a cost.
The conversation should not start with whether microservices are good or bad. That is irrelevant. The focus should be on the forces acting against your system and the problems you are trying to solve.
Do you have consistency issues that need to be addressed? Is there a part of the system where the rules change quickly and should be isolated? Does a workflow contain delays that downstream processes need to account for?
Those are the questions you should be asking.
Do not start with, “Should we use this pattern or that pattern?” Use the pattern that fits as a solution to the problem you actually have.
If you are making technical decisions first, you are guessing that you are eventually going to have the problem those decisions are supposed to solve.
2. Building Entity Services Driven by CRUD
The second bad decision is what I call entity services. This is where the system is completely driven by its data model rather than its behavior.

At first glance, the code can look great. It might be well organized. Maybe you have Clean Architecture or some type of layered architecture. You have controllers, services, and repositories that interact with the database.
But the entities are really just tables or object graphs representing customers, orders, products, and other records.
Ultimately, you just have CRUD.
Consider a simple order with an ID, a customer ID, a status, and a total. What does that model tell us?
It tells us that some data exists. It does not tell us anything about how an order behaves.
Can you cancel an order? You would assume so, but how does that happen? Do you just change the status? At what point can the order be shipped? Does it need to be in a particular status? Can it be changed after inventory has been reserved?
The model tells us none of that. It only gives us data.
Where does all the business logic go when the system is driven by CRUD and entity services?
Often, it stays in the end users’ heads. They are the ones who actually understand the processes and workflows.
Some of that logic will make its way into the system, but it is usually sprinkled everywhere. It might be in controllers, message handlers, stored procedures, or increasingly, frontends that contain most of the actual business logic.
CRUD itself is not bad. There are always parts of a system that are inherently CRUD. Referential data and simple lists might have no meaningful behavior behind them. They are CRUD, and they should be treated that way.
The issue is assuming everything is CRUD. It is developing everything as if it were just an updatable record when it is not.
There is a significant difference between an operation called UpdateOrder, where you change properties on an order, and an operation called CancelOrder, where you explicitly communicate that the user intends to cancel an order and provide the reason.
That is not simply CRUD. It expresses behavior and business intent.
The question you need to ask is:
Does the operation I am performing communicate business intent?
In this example, the intent is to cancel the order. Once that intent is explicit, you can start asking useful questions.
Can the order still be cancelled based on how long ago it was placed? Has inventory already been reserved? Has the order been shipped? Does the customer need a refund?
Those questions are derived from the intent of the operation.
When everything is treated as a generic update, you lose that intent. You also lose the natural place where business rules, validation, and workflow decisions should exist.
3. Using the Database Schema as an Integration Point
The third bad decision follows directly from the second. It comes from thinking only about the data model and using the database schema as an integration point.
Imagine two parts of a system: sales and warehouse.
They use a single database instance, but the database is somewhat separated into data related to sales and data related to the warehouse.
Sales interacts with the data it owns. But then it also reaches over and queries data that appears to belong to the warehouse. Potentially worse, it writes directly to warehouse data.
Who actually owns that data?
Is it sales, or is it the warehouse?
At that point, there is no real separation and no clear ownership. The database schema has become the integration point.
Some people will say they do not care. They have one large database, and the different parts of the system need consistency constraints. They want to use the database directly.
That can be fine, as long as you understand the implications. You have no explicit contract. You are treating the database as shared storage.
The problems usually start when you need ownership.
When data is written, who controls how it is written? If a table, collection, or stream needs to change, who owns that change? Who needs to be involved? Which other parts of the system could break?
Without clear ownership, you do not know.
One solution is to have the warehouse expose an API. That API becomes a contract that sales can use to send requests or retrieve information.
A database view can also be a contract. A view can explicitly define how other boundaries are allowed to access data. If you create a view for that purpose and treat it as a contract, it can be a completely valid alternative.
The important distinction is that sharing a database instance is not automatically a problem. Different boundaries can own separate schemas within the same database instance.
The problem is the lack of ownership.
When the database becomes a free for all and anything can read or change any data, you eventually start breaking things.
Suppose an order gets into an invalid state. How did it get there?
You have no idea.
Anything could have changed it. The update might not have gone through the actual process, business rules, and validation required to enter that state correctly.
The order ended up in an invalid state because nobody clearly owned the process of changing it.
4. Creating Abstractions Before You Know What Varies
The fourth bad decision, and arguably my biggest pet peeve, is building abstractions when you do not know what varies.
It usually starts with a reasonable sounding idea.
Maybe we will need to swap something out later.

You begin with a shared service. Then you notice some patterns, so you create a generic workflow engine. After that, you start thinking about what happens if you change the database or messaging library, so you create abstractions around those as well.
Only after all of that do you start building the actual application.
It sounds like it makes sense. We are all taught to value reuse. There is also the constant “what if” question.
What if the underlying provider changes? We will have an abstraction. We can create a new implementation behind it without rewriting large parts of the application.
For example, maybe we want to replace our messaging library. If everything is behind an abstraction, that should be easy.
I understand the reasoning. The problem is that if you only have one implementation of the abstraction you are creating, you probably do not have an abstraction.
You do not have enough information.
You do not understand the other concrete implementations, where they overlap, or where they differ. You are trying to generalize something when you only understand one example.
Messaging libraries are a good example. RabbitMQ and Azure Service Bus have significant differences. Kafka is different again. They might all appear to involve sending and receiving messages, but they have different semantics.
If you start with one of them and immediately build an abstraction around it, that abstraction will be shaped entirely by the only implementation you know.
You did not remove the coupling. You hid it behind an interface.
Are abstractions bad? Of course not.
But a bad abstraction pretends to remove coupling when it does not. It often makes the system harder to understand because developers now need to understand both the abstraction and the concrete technology hidden behind it.
You should create abstractions based on actual variation that you understand, not variation you are imagining might exist someday.
5. Building for Scale You Do Not Have Yet
The fifth bad decision is building for scale you do not have yet.
“Yet” is the important word.
This means paying all the upfront complexity to build for a level or type of scale that you do not know you will ever have.
That does not mean you should design a system that cannot scale if the need arrives. It means you should not pay the entire cost upfront based on hypotheticals.
We might have millions of users.
We might need to replace the database.
This might eventually become a global system.
What do any of those statements actually mean?
When you say the system needs to scale, are you talking about more users, more data, more transactions, or more geographical regions?
“It needs to scale” is not a requirement.
This is not about ignoring scale. It is not about assuming growth will never happen. It is about defining clear boundaries and managing the coupling between those boundaries.
You also need to understand that logical boundaries and physical boundaries are not the same thing.
When you define logical boundaries and avoid coupling them unnecessarily, you give yourself a much better chance of scaling different parts of the system if that need actually appears.
This is also why the conversation about modular monoliths versus microservices is often misleading.
People assume microservices automatically scale better because everything is independent. But a modular monolith and microservices can often be scaled in the same ways.
Once you understand that logical and physical boundaries are different things, the idea that these architectural styles are complete opposites starts to fall apart.
You can define meaningful boundaries without immediately turning every boundary into a separately deployed service.
Start with the boundaries. Decide on the physical deployment model when you have enough information to justify it.
Expensive Decisions Require Real Information
These five decisions cause a lot of pain in software systems.
You make technical decisions first without understanding the domain.
You treat your data model as if it were a domain model, while the actual workflows remain in users’ heads or are scattered throughout the system.
You have no clear ownership, turning the database into a free for all where anything can read or change data anywhere.
You create abstractions without understanding what they are supposed to abstract.
You build for hypothetical scale and pay the cost of complexity before you know whether you will ever need it.
In every case, the problem is the same. You are making an expensive decision without enough information.
Architecture should not start with patterns, frameworks, or infrastructure. It should start with understanding the business, the workflows, the constraints, and the actual problems the system needs to solve.
Then you can make the architectural decision that fits.
Join CodeOpinon!
Developer-level members of my Patreon or YouTube channel get access to a private Discord server to chat with other developers about Software Architecture and Design and access to source code for any working demo application I post on my blog or YouTube. Check out my Patreon or YouTube Membership for more info.




