The Hidden Complexity of CRUD

The Hidden Complexity of CRUD
The Hidden Complexity

In an age where microservices have become the architectural norm, it’s common to see service interfaces defined as simple CRUD endpoints. At first glance, this approach appears intuitive and easy. After all, CRUD operations map neatly to basic data persistence actions, making it straightforward to manipulate data over HTTP. But underneath this seeming simplicity lurks a hidden complexity that can erode the very principles that microservices are meant to uphold—loose coupling and high cohesion. We must think beyond CRUD to get the best from a microservices architecture.

This blog post was sparked by a debate at work where it was clear that many microservices were built using simple CRUD (Create, Read, Update, Delete), and people did not see the problem. In this blog post, I wanted to dig deeper into why CRUD is bad.

Why Move Away from CRUD?

At the heart of any well-designed service is the principle that business logic and data should live together. The logic that governs how data changes and under what conditions should not be detached from or managed outside the service boundaries. Yet when a service offers raw data manipulation methods—like generic “update” calls—those external consumers are free to directly orchestrate changes to the service’s internal data structures almost unchallenged.

Drawing Lessons from Object-Oriented Design

What's funny is that those same people wouldn't design their classes that way. This CRUD thinking seems to be only prevalent when dealing with (micro)services. That's counterintuitive as the impact of bad design in distributed services is far broader and more detrimental to the overall system than within a single service's boundaries.

Maybe it's because OOP has been around for a while, but microservices and distributed systems are new to many people. In OOP, it has long been understood that objects should not reach into other objects and twist their insides to achieve something. Instead, they send messages and request that the object perform an action on their behalf. The object then enforces its own rules and updates its data accordingly.

CRUD-Based Systems are Brittle

CRUD promotes synchronous request/response communication, which couples the services temporally and operationally. When two services are temporally coupled, they must both be available when they need to communicate. When services are operationally coupled, they run at the level of the weakest link in terms of operational capabilities like scalability, elasticity, performance, latency, fault tolerance and so on.

Defining what I mean by asynchronous is essential as it anchors this particular point I'm trying to make. I loved this talk by Sam Newman, who argues that asynchronous has become such an overloaded term that it's almost meaningless. So here's what I mean by asynchronous in this context:

  • non-blocking, and
  • temporally decoupled.

If either of these conditions is true, then the communication is synchronous. This makes you think about what is synchronous/asynchronous and not simply define it by the protocol used. For example, is request/reply over a message bus asynchronous? It depends. Do you have to block and wait for a response to continue with your work (synchronous), or can you continue and pick up the work whenever the response is received (asynchronous)?

If synchronous communication couples the services operationally and most of the communication is synchronous by virtue of services being CRUD, then we have one big system, albeit distributed. This phenomenon is what Neal Ford and Mark Richards term Architectural Quantum in their books - synchronous calls are just one of the forces behind expanding the Architectural Quantum of a system. 

All this means that you end up with an operationally-coupled, distributed monolith. Microservices built this way accumulate technical debt rapidly, making them difficult to change, difficult to maintain, and prone to cascading failures. In distributed systems, where failures are not just possible but inevitable, this tight coupling exacerbates the impact of any failure, spreading its effects throughout the system and undermining resilience.

"If you're synchronous, you're not resilient" Gregor Hohpe.

CRUD-Based Systems Are Not Evolvable

CRUD complicates system modifications. Exposing CRUD interfaces reveals internal data structures and connects them to business processes that exist outside the service. Teams you may not even know are interested in these data structures will start building new processes on top of it. These new processes often become inefficient and less effective as they try to adapt to the existing exposed data structures. Consequently, the internal data structures end up supporting multiple business processes, which limits their ability to evolve due to concerns about potentially breaking the system in unforeseen ways - CRUD-based systems lack the flexibility for evolution.

CRUD = Untamed Complexity

These services usually have no, or very little, business logic under the guise of simplicity. However, this doesn’t simplify your system; it just relocates complexity. Instead of business processes and rules being baked into your services with the data they operate on, they are pushed up to the consumers and the interactions between them. I used consumers here because I have seen this implemented in many ways:

  • Using orchestrator services
  • Using the UI to orchestrate
  • Relying on the knowledge in users' heads

The worst scenario is when those business processes exist only in users' heads. This means that knowledge is not captured anywhere in the system, making it harder to reason about, optimise, test or know whether changes will break them. Furthermore, Users can change how they do things on a whim, breaking the system in unexpected ways.

The Cynefin framework states that complexity is when the relationship between cause and effect is only understood in retrospect. This is clear in CRUD-based distributed systems because you don't know what will happen when you make changes to the data structures, don't know what other things will break, and don't know which service you need to change when business processes change. Further more by pushing this up to the consumers and their interactions, local complexity is traded in for global complexity. In other words, a big ball of mud is traded in for a distributed version of it - ouch!

Managing Transactions Across Boundaries

Business processes rarely fit neatly within a single entity. They often span many entities and sometimes many services. Let's start with a single service and consider the complexity of handling a transaction that involves multiple entities within that service. If you expose a simple CRUD interface, how do you coordinate these changes correctly and ensure transactional consistency? Especially when the service is unaware of the transaction it's participating in. What if updating one entity succeeded, but the other one failed? What happens now? Do you roll back the first one? Do you attempt a compensatory action? What if the compensation fails? Who manages all this complexity? The user? An orchestrator? If it's the former, that is a massive cognitive load for one user for a single service. What about when the business process spans multiples of these services, each with a mini-workflow to manage? Do we expect every user to have that knowledge in their head? Do we expect every user to be consistent in dealing with these issues?

Putting it in an orchestrator instead is also problematic because those orchestrators will need to understand the internal implementations of the services since the services do not encapsulate that logic. This leads to a web of dependencies and assumptions undermining autonomy and scalability.

Orchestrators in this setup will also contain all the business logic to handle all coordination among the services involved in the business processes, as well as across entities within each CRUD service, making them hard to maintain, complex and brittle. While in the services, not much is gained by removing that complexity because, as stated earlier, those become less independent, less resilient, difficult to test in isolation, complex to change, and operationally and temporally coupled to the orchestrators and other services. 

But those orchestrators just implement Sagas, so what's wrong with that? The issue, in this particular situation, is that you have the opposite of 'smart endpoints, dumb pipes'. It is almost akin to pushing the aggregate root of your services' domain into the orchestration layer. This essentially flips the model on its head. Instead of each service defining and enforcing its business constraints, the orchestrator ends up shouldering that burden—something best avoided if we want to preserve service autonomy, among other things. I'm not saying not to use sagas, but rather that sagas are not a substitute for good design. You need to design your services' boundaries correctly first, not rely on a saga to paper over the cracks. So, what should the service be built around?

What is a Good (Micro)Service Boundary?

This is a divisive topic fueled mainly by the unfortunate misnomer that is Microservices. "Micro" has caused significant confusion when adopting this architectural style, as debates often centre on microservices' size. But size is not a good heuristic. Take those CRUD-based microservices that I have been lambasting throughout this post. They are small but cause a myriad of issues, to put it mildly. They were designed based on size. So if not size, then what? I believe this deserves a separate blog post as it is a nuanced topic but err on the side of transactional boundaries at a minimum. And a business capability boundary, in general, should be the upper limit. Can you implement such microservices using CRUD? No. They are far too complex. These services would have business rules that need to be protected, which is impossible to do with CRUD where you have no clue what the intention of the consumer is and limited control of what they can do to your inner state.

CRUD Has Its Uses

CRUD is not always bad.  It does have its place, namely when dealing with reference data and supporting subdomains in DDD. Most supporting subdomains just manage reference data, and the ones that don't are relatively straightforward. Why is CRUD a good fit? Because:

  • Low Complexity: These services don't involve complex transactions and processes.
  • Little to No Business Logic: There is no, or very little, business logic.
  • Naturally CRUDy: The operations needed to maintain reference data are CRUDy by nature: you create, read, update and delete reference data, and that's all you'll want to do with them.