Architecting to Scale - Part 1 - Carving the Monolith

September 25, 2024#Software Development
Article
Author image.

Jeff Valore, Senior Consultant

Welcome to the series “Architecting to Scale”. In this series we are going to look at an iterative approach to scaling your applications, whether as a ‘monolith’ or ‘microservices’. This series is a mix of “application architecture” and “system design” with the goal of helping you decide on an approach to effectively handle the load required by your application, while keeping in mind a path to larger scaling if it is needed in the future. This series is language and hosting provider agnostic, but some C#/.NET examples and tools may be referenced.

Microservices have had a lot of hype in recent years, with promises of big scalability. But is the added complexity worth it? Does your business even need that level of scale? This series will look at ways to improve scalability, possibly without having to jump to the extreme of breaking things into microservices.

Starting with a basic monolithic application, we’ll see some approaches to scaling, and progressively move toward microservices, but for a real-world application, the key takeaway here is that you don’t necessarily need to jump directly to the most complex microservice solutions. There is likely a middleground option that will fit the needs of your business without as much complexity.

Part 1 - Carving the Monolith

There is a reason the “monolith” (one application that contains all the business responsibilities) has been so prevalent for decades.

  • Easy to develop
  • Easy to debug
  • Easy to deploy
  • IDEs have great tooling to navigate and visualize code in a single project

However at a certain size, they start to be more difficult to scale to match the necessary load. It should come as no surprise that as applications grow, there starts to be a desire to pull apart pieces into their own “services” that can be scaled individually, usually to reduce overall load on critical business functions so they don’t become blocked.

This often becomes a challenge, as monolithic applications are usually not designed to be divided. Coupling of internal classes usually makes it difficult to remove any one part of a system without carrying over all the other parts that it depended on.

Let’s begin this series by looking at architecting our monolithic application to be internally segregated, making it easier to pull apart later.

The problematic “N-Tier” architecture

N-Tier Monolith example diagram

We’ve all been here. Build an application split into “layers” by general responsibility. The result is almost always functional software, but with a highly coupled internal structure, where all the classes or components within a layer may depend on each other, or a spider-web of internal dependencies across layers. This is not conducive to breaking the application apart into multiple services later.

Splitting by Feature

Vertical slice monolith diagram

A better way to organize the internals of your application is to plan as if the application will be split into separate services eventually, even if you may never actually get to that point.

You may recognize this as a “Vertical Slice Architecture” or a “Modular Monolith” but I prefer not to put a definite label on it. The specific architecture choice could be a nuanced one that is outside the scope of this discussion, but…

The key architectural pattern here is to design your application into segments that function independently. They can internally follow an “n-tier” approach if you desire, but more importantly, where a segment of the application needs to interact with or influence another, it should do so in a decoupled / indirect way. Think of each feature as if it were its own “microservice”, but build it in a single application. This will enable these segments/features to be easier to pull off into their own service later, if needed.

Internally, you could split like functionality by module if desired. This lends itself to a “modular monolith” architectural style. Cross-cutting and infrastructure concerns that are not specific to a feature can be pulled into their own reusable code modules and dependency injection used to provide them to your application’s feature segments.

Vertical slice architecture organized into modules

Decoupling Modules with Mediator or Messaging patterns

What helps us keep these parts of the application decoupled internally is to leverage either the “Mediator” pattern, or use a “Message Bus” internally.

When designing messages, it can help to try to adhere to a “CQRS” style of messaging, where Commands (updaters) and Queries (readers) are separated. Separating data readers from writers can be helpful for future scaling where you have DB intensive operations. We will discuss that more in Part 2 of this series.

Choose a mediator or messaging library suitable for your needs. To make it easier to pull services off the monolith, consider one that uses an in-process / in-memory message queue, but can later leverage an external transport like RabbitMQ or AWS SQS. In the C#/.NET space, these tools are recommended:

Let’s take a deeper look into what it might look like for features to communicate in a message-based way:

Diagram showing application internal communication between features using messaging

Here, we have our Submit Order feature. Submitting of an order can be triggered by 2 means:

  1. An external user can submit their card by calling the /cart/submit API endpoint.
  2. Another part of the application (in this example the Backorders feature) can submit an order after an item is back in stock.

In both cases, the core logic for order submission is in the SubmitOrderCommandHandler, which is executed by a “Command” message. A Command is part of the CQRS pattern and intended to initiate an action that would update data.

The Submit Order API RequestHandler and the Backorders classes do not contain any reference to the SubmitOrderCommandHandler. They utilize the Mediator pattern to indicate that an order needs to be submitted by publishing a SubmitOrderCommand message, and it is the mediator’s responsibility to route that request appropriately. Our classes are completely decoupled here.

After the order has been submitted, the SubmitOrderCommandHandler broadcasts a OrderSubmittedMessage to any listeners that may care to act upon it. “Messages” like this tend to be in a past tense and indicate that something happened in the system.

In this example the Send Order Email feature is listening for the message, and will send the customer an Email notification when an order is submitted. Note that again here, the Submit Order and Send Order Email features are completely decoupled. They do not need to have any direct reference to each other to perform their business use case.

Also, notice that this diagram doesn’t even include the “module” or “application” delineation boxes any more. Would it matter if it did? The features in this final diagram could be packed into a single deployable/executable project, or be individually deployed. You can’t really tell from the diagram, but it also wouldn’t matter because they are decoupled. The deployment and communication become an infrastructure detail, not a business logic detail.

In-Process vs External Message Transports

Diagram comparing in-process message bus transport versus an external transport

While building an initial monolithic application, using an in-process / in-memory message transport is beneficial.

An in-process transport has several advantages:

  • Speed, since messages can be passed by-reference and not serialized to send to an external message queue.
  • No latency, since there is no network request involved.
  • Better debugging experience. In most IDE debuggers you should be able to step-debug across message sender and receiver.

However once you need to communicate with other processes, you will need to involve an external transport. My recommendation would be to use an in-memory transport where you can, until you need to communicate outside your process. Even once you spin off separate services, it would be performant to retain an in-process transport bus for internal service communication, so you may end up with 2 message buses for routing internal vs external messages.

Where To Message vs Dependency Inject?

Diagram showing the different between a feature built with messaging vs one built with dependency injection. Both are acceptable choices.

There is often a point of contention about where you should adopt messaging / mediator patterns for decoupling. This can be an opinionated topic, but like all things, I think it depends on the likelihood that the items will ever actually need to be separated. The argument could be made that all communication should be made over a mediator or message pub/sub pattern, but I think this could be an over-optimization and add unnecessary complexity.

Because our goal here is to make features or “vertical slices” that could be spun off later into their own application, my recommendation is to use messaging between features to keep them independent. However, within each of those feature slices, I don’t see the value in ever pulling them apart. If messaging adds too much complexity, just use DI within the feature vertical.

The messaging is really about defining an interface for the features to communicate in a decoupled way. It doesn’t have to also be used internal to each feature.

Key Takeaways:

  • Start with a monolith…
  • …but think about independent Services.
  • Structure code into discrete parts that make sense to pull apart later, even if you may never need to.
  • Leverage the mediator, publish/subscribe, or other messaging patterns to decouple features, even within a monolith.
  • Attempt to segregate Readers and Writers along the lines of a CQRS pattern.
  • For messaging between independent features, try not to expect response objects.
  • Start with an in-process messaging technology for improved performance and debugging.

Upcoming in Part 2: Scaling the Monolith

At this point, we’ve talked about architecting the internals of our monolithic application to set us up for later scaling successes.

In the next part of this series, we will take the feature-sliced monolith we have so far, and look at different ways to start to scale it, and discuss the considerations we should make before pulling out separate services.


Copyright © 2024 NimblePros - All Rights Reserved