Balancing Business Needs: Evaluating Architecture in Domain-Driven Design
One topic has come up again and again over the years, sometimes more, sometimes less, in conversations among developers: What’s the best way to modularize software? As a longtime software developer, I’ve seen the tides shift and a variety of approaches rise and fall. But amidst the talk of microservices, one concept that has always caught my attention is the Modular Monolith, especially when used in the context of Domain-Driven Design (DDD).
Understanding Modular Monolith and Microservices
First and foremost, let’s clarify our terms:
- Modular Monolith: It’s essentially a monolithic architecture where the application is divided into loosely coupled modules. Each module focuses on a distinct concern, ensuring separation of concerns.
- Microservices: Here, the application is broken down into smaller, independent services that run as separate processes. These services often communicate through APIs or messaging systems.
Domain-Driven Design and Bounded Context
Central to my approach is the concept of Domain-Driven Design (DDD). DDD is all about understanding the business domain, designing based on its intricate complexities, and aligning technical solutions to meet business needs. Within DDD, a critical component is the bounded context. It’s a clear boundary within which a specific domain model is defined and applicable.
Imagine our “sell books online” example. The process of selling books can be broken down into various sub-domains, such as catalog management, order processing, and invoicing. Each of these can be seen as a bounded context, allowing us to focus on one specific part of the business at a time.
Modular Monolith vs. Microservices
I often find myself at the crossroads of modular monolith and microservices. And for good reason — both have their unique strengths.
Separation of Concerns
- Modular Monolith: Within a modular monolith, we achieve a high degree of separation but within the confines of a single application. Think of it as rooms in a house. Every module, like our hypothetical ‘user reviews’ segment, is a unique room. While they are part of the same building, they serve distinct purposes and maintain their boundaries.
- Microservices: Microservices elevate this concept, treating each concern as not just a separate room, but an entirely different building. The ‘user reviews’ would be its standalone service, with its runtime, database, and potentially even its tech stack.
- Modular Monolith: Module communication is internal, making it inherently faster. My preference for event-driven mechanisms and using interfaces and dependency injection here is to streamline this communication, ensuring modules converse without stepping on each other’s toes.
- Microservices: Here, services communicate over the network. It allows for a diverse tech stack and independent deployments but can introduce latency. An event-driven architecture is often employed here, but it comes with the overhead of network calls.
- Modular Monolith: The ‘share nothing’ idea is particularly striking within a modular monolith. Each module operates with its dedicated data store, data and business models, all contained within the overarching application. In practice, this might mean in terms of data using a single PostgreSQL instance where each module has its separate schema, ensuring no data overlap. This separation ensures that data from one module doesn’t mix with or impact another. For instance, in our “sell books online” example, the ‘user reviews’ module might operate within its distinct schema, ensuring that a surge in activity in, say, ‘order processing’ due to a big sale doesn’t directly affect or slow down the user reviews module. But it could be also separated instances or engines.
Architectural representation of the modular monolith system for an online bookstore. Each module communicates with its designated data store, ensuring the ‘share nothing’ idea in terms of data management.
- Microservices: The ‘share nothing’ approach in microservices is more about complete operational independence. Each microservice naturally has its separate data store, ensuring that it operates without any direct dependency on others. However, while this gives each service more autonomy, it can introduce challenges, especially when you need transactional consistency across services. This is because each service’s datastore is not just separate in principle, as with modular monoliths, but often physically distinct, sometimes even residing on separate servers or environments.
- Modular Monolith: Given its contained nature, it’s often simpler to align architectural practices and enforce consistency. It does mean, however, that the entire system might need to be redeployed for changes in a single module.
- Microservices: Services can be developed, deployed, and scaled independently. It’s the epitome of separation of concerns but can lead to challenges in maintaining uniformity and can complicate deployment pipelines.
Conformist Pattern in Modular Monoliths
Central to Domain-Driven Design’s strategic design is the Conformist pattern. Especially in situations where one team is upstream and the other downstream, the downstream team can sometimes find themselves at the mercy of the upstream team. This dynamic is highlighted when the upstream has no particular motivation to cater to the downstream’s needs.
Using the ‘sell books online’ example, suppose the ‘book catalog’ module (managed by Team A) is upstream and the ‘order processing’ module (handled by Team B) is downstream. If Team A has no incentive to adapt their interface or service for Team B’s needs, Team B might end up having to ‘conform’, thereby adopting the Conformist pattern. The downstream team may have to make do with whatever interface or features are provided, even if they’re not optimal for their requirements.
In a modular monolith, internal negotiations and architecture reviews can be facilitated to address these dynamics, ensuring that teams work cohesively and don’t end up unintentionally enforcing the Conformist pattern, leading to sub-optimal solutions.
Flexibility in Microservices
Microservices inherently offer a greater degree of flexibility, especially in technology choices. Each microservice can potentially have its own stack, depending on its unique requirements. This means one service could be written in Python using Flask while another could be in Java with Spring Boot, based on what’s most suitable for their respective functionality.
Moreover, this flexibility extends beyond just the tech stack. Microservices can adopt different development practices, scaling strategies, and deployment patterns, allowing teams to work in ways that are most efficient for their specific service.
However, it’s essential to strike a balance. While flexibility is a strength, without proper governance, it can lead to a fragmented system where maintaining uniformity and integration becomes a challenge.
Why I Lean Towards the Modular Monolith
While both architectures have their merits, my penchant for modular monoliths over microservices arises from practical observations:
- Separation of Concerns: Within a modular monolith, we can achieve a high degree of separation. Each module addresses a particular business concern. Drawing from our online bookstore, imagine a module solely dedicated to ‘user reviews.’ It’s independent, isolated, and yet part of the broader system.
- Simplified Communication: Microservices inherently rely on network communication. Modules in a monolith, on the other hand, communicate internally. I particularly prefer event-driven approaches for module communication to avoid or reduce module dependencies when ever it is possible. But it is not always necessary and depends on the complexity and the need of the system. Harnessing interfaces and dependency injection is another effective method, facilitating synchronous and direct communication between modules.
- Data Store Independence: One of my firm beliefs is in the ‘share nothing’ architecture. Each module should ideally have its dedicated data store, ensuring data integrity and reducing the chances of data corruption. (But hopefully that’s always a given when taking a microservices approach!)
- Operational Complexity and Cost: Operating microservices demands robust DevOps practices. Each microservice may require its deployment pipeline, scaling strategy, and monitoring tools. While tools like Kubernetes have made orchestration easier, the initial setup and ongoing maintenance add complexity. This not only requires skilled manpower but can also escalate operational costs. On the contrary, a modular monolith can be deployed as a single unit, reducing the need for intricate orchestration and potentially reducing hosting and operational expenses.
- GitOps Overhead: Embracing the GitOps approach with microservices can sometimes feel like opening Pandora’s box. GitOps, which stands for “Git-based Operations,” promotes using Git as the single source of truth for declarative infrastructure and applications. While this provides enhanced traceability and facilitates automation, it introduces another dimension of complexity when used with microservices. Each service, with its own deployment pipeline, combined with GitOps practices, can sometimes overshadow the core business logic we intend to address. In essence, the overhead of managing the GitOps flow for each microservice feels like managing a distinct project in itself. And in scenarios where rapid delivery of business features is critical, this overhead can become a deterrent, diverting focus and resources from the main business objectives. In contrast, managing GitOps for a modular monolith can be substantially more straightforward, as you’re essentially handling a single, cohesive unit.
Wrapping it Up
In the discussions of modular monoliths and microservices, I often see teams getting lured by the siren song of industry trends, sometimes neglecting to consider their unique requirements. But my journey as a software engineer has taught me that every project, every domain, is a universe in itself. What works for one might not necessarily work for another.
A modular monolith, especially when infused with the principles of Domain-Driven Design, offers a balanced blend of structure and flexibility. The focus is always on the domain, the bounded contexts, and ensuring that our software mimics the business’s reality as closely as possible.
Always start with understanding your domain. Modularize based on the domain’s needs. And whether you opt for a modular monolith or microservices, ensure that your architecture serves the business, not the other way around.