One binary, many boundaries: building a modular monolith in Go¶
We had a clear challenge: migrate from a legacy platform to a new OMS-based solution from a third-party vendor before Black Friday.
A deadline, not an architecture project¶
We had to replace a legacy on-prem solution, full of embedded business logic, with a third-party cloud OMS (Order Management System). The OMS integration in this phase was deliberately limited in scope, but there was a strong business requirement: the OMS should become the operational control plane for customer support and configuration.
Customer service personal shouldn't need to understand several backend systems to help a customer. Operationally, the ambition was simple: one UI to rule them all, that is OMS to be that single portal to be used by customer support for any customer-related orders. That meant the OMS had to be continuously supplied with the product, inventory and order-related data it needed, while our own platform had to fill the gaps between the OMS and the rest of the Coop ecosystem.
We had a small team, limited experience with the OMS domain model, and a deadline that wasn't going to move.
We needed an architecture that would let us move quickly without making the system difficult to maintain, once Black Friday was behind us.
Why a modular monolith¶
Microservices were an obvious architectural option. It would have given us independent deployments and well-known pattern to work with. But independent deployments weren't one of our problems. They would have added distributed system problems at exactly the point where we knew the least about the domain.
Starting with microservices would have introduced more deployment units, network communication, distributed tracing, additional failure modes and more infrastructure. It would also have forced us to make early decisions about service boundaries in a domain, which we were still learning about.
A traditional monolith would have been simpler, but we wanted stronger boundaries because the system was integration-heavy and likely to evolve on a short notice.
A modular monolith therefore became a very good candidate. It allowed us to have: one application, one deployment unit and one operational model, while still organizing the system around explicit domain and capability boundaries. It let us keep the deployment and operational model we already understood, while still designing the system around boundaries that could evolve as our knowledge improved.
A modular monolith is still a monolith. The difference is that its internal modules have clear responsibilities, explicit contracts and controlled dependencies.
For us, the difficult part was not creating packages called orders, products
or inventory, but rather deciding what each module owned and what it was
allowed to know about the others.
Our initial module boundaries¶
We started by identifying the capabilities required around the OMS.
The initial structure included modules such as:
- Inventory - retrieves inventory data and supplies the OMS with stock information.
- Products - retrieves product data from upstream systems and synchronizes it with the OMS.
- Orders API - exposes the API used by the storefront to create orders.
- Fulfilment - handles order fulfilment-related integration and processing.
- Receipts - handles receipt creation and related processing for customer orders.
- OMS - encapsulates communication with the third-party OMS, hiding vendor-specific API details from the rest of the application.
The OMS module acts as an integration boundary. It owns the communication with the vendor's APIs, including authentication, request and response handling, mapping, and transport-related error handling. Other modules can use its exposed functionality without needing to know how the OMS vendor-specific API works.
This gives the architecture a clear separation of responsibilities:

Figure 1. High-level architecture of the modular monolith and its integration with the third-party OMS. The arrows represent communication relationships rather than a specific execution flow.
Not every module has the same shape¶
At first, we thought of these simply as 'modules', however during implementation, we discovered that they didn't all have the same shape.
Products and Inventory were essentially data synchronization pipelines. They retrieved data from upstream systems, transformed it and supplied it to the third-party OMS through our OMS module.

Figure 2. Product and Inventory are independent synchronization pipelines. The OMS module encapsulates communication with the third-party platform.
These modules contained very little business logic and didn't need to coordinate with each other. Their responsibilities were clear: retrieve the required data, transform it and keep the OMS up to date.
The order flow was different. Creating and processing an order represented a business workflow that required several capabilities to collaborate. That difference influenced how we designed communication between modules.
A mediator for the order flow¶
We didn't want to introduce a universal communication mechanism just because we had multiple modules. For Products and Inventory, there was no meaningful cross-module workflow to orchestrate. Their responsibilities were independent and straightforward.
Order-related processing was different. Several modules could participate in the same overall process, and interactions could travel in different directions. For this, we introduced a mediator.

Figure 3. The mediator provides a communication boundary for order-related modules.
The mediator therefore does more than call several modules in sequence. It coordinates and routes interactions across the order flow, while the participating modules remain responsible for their own capabilities.
For example, an order may enter through the Orders API and then be sent to the third-party OMS. After the OMS can execute its own workflow and later - send an event back to us. The mediator routes that event to the module responsible for handling it.
Wiring the modules together¶
Once the module boundaries and communication patterns were defined, we still needed to construct the application and connect everything together. We kept this deliberately simple.
Go already gave us the mechanisms we needed through constructors and interfaces, so we didn't introduce a dependency-injection framework. Instead, the application composition root creates infrastructure dependencies, constructs the modules and wires their relationships explicitly.
A simplified version looks like this:
func main() {
omsModule := oms.New(omsClient)
fulfilmentModule := fulfilment.New(cfg)
receiptsModule := receipts.New(receiptsClient)
orderMediator := mediator.New(omsModule, fulfilmentModule, receiptsModule)
}
The code is intentionally simple, but it shows an important property of the design: dependencies and communication paths are explicit. The dependency graph is visible in ordinary Go code. If a module needs another capability, that relationship appears explicitly when the application is assembled. Products and Inventory depend on the OMS capability because they synchronize data with the external platform. The mediator is constructed with the capabilities it needs to route those interactions. This helped us avoid creating an arbitrary web of dependencies between modules. It also prevented the Orders API from becoming the place that needed to know how every other part of the system worked.
The broader lesson was that modularity doesn't require every module to use the same pattern. Synchronization pipelines, integration adapters and business workflows are different problems, and the architecture should accommodate all of them.
Finding the right amount of commonality¶
The different shapes of our modules also challenged our initial ideas about common interfaces. Some modules exposed HTTP APIs, others - consumed events. Their dependencies and configuration requirements were different.
We still needed a common way to initialize, start and stop modules, but we did not want to force every module into the same implementation structure.
A simplified illustration of the kind of lifecycle contract we wanted is:
type Module interface {
Start(ctx context.Context) error
Stop(ctx context.Context) error
}
That led us to define a common lifecycle while allowing different configuration flavours for API-oriented, event-driven and synchronization modules.
The guiding principle became: consistency should make the system easier to understand, not force unrelated capabilities to look identical.
Shared code can quietly destroy modularity¶
One of the easiest ways to weaken a modular monolith is through shared packages.
In Go, it's tempting to create a common, shared or utils package whenever
two modules need similar functionality. Over time, these packages can become a
place where business models and logic accumulate, creating dependencies that
bypass the intended module boundaries.
We tried to be conservative about extracting shared code. We used the rule of three as a guideline: rather than abstracting the first duplication, we waited until a common need appeared several times and the abstraction became clearer.
We also tried to limit shared packages to non-business capabilities, such as logging, tracing, HTTP infrastructure, messaging primitives and retry mechanisms.
Business concepts required more caution. A Product or Order model might look reusable, but the same concept can have different meanings and requirements in different modules. That led us to another important lesson: share technical capabilities where useful, but avoid turning shared business models into an implicit dependency between modules.
Data models are part of the boundary¶
As the implementation evolved, we also started revisiting how modules exposed their data.
It's convenient to reuse an internal model in a module's public interface. But an internal model often contains details that are meaningful only to that module's implementation. The OMS module makes this particularly relevant. The third-party OMS has its own API contracts and data structures. We don't want those vendor-specific models to become the default models used throughout our application. Instead, a module's public contract should express the information or capability its consumers need. The OMS module can then translate between those contracts and the vendor's API. Likewise, an Inventory module might internally work with warehouse records, reservations, source-system information and synchronization metadata. Another module may only need to know the availability of a particular SKU.
The public contract should express the information the consumer needs, rather than exposing the internal representation simply because it already exists. A useful way to summarize this is: a module's API should describe what it offers, not how it happens to implement it.
How did it go?¶
So how did it go? We were able to deliver well in time for round of integration testing before the launch. And then, new version with OMS passed Black Friday with honours.
Not every initial decision was perfect. Some module APIs need refinement, and we are still evaluating whether the boundaries we established are as strong as we intended. That's part of the value of the approach: we can evolve the internal architecture without paying the cost of distributed systems before it's necessary.
What comes next¶
Our next focus is on strengthening module contracts, particularly their public data models. We're introducing architecture tests to verify that module boundaries are respected. For example, a module shouldn't import another module's internal packages. We also want to make sure that vendor-specific OMS models remain contained within the appropriate integration boundary rather than leaking across the application.