Imagine trying to renovate a historic building while people still live and work in it. You can't just tear down walls without a plan, or you'll disrupt everyone's lives. Similarly, rearchitecting a growing front-end application can be just as delicate. The journey from a small project to a company with a modular monolith architecture offers invaluable lessons about scalability, code management, and keeping development teams sane.
Starting with a Simple Structure
Let’s start with a real-life experience from a few years ago. Like many, our Vue-based platform began with a standard structure, prioritizing rapid development and feature delivery. As a startup, survival depended on speed and flexibility, so we postponed all non-essential refinements for as long as possible. But as we scaled, we encountered the dreaded "merge hell," where changes in one part of the codebase unexpectedly broke others - a typical situation for a growing business.
Below is one of the variations of a basic structure:
Have you ever tried organizing a messy garage, where every tool and item seems to be scattered with no rhyme or reason? That’s how our Vue project felt as it grew larger and more complex. Let’s dive into how we transitioned from chaos to a more structured approach using a modular monolith architecture.
Vue Overview
First, lets give ourselves a solid overview of Vue and its core ecosystem of libraries. For this Start with the official Vue documentation and the template they provide. Next, I would recommend you to explore Vitesse by Anthony Fu. This template boasts numerous pre-configured settings, which will deepen your understanding of the Vue ecosystem.
When you’re done with the previous two steps, try Nuxt, which is a powerful framework based on Vue, along with their official project template. Finally, check out Vitesse-Nuxt3 by the same author. This will give you an advanced, production-ready setup combining Vitesse and Nuxt.
In the initial structure we implemented, our team felt constrained. Changes in one area triggered unexpected problems in others, leading to "merge hell" and hindering our ability to swiftly deliver new features.
By addressing these issues, we aimed to streamline our development process and enhance scalability.
An example of such case with each colour highlighting one business context:
Growing Pains
With multiple cross-functional teams and over a dozen front-end programmers, using a full SDLC process, the deployments will happen four to eight times daily. The tightly coupled codebase became a bottleneck, causing problems akin to trying to renovate that historic building with all its residents still inside. We needed a solution to prevent these collisions and streamline our processes, avoiding high coupling of the codebase.
There are a lot of ways to solve or at least work with these problems. Here are a few of the most common approaches one can turn to:
Low Coupling, High Cohesion:
- Low Coupling: Minimizing dependencies between modules or components.
- High Cohesion: Ensuring that each module or component is focused on a single task or closely related tasks.
GRASP, SOLID, DDD:
- GRASP (General Responsibility Assignment Software Patterns): A set of guidelines for assigning responsibilities to classes and objects in object-oriented design.
- SOLID: A set of five principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) for designing maintainable and scalable object-oriented systems.
- DDD (Domain-Driven Design): A methodology for designing complex software by focusing on the core domain and its logic.
Dependency Injection, Inversion of Control:
- Dependency Injection: A design pattern where an object receives its dependencies from an external source rather than creating them itself.
- Inversion of Control: A design principle where the control flow of a program is inverted, meaning that the framework or runtime controls the execution rather than the application code.
Vertical Slice Architecture, Modular Monolith:
- Vertical Slice Architecture: An approach where each slice of functionality is implemented end-to-end, from the user interface to the database, promoting separation of concerns.
- Modular Monolith: A single application designed with modularity in mind, where modules are logically separated but reside in the same codebase and deployment unit.
The Modular Monolith Solution
Voltron leverages modular monolithic architecture.
We adopted a modular monolith architecture to address our issues. The main reason was its balance of simplicity and scalability. This architecture allows clear modular boundaries within a single codebase, reducing dependencies and simplifying management, which was important for us. It also minimizes the "merge hell" we suffered from and ensures consistent development, enabling easier refactoring and rapid feature delivery. This approach supports rapid development while preparing for the rapid future growth the company was aiming for.
A modular monolith can be visualized as follows:
Simplifying complexity. Our goal is to divide our application into manageable parts (modules) and minimize their interaction to the essentials. But what exactly are these "modules," and how do we achieve this "optimal" interaction? When you embark on this journey, it’s not always crystal clear.
Simplified this transition can be described as follows:
Modules as Building Blocks: These are distinct parts of our application, each encapsulating:
- A specific function
- A particular opportunity
- A domain problem
Shared Infrastructure: This is the foundation on which our modules are built and connected. It ensures the basic operations of the application, including:
- DI-container management
- Routing
- Global state
- Global services
- Global event bus
- Common business contexts (e.g., user profiles, feature flags, A/B tests)
- Shared components or utilities
Advantages
- Simplicity and Developer Experience: A single codebase with clear modular boundaries makes it easier to manage and understand.
- Consistency: With everything in one place, inconsistencies in APIs, types, and library versions are minimized.
- Ease of Refactoring: Clear boundaries and unit tests facilitate safe refactoring.
- Reduced Merge Conflicts: Each team works within its module, minimizing code conflicts, which caused us a lot of headaches before the transition.
- Efficient Deployment: Modular design supports our frequent deployment schedule without major hiccups.
Challenges
Yet, nothing is perfect, not even our shiny Modular Monolith solution. While it brings order and scalability, we still face challenges like implicit module interactions and the risk of creating "megamodules." So, even our heroic monolith isn't without its kryptonite! Here is a list of a few challenges you might still face along the way.
- Implicit Interactions: Many inter-module communication methods can be implicit, requiring excellent documentation and testing.
- Scalability Limits: As teams grow, we must remain vigilant against the monolith becoming too cramped, potentially necessitating further architectural evolution like microfrontends.
- Risk of Strong Coupling: Incorrectly defining module boundaries can lead to high coupling, counteracting the benefits of modularization.
- Mega Modules: Misunderstood domains can lead to overly large modules with too many responsibilities, necessitating redesign.
Interaction Between Modules
Managing interactions between modules is crucial. Here are some approaches we used:
- Event-Based Approaches: Utilizing eventBus and Vue events to achieve loose coupling.
- Dependency Injection and Inversion of Control: Managing dependencies and control flow between infrastructure and modules.
- Dependency Injection and Inversion of Control: Managing dependencies and control flow between infrastructure and modules.
- Global State Changes: Using global services to create contextual dependencies.
- Vue Slots: Allowing flexible composition of components from different modules.
- Direct Module Use: Minimizing direct dependencies between modules.
Advantages
- Simplicity and Developer Experience: A single codebase with clear modular boundaries simplifies management and understanding.
- Consistency: Everything in one place minimizes differences in types, APIs, and library versions.
- Ease of Refactoring: Clear boundaries and unit tests facilitate safe refactoring.
- Reduced Merge Conflicts: Each module is owned by a team, reducing the need for cross-team code changes.
- Efficient Deployment: Modular design supports frequent deployment without major issues.
These advantages, combined with a robust CI/CD process, enable us to perform five to nine deployments to production daily.
Challenges
- Implicit Interactions: Inter-module communication methods can be implicit, requiring excellent documentation and testing.
- Insufficient Flexibility: As the team grows, we may face issues with the monolith becoming too cramped, necessitating a move to other architectures like polyrepositories or microfrontends.
- Strong Coupling Risks: Incorrectly defining module boundaries can lead to high coupling, requiring redesigns.
- Mega Modules: Misunderstanding the domain can lead to overly large modules with too many responsibilities, needing further breakdown.
Conclusion
We began with a simple Vue application structure, but as our teams grew, so did the complexity and the inevitable "merge hell." Recognizing the critical issue of strong connectivity, we decided to embrace the modular monolith approach. This decision allowed us to strike a balance between reducing connectivity and speeding up feature delivery.
Our transition to a modular monolith architecture was like finding the sweet spot between chaos and order. While it's not a magical fix-all and comes with its own set of challenges, it significantly improved our scalability and developer experience. Of course, just like in any long-term relationship, you have to keep working at it—future growth might demand more architectural tweaks, so there's no such thing as a final solution.
This journey has highlighted the importance of thoughtful modularization in building scalable, maintainable software. And remember, even the best-laid plans need some wiggle room for unexpected twists and turns.
So, keep adapting and stay nimble—your codebase will thank you for it!