So you want modular architecture but you don’t want the operational headache of microservices. That’s where modular monoliths come in.
Here’s the thing though – modular monoliths only work if you maintain the discipline. Without it, you’re going to end up right back where you started, with a traditional “big ball of mud” that no one wants to touch.
The difference between a well-structured modular monolith and a mess isn’t about deployment – it’s about boundaries. Strong, enforced boundaries that keep modules genuinely independent while they share a single process.
This guide is going to walk you through how to build modular monoliths correctly. We’ll cover identifying module boundaries using Domain-Driven Design, enforcing them with hexagonal architecture, implementing internal messaging for loose coupling, enabling independent module scaling, and keeping teams autonomous through code ownership.
These patterns give you deployable monoliths with the organisational benefits of microservices but without distributed systems complexity. The focus is on logical separation, not physical distribution.
For broader context on the architectural fundamentals of modular monoliths versus microservices, check out our pillar article on software architecture patterns.
How Do You Identify the Right Module Boundaries in a Modular Monolith?
Getting boundaries right is make or break for modular monoliths. Draw them in the wrong places and you’ll spend your life coordinating changes across modules. Draw them right and teams can work independently for weeks.
Module boundaries need to align with business capabilities and domain concepts, not technical layers. If you’re separating your app into presentation, business logic, and data access layers you’re creating boundaries that slice through every feature. That’s exactly the opposite of what you want.
Domain-Driven Design’s bounded contexts give you natural module boundaries. A bounded context is where different parts of the business use different language, rules, and models. It’s the focus of DDD’s strategic design, which is all about organising large models and teams.
Different groups use different vocabularies in large organisations. When the sales team talks about a “customer” they mean something completely different to what the support team means. That vocabulary mismatch signals a boundary location.
EventStorming and Domain Storytelling are practical techniques for domain modelling. The Bounded Context Canvas workshop brings together both deep and wide knowledge of the business domain.
Look for areas with different rates of change, different scalability requirements, or different consistency requirements. These indicate boundary locations. A common mistake is focusing too much on entities rather than capabilities.
Several indicators help you find the right boundary spots. Focus on capabilities of your system when defining logical boundaries. Each module should be a complete business capability that a team can own end-to-end. Think about team structure too – modules should align with team boundaries to enable autonomous development without endless coordination.
Context mapping shows you integration points between bounded contexts. It reveals where modules need to talk to each other and helps you design those interfaces deliberately instead of letting them emerge through database coupling.
Take an e-commerce system like Shopify. You might define modules around product catalogue, inventory management, and order fulfilment – each one a distinct business capability with its own team and evolution path.
Modules work best when they match team boundaries. If you can map a module to a single team that owns it completely, you’ve probably got the boundaries right. If multiple teams need to coordinate on every change, the boundaries are wrong.
What Are Logical Boundaries and How Do You Enforce Them?
Logical boundaries are conceptual separations between modules within a single deployment, enforced through code structure and architectural rules rather than process isolation. Unlike microservices with their network boundaries, modular monoliths rely on developer discipline and architectural testing.
A logical boundary is a grouping of functionality or capabilities within your system. The key thing about logical boundaries is they don’t have to map 1:1 to physical deployment boundaries. That’s the whole point – you get the organisational benefits of modularity without deployment complexity.
Modular monoliths are single deployed applications with well-separated modules, clear boundaries, and in-memory communication. Unlike traditional “big ball of mud” monoliths, modular architectures enforce boundaries through domain-driven design, hexagonal architecture, and automated tests using tools like ArchUnit or NDepend.
Enforcement comes from several layers – namespace structure, access modifiers like internal or private, dependency rules, and architectural fitness functions. Each layer protects against boundary erosion.
Use namespace or package structure to physically separate modules. In Golang, bounded contexts isolate all modules within their respective packages. One module equals one top-level namespace or package.
Each module should have a single public interface – a facade that exposes only what other modules need. All internal domain models, repositories, and services stay private. Other modules can only access what’s explicitly exposed through the public API.
Apply access modifiers to hide internal components. Prevent cross-module internal references through package structure. Keep all domain models, repositories, and services marked as internal or private.
Architectural tests fail when boundaries are violated. These tests run in your CI/CD pipeline and catch violations before they hit production. They’re your automated enforcement.
ArchUnit for Java and Kotlin tests architecture rules as unit tests. NetArchTest for .NET verifies namespace and dependency rules. Go internal packages enforce boundaries at the language level. Python import-linter handles boundary enforcement for Python.
Dependency rules are simple: modules cannot directly reference another module’s internals, only its public API. Static analysis and dependency graphing tools catch boundary violations during development.
Code review matters. Treat boundary violations as architectural debt that needs fixing during code review. Don’t let them pile up with plans to “fix them later.” Later never comes.
We’ll look at several anti-patterns that undermine logical boundaries later in this article.
How Does Hexagonal Architecture Help Build Modular Monoliths?
Hexagonal architecture – also called ports and adapters – structures each module with domain logic at the centre, isolated from external concerns through explicit interfaces.
The principle is that the domain is inside and the Dependency Rule is respected. Source code dependencies point only inward, toward higher-level policies. This applies dependency inversion – all dependencies point toward the domain.
Hexagonal architecture centres on keeping core business logic isolated and independent from external concerns like user interfaces, databases, or other external systems. Each module has its own hexagonal structure.
Ports are interfaces defining boundaries. Primary ports define how external actors interact with the module – API controllers, event handlers, command handlers. Secondary ports define how the module interacts with external systems – databases, other modules, external services.
Adapters are concrete implementations that plug into these ports. The key distinction is that the domain defines interfaces (ports), infrastructure implements them (adapters), and the domain doesn’t reference infrastructure.
You might also see this called Ports and Adapters, Clean Architecture, and Onion Architecture. They’re all variations on the same theme – protecting the domain from infrastructure concerns.
The same pattern works at both the system level and within individual modules. You can apply hexagonal architecture to the entire application and to each module within it.
Benefits for modular monoliths are significant. Modules become technology-agnostic and testable without infrastructure. You can swap implementations via adapters. Domain models don’t know about databases – repository interfaces like IOrderRepository are defined in the domain, while ORM and database implementations like SqlOrderRepository live in adapters.
Ports belong to the domain, defined in domain language. Adapters belong to the infrastructure layer. Module APIs are primary ports. Inter-module communication happens through ports.
The module composition root wires up dependencies. This is where you connect ports to adapters. It’s the only place that knows about concrete implementations.
This separation means you can test your domain logic without spinning up a database or making HTTP calls. You can swap database implementations without touching domain code. You can change message broker technologies without rewriting business logic.
How Do You Implement Internal Messaging Within a Monolithic Application?
Internal messaging enables asynchronous, event-driven communication between modules using an in-process event bus instead of external message brokers. A practical use case for an in-memory message bus is building a modular monolith where you implement communication using integration events.
Modules publish integration events when significant state changes occur, and other modules subscribe to events they care about. Integration events contain only as much as needed to avoid the so-called Fat Events.
The distinction between domain events and integration events matters. Domain events are internal to a module, part of the domain model, while integration events are public contracts between modules. Domain events stay within a bounded context. Integration events cross boundaries.
Integration events are published after transaction commits. This timing is important – you don’t want other modules reacting to changes that get rolled back.
For asynchronous communication, use MediatR for .NET, implementing publish-subscribe patterns within the process. .NET Channels provide high-performance scenarios for in-memory messaging. Spring Event and EventBus for Java provide event-driven infrastructure.
Keep events lean. Avoid fat events by including only essential identifiers and changed data. Consumers can query for additional details if they need more. This keeps coupling loose.
Event versioning strategy matters for evolution. Events are contracts between modules. Those contracts need versioning just like any other API.
Use synchronous calls for workflows requiring immediate feedback or within-transaction consistency. Use asynchronous events for notifications, side effects, and eventual consistency scenarios.
Reliability patterns matter even in monoliths. The outbox pattern stores events in the database within the same transaction. A background worker publishes from the outbox, ensuring at-least-once delivery. The outbox pattern ensures “read your own writes” semantics.
The inbox pattern tracks processed event IDs in the consumer’s database. It detects and skips duplicate events, handling at-least-once delivery semantics.
These patterns protect against failure scenarios. If the application crashes after committing a transaction but before publishing events, the outbox pattern ensures those events still get published when the application restarts.
What Strategies Enable Independent Scaling of Different Modules?
While modular monoliths deploy as a single unit, you can optimise performance for specific modules through targeted strategies without extracting services.
During holiday seasons, specific modules can be deployed independently, then merged back after the season. This is about selective optimisation within the monolith, not permanent extraction.
Use read replicas and caching for read-heavy modules to reduce database load. Implement database partitioning or sharding to isolate high-volume module data.
Apply selective horizontal scaling by running multiple instances with load balancer routing based on request patterns. Use module-specific thread pools or resource allocation to prevent resource-hungry modules from starving others.
Profile module hot paths and optimise database queries per module. Module-specific caching strategies with different TTLs for different modules can improve performance.
Separate connection pools per module prevent one module’s database load from affecting others. Table partitioning for high-volume module data keeps query performance consistent. Module-owned cache namespaces prevent cache key collisions.
Well-defined module boundaries make extraction to a separate service straightforward when needed using the strangler fig pattern. Extract modules to microservices only when you need independent deployment, distinct technology requirements, or genuine team autonomy needs. Good module boundaries make extraction favourable when it’s justified.
Most performance problems can be solved without distribution. Database optimisation, caching, and connection pooling solve the majority of scaling challenges. Distribution should be your last resort, not your first move.
How Can Teams Maintain Autonomy Through Module Ownership?
Module ownership assigns specific teams responsibility for specific modules, enabling autonomous development within a shared codebase.
Teams own their module’s API contract, internal implementation, data schema, and deployment pipeline contributions. A good starting point is creating small, autonomous teams of 5-9 engineers, each led by a product owner and tech lead.
Assign ownership based on product areas or services rather than technical layers. Layer teams – where one team owns the frontend, another owns the backend, and a third owns the database – create coordination overhead. Product-aligned teams can make changes end-to-end.
Use CODEOWNERS file or equivalent in version control. Module owner must approve changes to their module. Teams can modify their own modules freely but submit proposals for changes to other teams’ modules.
Autonomous teams have the ability to implement changes themselves through self-service tooling. They don’t need to wait for other teams to deploy database changes or configure infrastructure.
Balance autonomy with collaboration through ownership models. Strong ownership means the team owns all changes to their module. Weak ownership means the team reviews and approves others’ changes. Teams aligned with bounded contexts work better than layer teams.
Module APIs are contracts between teams requiring versioning and deprecation policies. Backward compatibility requirements for API changes prevent breaking other teams.
Reduce synchronous meetings through clear boundaries. Async communication via events means teams don’t need to coordinate timing. Documentation and ADRs for module decisions keep everyone informed without meetings.
Teams can work on their modules in parallel with minimal cross-team coordination. They merge changes to the shared codebase through the same CI/CD pipeline, but the modules themselves stay independent.
This is how you get the organisational benefits of microservices – team autonomy, independent development cycles, clear ownership – without the operational overhead of distributed systems.
Which Tools and Frameworks Support Modular Monolith Development?
Now that we’ve covered the patterns for building modular monoliths, let’s look at the tools and frameworks that support these architectural decisions.
ArchUnit for Java lets you express architectural rules in a fluent, Java-based DSL. NetArchTest for .NET verifies namespace and dependency rules. Go tool enforces module boundaries via internal packages. Python import-linter handles boundary enforcement for Python.
Run architectural tests in CI/CD to prevent boundary erosion. These tests are your first line of defense against architectural decay.
NDepend for .NET visualises and queries dependencies. Structure101 provides dependency management and visualisation. IntelliJ and Rider offer dependency matrix and cycle detection. SonarQube tracks architectural debt.
MediatR for .NET provides CQRS and pub-sub patterns. Spring Event and EventBus for Java provide event-driven infrastructure. .NET Channels offer high-performance in-memory messaging.
Nx provides module-aware builds and dependency graphs. Bazel offers reproducible builds with dependency tracking. Turborepo provides caching and parallel task execution. Gradle and Maven multi-module projects support modular organisation.
Architecture Decision Records document module decisions. C4 model provides module visualisation. PlantUML and Mermaid generate diagrams from code.
The tool ecosystem for modular monoliths has matured. You’re not pioneering untested patterns – these are proven approaches with solid tooling support.
What Are the Common Anti-Patterns to Avoid When Building Modular Monoliths?
Understanding what to do is only half the story. Let’s look at the common anti-patterns that degrade modular monolith architectures – and how to avoid them.
The shared database data anti-pattern happens when modules directly access each other’s database tables, creating tight coupling through shared state. All data access must go through module APIs or integration events, not direct table access.
Shared database data prevents schema evolution independently and prevents service extraction. Transaction boundaries must be defined and separated between modules. Tables must be grouped by module and isolated from other modules.
Database views can serve as a temporary migration step, but they’re not a long-term solution. Eventually you need proper API boundaries.
Circular dependencies between modules indicate poor boundary definition. Module A depends on B, B depends on A – this creates a circular dependency that prevents understanding modules in isolation.
Use dependency analysis tools to detect cycles. Extract shared concepts to a new module to break cycles. Use events to break cycles by making the dependency one-directional.
The fat events anti-pattern publishes excessive data in integration events, creating large contracts and tight coupling. Keep events lean with IDs and minimal changed data. Consumers query for additional details if needed.
One module orchestrating many others creates a central bottleneck – the God module anti-pattern. Distribute business logic to appropriate modules. Use events for coordination instead of central orchestration. Each module should be self-sufficient for its domain.
Module APIs exposing internal implementation details create leaky abstractions. Design APIs based on use cases, not internal structure. Use DTOs separate from domain models. Apply anti-corruption layer patterns.
These anti-patterns emerge gradually. You won’t wake up one day with a God module. It happens through small compromises that pile up. That’s why architectural testing and continuous monitoring matter – they catch the drift before it becomes a problem.
FAQ Section
What’s the difference between modular monolith and microservices architecture?
Modular monoliths are single deployments with logical module boundaries enforced through code structure. Microservices are separate deployments with physical boundaries enforced through network calls. Modular monoliths avoid network latency, distributed transactions, and deployment complexity while maintaining modularity.
Can I migrate from modular monolith to microservices later?
Yes. Well-defined module boundaries make extraction straightforward using the strangler fig pattern. Good modular monolith design is actually the best preparation for microservices if you need them later. But most teams discover they don’t need them.
Should I use in-memory event bus or external message broker for internal messaging?
Start with in-memory event buses. They’re simpler and sufficient for most modular monoliths. External message brokers add operational complexity. Move to external brokers only if you need durability guarantees that outlive application restarts or if you’re extracting modules to separate services.
How fine-grained should my modules be?
Align modules with bounded contexts from Domain-Driven Design. Modules should be large enough that teams can work independently but small enough that a single team can understand the entire module. If modules are too small you’ll spend all your time on integration. Too large and you lose the benefits of modularity.
How do I prevent shared database access between modules?
Enforce it through architectural tests that verify modules only access their own tables. Use database schemas or connection strings to segregate module data. Make direct table access technically difficult through access controls. Provide APIs for cross-module data access.
What’s the relationship between DDD and modular monoliths?
Domain-Driven Design provides the strategic patterns for identifying module boundaries through bounded contexts. DDD’s tactical patterns work well within individual modules. DDD and modular monoliths are complementary – DDD helps you design good boundaries, modular monoliths help you enforce them.
Do I need hexagonal architecture for modular monoliths?
No, but it helps. Hexagonal architecture at the module level makes modules truly independent of infrastructure. You can test modules without databases, swap implementations without changing domain code, and extract modules more easily if needed later.
How do I handle transactions across multiple modules?
Don’t. Each module should own its transactions. Use eventual consistency and the saga pattern for cross-module workflows. Publish integration events after transactions commit. Design your bounded contexts so that most operations don’t require cross-module transactions.
Can different modules use different databases or technologies?
In a modular monolith, modules share the deployment unit but can use different database schemas or even different database engines. They can use different caching strategies, different ORMs, different libraries. The shared deployment creates some constraints but allows more flexibility than you might expect.
How do I test modular boundaries effectively?
Use architectural testing frameworks that verify dependency rules, detect circular dependencies, and ensure modules only access other modules through defined interfaces. Run these tests in CI/CD. Combine with integration tests that verify module APIs and contract tests for event schemas.
What’s the difference between integration events and domain events?
Domain events are internal to a module and part of the domain model. Integration events are public contracts between modules. Domain events happen within a transaction. Integration events publish after transaction commits. Domain events can be detailed. Integration events should be lean.
How do I version module APIs for backward compatibility?
Use semantic versioning. Support multiple API versions simultaneously during transition periods. Deprecate old versions explicitly with timelines. Use tolerant reader patterns so consumers ignore unknown fields. Event versioning requires special handling – include version numbers in event metadata and support multiple event versions.