Modular monolith boundaries are discovered from how a business changes and runs, not from a preferred folder tree. A single deployable can contain genuinely autonomous business modules, but only when dependencies, data access, and decision rights are explicit. Renaming horizontal layers as modules does not remove coupling. The practical task is to find cohesive responsibilities that can change together while interacting through narrow, intentional contracts.
No single evidence source is trustworthy enough. Domain workshops reveal language and policy; version history reveals files that repeatedly change together; traces reveal calls that execute together; schemas reveal ownership conflicts; incidents reveal operational coupling; and team history reveals coordination cost. The method below combines those signals, records disagreements, and tests a candidate boundary before a costly extraction or reorganization.
Define what a useful module boundary must achieve
Begin with outcomes. A module should own a recognizable business capability, expose a small public interface, protect its internal model, and have an accountable team. It may share a process and deployment pipeline with other modules, yet its internal types and tables should not become an informal common library. Microsoft’s guidance on common web application architectures distinguishes a monolithic deployment from an all-in-one internal design: one deployment can still use projects, dependency inversion, and testable separation.
Write acceptance criteria before drawing boxes. Examples include: most feature changes remain inside one module; cross-module calls use declared interfaces; only the owning module writes its records; a module test can run with substitutes for peers; and failures have an identifiable owner. These are hypotheses, not instant rules. A checkout operation may legitimately need a local transaction across pricing and order state today, while customer messaging can be separated behind an asynchronous port.
Collect five independent evidence views
| Evidence view | Question answered | Useful measure | Main distortion |
|---|---|---|---|
| Business capability | Which rules and outcomes belong together? | Shared vocabulary, invariants, decision owner | Workshop opinions can mirror the current org chart |
| Change history | Which files repeatedly change in one delivery? | Co-change count normalized by file activity | Large formatting or generated-code commits create noise |
| Runtime traces | Which paths execute together and where is latency spent? | Call frequency, critical-path time, fan-in and fan-out | Sampling misses rare and batch workflows |
| Data access | Who reads and writes each record? | Writer count, cross-area joins, transaction scope | ORM mappings can hide dynamic SQL and reports |
| Team operations | Where does coordination or failure ownership accumulate? | Cross-team reviews, handoffs, incidents, rollback ownership | Ticket labels may not match actual work |
For change coupling, mine at least several representative release cycles and exclude bulk renames, dependency lockfiles, generated assets, and mechanical formatting. Count a pair only when both files participate in the same purposeful change, then divide by each file’s overall activity so frequently edited infrastructure does not dominate. GitHub’s repository activity view helps inspect pushes and merges, but a reproducible script over commit history is better for a full matrix.
Triangulate candidate modules instead of averaging scores
Evidence should challenge other evidence. If billing and entitlement files co-change frequently but traces show only one narrow call, inspect the commits: the cause may be a missing contract test, a shared DTO, or a genuine invariant. If traces show heavy traffic but changes are independent, the boundary may be sound with a chatty interface that needs batching. If two areas share transactions and tables, do not declare independence because their names sound different in a domain workshop.
Trace business operations, not just services
Instrument entry points and internal module calls with stable operation names and business-safe attributes. OpenTelemetry defines a trace as the path of a request through an application and spans as units of work within that path; its trace concepts support parent-child relationships and links. In a monolith, create spans around candidate module ports rather than every method. Capture scheduled jobs and message handlers as well as HTTP requests, and preserve links when work continues asynchronously.
Build a weighted directed graph where nodes are candidate capabilities and edges summarize call frequency, synchronous latency, failure propagation, and data transferred. Keep rare but consequential paths visible: month-end close may run once but impose stronger consistency than thousands of catalog reads. Runtime adjacency does not automatically mean one module. It shows the contract and reliability burden that separation would introduce.
Assign data ownership and transaction boundaries
Create a table-level and aggregate-level access inventory from ORM mappings, SQL logs, stored procedures, change-data-capture consumers, exports, and operational scripts. Mark one authoritative writer for every business fact. Other modules may receive a projection or call a query port, but direct writes make invariants unenforceable. Shared reference data needs an explicit steward and distribution policy rather than an owner called everyone.
| Signal combination | Likely decision | Required follow-up | Do not assume |
|---|---|---|---|
| High co-change, shared invariant, one transaction | Keep together initially | Refactor internals and define subcomponents | A future service split is impossible |
| Low co-change, narrow calls, distinct writers | Strong module candidate | Create public port and dependency test | Network extraction is immediately valuable |
| Low co-change, high synchronous traffic | Separate responsibility with interface redesign | Batch reads, cache stable data, measure latency | Traffic volume means shared ownership |
| High co-change, weak runtime relation | Investigate development coupling | Remove shared DTOs, fixtures, and build dependencies | The domain itself is coupled |
| Distinct logic, shared writes | Boundary is not yet enforceable | Choose write owner and provide migration path | Separate packages provide data autonomy |
Treat transaction scope as a business statement. Ask what must be atomically true when an operation commits, what can be compensated, and what deadline applies to convergence. Keeping two invariants in one module can be the honest design. Splitting them early merely converts a local transaction into retries, idempotency, reconciliation, and customer-visible intermediate states.
Enforce one boundary and run a delivery pilot
Choose a candidate with clear ownership and moderate coupling. Move its code behind a public interface, make internal packages inaccessible, route database writes through its application layer, and add architecture tests that reject forbidden imports. CODEOWNERS can request review from responsible teams for matching paths, as described in GitHub’s CODEOWNERS documentation, but review ownership complements rather than replaces executable dependency rules.
- Lead time and number of teams involved for changes centered on the module.
- Cross-module import violations, direct table accesses, and public API growth.
- Test setup time and whether module behavior can be exercised without unrelated subsystems.
- Trace fan-out, latency contribution, and failure containment for representative operations.
- Incidents, rollbacks, and policy exceptions with the responsible owner and expiry date.
Run the pilot through several real changes rather than a demonstration branch. A boundary that looks elegant but forces every feature through three interfaces is wrong or drawn at the wrong granularity. Conversely, some cross-module coordination is expected. The goal is not zero edges; it is understandable coupling whose cost is justified by the business operation.
Use a boundary scorecard as a conversation aid, not an optimizer. Record confidence and evidence date beside each signal. A module candidate supported by clean commit history but based on traces from one quiet week should be marked incomplete. Re-run the analysis after seasonal traffic, a major product launch, or a team reorganization. Boundary quality is partly temporal: a capability that once changed with everything else can become autonomous after policy and ownership mature.
Also inspect shared utilities. Logging adapters, clocks, identifiers, money types, and error envelopes may be legitimate platform dependencies, while a shared Common package containing customer, order, and billing models is usually domain coupling without ownership. Classify shared code as stable technical mechanism, governed cross-domain standard, or misplaced business behavior. Give the first two version and compatibility rules; move the third to its owning module and expose only the behavior peers need.
Key takeaways
- Define boundary outcomes before choosing module names or packages.
- Combine business, change, runtime, data, and ownership evidence; investigate disagreement between them.
- Normalize commit analysis and include rare but critical runtime workflows.
- Give every business fact one authoritative writer and document atomic invariants.
- Prove a boundary through real delivery, architecture tests, telemetry, and operational ownership before extracting a service.
Frequently asked questions
How many modules should a modular monolith have?
There is no useful universal count. Start with business capabilities that have coherent rules and owners, then merge candidates that require pervasive shared transactions or split candidates that contain independent change streams. A small system may need only a few modules; a large one can support more if contracts remain narrow and ownership stays clear.
Can modules share one database?
Yes, physical database sharing is compatible with a modular monolith. Logical ownership still matters: schemas, permissions, repository APIs, and tests should prevent one module from changing another module’s data directly. Separate physical databases are an optional later choice, not the definition of modularity.
When should a module become a service?
Extract when independent deployment, scaling, technology, resilience, or regulatory isolation has a measured benefit that exceeds distributed-system cost. A well-enforced in-process boundary makes extraction safer, but it does not create a business case by itself.
Conclusion
Reliable modular monolith boundaries emerge from converging evidence and tested responsibility. Use domain language to propose candidates, repository and trace data to challenge them, data ownership and transactions to make them honest, and a delivery pilot to prove their value. The result is not a prettier monolith. It is a system whose coupling is visible, governed, and inexpensive enough for the way the organization actually changes software.