Fighting with Monolith: Journey to Bounded Contexts
Oleksandr Kholodniak
Leading Innovation with AI and Machine Learning Strategies.
I joined this startup from the very beginning and had the unique opportunity to witness how the application evolved as the company grew. Like many startups, we focused on building features quickly, adopting a "move fast" mentality to meet the demands of our users. However, as the app expanded and became more feature-rich, we realized that continuing with a monolithic architecture was no longer sustainable. We needed to transition from a startup mentality to a more mature, scalable architecture.
The Challenge of Monoliths
Monoliths are great for getting a product off the ground. In the early stages, having all the business logic, data models, and services in a single codebase made it easy to develop and deploy new features rapidly. But over time, we began to experience the downsides:
These challenges pushed us to rethink our approach, leading us to embrace bounded contexts.
Step-by-Step Approach: Splitting into Bounded Contexts
To decouple the growing monolith, we adopted Domain-Driven Design (DDD) principles, where we broke the system into bounded contexts — independent parts of the domain that could evolve independently. Each context had its own clearly defined boundaries, making it easier to reason about and develop.
Our refactored directory structure, for example, represented these domains, including contexts like Courses, Payments, and Driving Exams. Below is the structure of the Courses context:
Each folder within a context contained commands, events, and repositories, effectively isolating the logic of that specific domain.
Example of Command and Event Handling
Each context was modeled as a self-contained system that could emit events and respond to commands. Below is an example of how we defined a command in the Courses context to manage course definitions:
In this example, the DefineCourseCommand is responsible for creating a new course with a set of lesson configurations. Once the command is executed, the corresponding event, CourseDefined, is published.
Here’s a simplified version of the event:
The separation between commands and events enabled us to decouple processes and facilitate communication between different contexts.
Example of an Event Listener
Below is an example of an event listener we implemented for handling changes in the DrivingLessons context:
In this example, when the EmployeeSwitchedStudentEducationEvent is triggered, the listener processes the event and issues the SwitchDrivingLessonsEducationCommand. This decouples the logic by allowing the event to trigger actions without direct dependencies between services.
Event-Driven Architecture with Ruby Event Store
To manage the flow of events across different bounded contexts, we integrated Ruby Event Store. This allowed us to implement an event-driven architecture where contexts communicated by emitting and listening to events, rather than calling each other directly through service interfaces.
For example, when a course was defined, the CourseDefined event would be published. Other parts of the system, such as the Payments context, could listen to this event and trigger necessary actions, such as initializing payment plans for the course.
This approach provided multiple benefits:
领英推荐
Moving from REST API to Messaging Queue
In order to achieve more flexibility in communication between bounded contexts, we moved away from relying solely on REST API calls and introduced RabbitMQ as a messaging queue. This asynchronous messaging system allowed us to scale contexts more independently and provided fault tolerance, as messages could still be delivered even if a part of the system was down.
For example, the Courses context could publish a message to RabbitMQ when a new course was defined, and other services, such as Payments, could subscribe to that message and act accordingly without requiring a synchronous API call.
Benefits of RabbitMQ included:
Leveraging Dry-rb Ecosystem for Better Code
In addition to architectural changes, we utilized the Dry-rb ecosystem to improve our code quality and testability. This set of libraries allowed us to structure our code in a more maintainable way, adopting functional programming principles within Ruby.
Here’s how we used key Dry-rb components:
Example:
Example:
Example:
The Influence of Functional Programming
Alongside the architectural changes, we also introduced functional programming concepts into our Ruby codebase. This was heavily inspired by our experience with Elixir, a language designed for highly concurrent, fault-tolerant systems.
We adopted practices such as:
Advantages of the New Architecture
Disadvantages and Challenges
Conclusion
Splitting a monolith into bounded contexts is a challenging but rewarding process. It requires a shift in mindset from a quick-to-market approach to one focused on scalability, maintainability, and resilience. During my time at the company, we used Ruby Event Store, RabbitMQ, and adopted functional programming principles with Dry-rb libraries to decouple our system effectively, improve scalability, and prepare the application for future growth.
Though the journey required overcoming challenges such as increased complexity and deployment overhead, the long-term benefits — such as easier debugging, better maintainability, and independent scalability — have outweighed the costs. Even though I’ve since moved on from the company, I’m proud of the architecture we implemented, confident that it will continue to support the application’s growth and the company’s future ambitions.