Skip to content

Decoupling in Software Architecture Moves Complexity

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.

Learn more about Software Architecture & Design.
Join thousands of developers getting weekly updates to increase your understanding of software architecture and design concepts.


What happens when someone clicks the Place Order button?

To the end user, it is pretty simple. It is a button click. But in a large system, there can be a lot going on behind that button. We have to save the order, record the payment, charge the customer’s credit card, reserve inventory, send a confirmation email, and maybe record loyalty points. There may also be analytics, recommendations, or other processes that react to the order.

Because there are so many things happening, there are also a lot of things that can go wrong.

We are often told that decoupling is great. Use abstractions. Use dependency injection. Replace direct calls with messages and queues. Use publish subscribe and event driven architecture.

All of those things can be useful, but they are not goals. More importantly, they are not free.

Every step you take away from an in memory method call adds a new kind of complexity and a new cost. You might gain independent deployability, extensibility, scalability, or different availability characteristics. But you are introducing something else that you now have to understand, operate, and debug.

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.

You Did Not Remove the Complexity

It is easy to look at a large procedural workflow and think decoupling will clean everything up.

If one button click has to save the order, send an email, update loyalty points, charge the payment, and reserve inventory, doing all of that inline in one place can become a hot mess. Splitting those responsibilities apart seems like it should reduce complexity.

But it does not.

You did not change the amount of complexity in the business process. You did not make the payment, inventory, email, and loyalty concerns disappear. You moved the complexity somewhere else.

That is the better way to think about decoupling. It is a spectrum, and the question is not how far you can move toward a fully decoupled system. The question is where your business case belongs on that spectrum.

You might use direct in process calls. You might introduce abstractions with interfaces and dependency injection. You might add temporal decoupling with a queue. You might use publish subscribe with independent consumers.

This is not a maturity model. The goal is not to reach a system where everything runs through events. If you force that approach everywhere, you can end up in a pile of pain. It is the classic round peg, square hole problem.

The goal is to find what fits best where.

Direct Calls Give You Traceability

Start with the most direct approach. The user clicks Place Order, and everything happens procedurally inside the same process.

Maybe the application saves the order first. Then it reserves inventory, charges the payment, records loyalty points, and sends the email. Everything runs through direct execution, and most code works this way whether we realize it or not.

There are a lot of advantages to this approach because it is easy to follow. You can look at the code, go to a definition, add breakpoints, and inspect the entire call stack at any point. The ordering is explicit because the code tells you exactly what happens and in what order.

That traceability matters. When something fails, there is a clear place to start looking. There is a call stack. There is a path through the code. You can step through the workflow from beginning to end.

The downside is that everything is highly coupled.

Even if saving the order, charging the credit card, and sending the email live in different classes or modules, they are still coupled through the workflow. We have defined that all of those things run together when the user clicks that button.

That also creates shared availability. If the payment processor, email provider, inventory system, and database are all part of the request, they all need to be available at the same time. One unavailable dependency can affect the entire operation.

As this grows, it can lead to the big ball of mud, or what I sometimes call the turd pile. You get a spaghetti fest of code because more and more responsibilities are tied together through one procedural flow.

Direct calls give you traceability, but they also give you coupling and shared availability.

Abstractions Create Seams, but Also Indirection

The next step is often to stop depending directly on concrete implementations.

Instead of coupling the order workflow directly to Stripe, an email provider, or a specific inventory implementation, we introduce abstractions. The order workflow might depend on an IPaymentProcessor, an inventory reservation interface, and an order email interface.

We are no longer depending on the concrete implementation. We have created seams, and those seams can become useful boundaries. They might represent functional boundaries, hide implementation details, and give us some extensibility.

The caller does not need to know the minor details of how Stripe works. It just needs to know that it wants to record a payment or charge a customer’s credit card. The details belong behind the abstraction.

There are real benefits here. We can have different implementations. We can swap providers. We can use a test implementation. A SaaS application might even choose a payment processor at runtime based on the customer.

But the cost is indirection, especially at runtime.

When you are looking at an interface in the code, the interface alone does not tell you what actually happens. Maybe the runtime implementation is Stripe. Maybe it is Square or PayPal. Maybe there are decorators around it for retries, logging, metrics, or other behavior.

The interface is only part of the story. The real behavior depends on how everything is wired together at runtime.

This becomes especially painful when every class has an interface, but every interface only has one implementation. Why does the interface exist? Who knows.

It is not creating a meaningful boundary. It is often just an abstraction built directly from the single implementation underneath it. You do not yet understand what the abstraction should represent because there is no real variation. You are just adding noise.

Abstractions are useful when they create a seam that matters. They are not automatically useful because an interface exists.

Temporal Decoupling Changes the Meaning

The next step is temporal decoupling.

When the user clicks Place Order, we no longer need to process everything immediately. The Order API can place a message on a queue and return a response to the customer. A separate process can pick up that message and continue processing the order later.

This gives us different scaling and availability options. If there is an influx of orders, they do not all need to hit the database, email provider, inventory system, and payment processor at the same time. Messages can wait in the queue and workers can process them at the rate the system can support.

The producer also does not need the consumer to be available at that exact moment. The worker can be temporarily offline. It can process the message later. We might have a service level expectation for how long that should take, but the producer and consumer are no longer coupled in time.

That is useful, but the biggest change is not technical. It is semantic.

There is a difference between an order being completed and an order being accepted.

With direct execution, the user clicks the button and the entire workflow may finish before they receive a response. If the credit card is declined, they know immediately.

With a queue, the application might respond that the order was placed when it has really only accepted the request for processing. The payment may not happen for another minute. If the system is backed up, maybe it takes five minutes.

So what happens when the credit card is declined?

Before, the user knew immediately. Now the application has already told them the order was placed. The communication style has to change. Maybe the UI shows that the order is processing. Maybe the customer receives an email if the payment fails. Maybe the system retries the payment. Whatever the answer is, the experience is different.

You often cannot take a direct procedural workflow, make it asynchronous, and expect everything else to stay the same. The UI, the user experience, and the meaning of the response all need to reflect that work is now happening later.

Messaging Brings Technical Consequences

Queues are useful because they decouple producers and consumers in time, but they also bring technical details that you immediately have to deal with.

You need to think about retries. If a downstream service such as Stripe is unavailable, should the message be retried? How often? For how long? What kind of backoff should be used?

You need to handle duplicate delivery. A timeout can make it unclear whether a message was processed, which can cause the same message to be delivered again. Your consumer needs to handle that without producing a bad side effect.

That leads to idempotency. Can the same message be processed more than once without charging the customer twice, reserving inventory twice, or recording the same loyalty points twice?

You also need to think about poison messages. A message might fail every time it is processed, continually taking up a worker slot and preventing useful work from moving forward.

That is where dead letter queues come in. If a message cannot be processed, you probably do not want to lose it. You may want to move it somewhere else so it can be investigated or reprocessed later.

Then there is the outbox pattern. If you save state to your database and also need to publish a message, how do you know both will happen consistently? You do not want to persist the order but fail to publish the message that starts the rest of the workflow.

These are not random implementation details. They are consequences of choosing messaging.

The other major change is that you lose the in process call stack. Distributed tracing with OpenTelemetry can help you understand what happened across processes, but it is not the same as stepping through one call stack in a debugger. The workflow now crosses process boundaries, and debugging changes with it.

Temporal decoupling gives you time and availability flexibility, but now you are operating a distributed workflow.

Independent Consumers Give You Extensibility and Invisible Behavior

At the far end of the spectrum are independent consumers using publish subscribe and event driven architecture.

I am a fan of event driven architecture when it is used correctly. I also advocate against using it where it does not fit or treating it as the goal. It is not the goal.

The main benefit is that the producer and consumers are deeply decoupled. From the publisher’s point of view, there may be no consumers. There may be two. There may be one hundred. The publisher does not need to know who they are.

The Order API can publish an OrderPlaced event. That event can then be distributed to consumers responsible for inventory, email, loyalty, analytics, recommendations, or anything else that needs to react.

The Order API does not have to coordinate all of those downstream services. New consumers can be added without changing the publisher. Existing consumers can change how they work and evolve independently.

Maybe a new recommendation engine is introduced later. It can subscribe to the event without the Order API needing to know anything about it. That is real extensibility.

But the same decoupling that gives us that flexibility also makes system behavior harder to see.

When an event is published, what actually happens?

There may be many consumers reacting to it. Some may publish more events. Those events may trigger more consumers. If the workflow is built through event choreography, there may not be one place where you can see the full behavior from beginning to end.

There is no single method to step through. There may not be a single stack trace. The behavior can feel invisible because it is spread across consumers, processes, queues, topics, and contracts.

Independent consumers can evolve, but now you have to live with invisible behavior.

Events Are Contracts

Publish subscribe also changes how you need to think about events.

Events are contracts. You need to treat them that way.

You cannot change an event whenever you feel like it without considering the consumers using it. You may not even know who every consumer is, but that does not mean they do not exist.

Events need to be versioned or changed in a backward compatible way. Consumers need time to move forward. Publishers need to understand that once an event is in use, its shape and meaning matter outside the boundary of the publisher.

This is another place where complexity moved. The producer no longer coordinates every downstream action, but now the event contract needs to be stable enough for independent consumers to rely on it.

Where the Complexity Lives

Where you land on the spectrum determines where the complexity lives.

With direct calls, the complexity lives in the code. It is in the procedural flow, the call graph, the error handling, the ordering, and the shared availability of everything involved.

With abstractions, the complexity lives in the seams you define and the runtime wiring. You need to understand what the abstraction represents, which implementation is being used, and what decorators or other behavior surround it.

With queues, the complexity lives in time and infrastructure. Can something happen later? How much later? What happens when processing fails? How do retries, duplicate delivery, idempotency, dead letter queues, and the outbox pattern work?

With events, the complexity lives in contracts and system visibility. Consumers can evolve independently, but the full behavior of the system becomes harder to see.

This is the tradeoff:

  • Direct calls give you traceability, but they create shared availability.
  • Abstractions give you replaceable seams, but they add indirection.
  • Queues give you temporal decoupling, but they make the workflow distributed.
  • Events give you independent consumers, but they create invisible behavior.

None of these options is automatically better than the others.

Choose What Fits the Business Case

Sometimes the right answer is a direct call. Sometimes it is an abstraction. Sometimes the work can happen later, so a queue makes sense. Sometimes independent consumers genuinely need to react to an event without the producer coordinating them.

The right answer depends on the context.

Do not use decoupling because you were told good architecture is decoupled architecture. Do not introduce interfaces because every class is supposed to have one. Do not use a queue because asynchronous processing sounds more scalable. Do not publish events because event driven architecture feels like the final level of architecture.

Ask what you are gaining and what cost you are accepting.

Every step away from an in memory method call changes where the complexity lives. You might improve scalability, availability, extensibility, or independent evolution. But the complexity did not disappear.

You moved it.

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.