How to Achieve More with Less Complexity
What is a Modular Monolith Architecture?
Modular Monoliths present a unique approach to software architecture, balancing the robustness of monolithic systems with the scalability of microservices. As a result, it allows for increased scalability and maintainability while avoiding the complexity and overhead commonly associated with microservices. In this essay, we will explore the advantages of utilizing a modular monolith architecture in Go and provide guidance on implementing it in your projects.
A modular monolith architecture is a single codebase divided into smaller, self-sufficient modules. Each module is responsible for a specific domain or functionality, communicating with other modules through well-defined interfaces. This approach enables easy scalability, as new functionality can be added by creating new modules, or existing modules can be replaced or updated without affecting the rest of the system. Additionally, having a single codebase for the entire application makes it easy to maintain, as all the necessary logic is in one place. It also allows for better organization, as developers can focus on specific application parts without navigating a complex codebase.
Why Go is Ideal for a Modular Monolith Architecture
Go is an excellent language for a modular monolith architecture. It provides strong typing and concurrency features that allow developers to utilize the language’s capabilities to define and implement modules. Go’s function types, interfaces, and structs provide a solid foundation for building modules, while the language’s built-in support for concurrency allows for easy parallelization and scalability.
Implementing a Modular Monolith in Go
Given its nature, there are countless ways to achieve this with Go. And here, I’ll speak abstractly to keep it as flexible as possible. To implement a modular monolith in Go, the initial step is to create a package for each module and define interfaces for the modules to communicate with one another. This is achieved by using Go’s `interface` type to define these interfaces, which grants ease in testing and flexibility. The interfaces should be clearly defined and specify the input and output of each function. This helps ensure that the modules are loosely coupled and can be easily replaced or updated.
When organizing the code within each package, it’s advisable to adopt a cmd and internal package structure. The cmd package comprises the application’s main entry point, while the internal package contains the module’s implementation. This structure allows for better encapsulation, keeping the implementation details hidden from other packages. This makes testing the modules and making changes to the application without affecting other parts of the system more manageable.
The Importance of Testing in a Modular Monolith
Testing is crucial when building a modular monolith, and it is imperative to test each module individually. To accomplish this, mock implementations of the interfaces can be created for testing purposes. Go’s testing package can be utilized for unit testing, and Go’s testify package for more advanced testing, or even go-check
for those not faint of heart! This approach guarantees that each module functions correctly and that changes made to the application do not break existing functionality.
Propagating Request-Scoped Information
Managing dependencies between modules can be handled within a modular monolith using an inbuilt dependency manager. Additionally, Go’s context package can propagate request-scoped information through the application. This enables better data flow control and makes handling errors and timeouts simpler.
Conclusion
A modular monolith in Go gives you a single codebase split into self-contained modules with well-defined interfaces. Go’s standard library (interfaces, structs, concurrency primitives) maps well to this pattern. Pair it with disciplined testing and dependency management, and you get a codebase that’s maintainable at scale without the operational overhead of microservices.
Clarified with a simple example
Abstract of the proposed hierarchy
| |
- The
cmddirectory houses the main entry point of the application and individual directories for each module. Each module’s directory comprises amain.gofile for the module’s command-line interface and a[module_name].gofile for the module’s implementation. - The internal directory holds the implementation of each module, with a dedicated directory for each module. Each module’s directory includes the
[module_name].gofile for the module’s implementation, and a[module_name]_test.gofile for the module’s tests. - To adhere to idiomatic Go and ensure clear communication between modules, interfaces are declared within the implementation of each module, in the
interfaces.gofile. Do not be steered away ifinterfaces.goseem to collide with idiomatic practices - this is to avoid duplication and to provide a proposed interface, but it is still up to the caller of the module to pick whether that one will be inherited or if a leaner solution would take its place. - The pkg directory contains any additional packages or libraries used by the application, such as third-party packages or common utilities that are shared between modules. This helps to keep the application’s codebase organized and makes it easier to manage dependencies.
From my experience, this directory structure works for the majority of projects – I’d estimate 70%+ would benefit more from this approach than from microservices. Some projects need additional vertical segregation (handlers, services, repositories split per HTTP request), which I’ll cover in a separate article. And yes, a few projects genuinely need microservices, but those require significant investment in funding, headcount, and tolerance for operational complexity.
Boilerplate
Update 25th of January, 2023.
The followup article with a more detailed Project Structure -> Modular Monolith - Boilerplate .
If you’d like to discuss modular monoliths or software architecture, reach out at aleksandar@nesovic.dev or via nesovic.dev .
