ENGINEERING INSIGHTS · ARCHITECTURE
Draw boundaries around change, not around nouns.
How to find module boundaries that keep a codebase changeable, enforce them in CI, record why they exist, and recognize when a module has earned its own service.
01 / 06
The question that produces the wrong boundaries
Most early architecture discussions start with the nouns in the domain: users, orders, products, invoices. Each noun becomes a folder, a table, sometimes a service. The result looks tidy on a diagram and behaves badly in practice, because most real changes cut across several of those nouns at once. Adding a discount touches products, orders, invoices, and the email templates. If those are four modules, or four services, one feature becomes four coordinated changes.
In 1972, David Parnas proposed a better criterion: decompose a system so that each module hides a design decision that is likely to change. The question is not "what are the things in this domain?" but "what is going to change, and what should be protected from that change?" Pricing rules change weekly; the fact that an order has line items rarely does. Tax calculation changes when you enter a new country. The payment integration changes when the commercial terms do.
A good boundary is drawn so that a typical change lands inside one module. A bad one is drawn so that every change has to negotiate its way across it.
02 / 06
Finding boundaries in an existing codebase
In an existing system you do not have to guess. Version control has recorded which files change together. Files that are repeatedly modified in the same commits share a reason to change, whichever folders they currently live in.
git log --since="12 months ago" --name-only --pretty=format:'@@' -- src \
| awk '/^@@/ { emit(); next } NF { files[$0] = 1 }
END { emit(); for (p in pairs) print pairs[p], p }
function emit( a, b) { for (a in files) for (b in files) if (a < b) pairs[a " " b]++; delete files }' \
| sort -rn | head -40Read the output for pairs that cross your current folder structure. A pricing file that always changes with the checkout controller and the invoice renderer is telling you where the pricing decision actually lives. Then talk to the people who request changes and ask what they ask for most often. The boundaries worth enforcing are the ones the history and the conversations agree on.
03 / 06
Start with modules inside one deployable
Once you know the boundaries, the cheapest place to enforce them is inside a single deployable application: a modular monolith. Each module owns its data and exposes a small public interface; everything else is private. Calls between modules are function calls, transactions still work, and moving a boundary that turned out to be wrong is an afternoon in an editor rather than a migration across services.
01Entry points
- HTTP API
- Background jobs
- Admin UI
02Public APIs
- checkout/index.ts
- pricing/index.ts
- invoicing/index.ts
03Internals
- Checkout flow
- Price rules and promotions
- Invoice rendering and tax
04Owned data
- checkout.*
- pricing.*
- invoicing.*
Martin Fowler's MonolithFirst argument rests on the same observation: boundaries are hard to get right before you understand the domain, and far cheaper to move inside one codebase than between services. Shopify's account of deconstructing its monolith describes choosing exactly this route, componentizing in place, at a very large scale.
04 / 06
Make a boundary violation a build failure
A boundary that exists only in a diagram erodes the first time someone is in a hurry. Make it executable. In a TypeScript codebase, dependency-cruiser can fail the build when code imports another module's internals. The $1 in the first rule refers to the module matched in from, so a module may import its own files freely but other modules only through their index.ts.
module.exports = {
forbidden: [
{
name: "modules-use-public-apis",
comment: "A module imports another module only through its index.ts.",
severity: "error",
from: { path: "^src/modules/([^/]+)/" },
to: {
path: "^src/modules/[^/]+/",
pathNot: ["^src/modules/$1/", "^src/modules/[^/]+/index\\.ts$"],
},
},
{
name: "entry-points-use-public-apis",
comment: "Routes, jobs, and UI code never reach into module internals.",
severity: "error",
from: { pathNot: "^src/modules/" },
to: { path: "^src/modules/", pathNot: "^src/modules/[^/]+/index\\.ts$" },
},
],
};Run it in CI next to the type checker. Most ecosystems have an equivalent: ArchUnit for Java, import-linter for Python, internal packages in Go. The tool matters less than the property it gives you: a dependency that crosses a boundary becomes a visible, reviewed change to the rules, not a quiet import.
Apply the same discipline to data. Give each module its own schema (in PostgreSQL, literally CREATE SCHEMA pricing) and, if you can, its own database role. Shared tables are the most common way a modular monolith turns back into a tangled one, because a join across modules is so easy to write.
05 / 06
Record why each boundary is where it is
Boundaries outlive the conversations that produced them. Two years later someone will want to merge two modules or split one, and they deserve to know what the original trade-off was. An architecture decision record is the lightest tool that works: a short, numbered note kept in the repository next to the code. Michael Nygard's original format has lasted because it is small.
# 7. Pricing is a separate module from checkout
Status: Accepted. Supersedes ADR 3.
## Context
Promotions change weekly and are owned by the commercial team.
Checkout changes rarely and contains the payment integration.
Most commits touching checkout/ last quarter were pricing changes.
## Decision
Move price calculation into src/modules/pricing behind
quote(cart, customer): Quote. Checkout stores the Quote it was
given and never computes prices itself.
## Consequences
- Promotions ship without touching payment code.
- Checkout must persist the Quote the customer saw, for disputes.
- A pricing outage blocks new checkouts; quotes are cached for 15 minutes.The record holds the forces at the time, the decision in a sentence or two, and the consequences, including the inconvenient ones. When the context changes, write a new record that supersedes the old one rather than editing history.
06 / 06
When a module has earned its own service
Extracting a module into a separately deployed service buys independence and costs a great deal: network failure modes, eventual consistency or distributed transactions, versioned interfaces, and one more thing to deploy, monitor, and secure. It is worth it when the module needs something the monolith cannot give it.
| Signal | Extract? | Why |
|---|---|---|
| A different scaling profile, such as CPU-heavy document processing | Yes | Scaling the whole application for one workload wastes money and couples failures. |
| A different release cadence, owned by a separate team | Yes | Independent deployment removes coordination that slows both teams down. |
| Different isolation needs for failures or security | Yes | A crash or compromise in one area should not reach payment or identity code. |
| The codebase feels large | No | Size is a navigation problem. Modules and clear ownership solve it more cheaply. |
| Microservices are the target architecture | No | An architecture is a means. Extract when a specific constraint demands it. |
| The boundary is still moving | Not yet | Wait until the interface has been stable for a while; moving it later is expensive. |
When the signals are there, the modular boundary you already enforce becomes the service interface. The strangler fig approach then moves traffic to the new service gradually while the old path keeps working. The hard part, finding the boundary, was done long before, inside the monolith.
One last force deserves a name. Melvin Conway observed that systems tend to mirror the communication structure of the organizations that build them. If two teams share one module, expect the code inside it to drift toward the line between the teams. Assign module ownership and team ownership together, or the org chart will redraw the architecture for you.
References
- On the Criteria To Be Used in Decomposing Systems into Modules David L. Parnas, Communications of the ACM, 1972
The original argument for information hiding as the basis for modules.
- How Do Committees Invent? Melvin E. Conway, Datamation, 1968
The paper behind Conway's law.
- MonolithFirst Martin Fowler
Why boundaries are easier to discover and move inside one codebase.
- Documenting Architecture Decisions Michael Nygard, 2011
The original architecture decision record format.
- dependency-cruiser rules reference dependency-cruiser
Rule syntax, including group matching with $1.
- Deconstructing the Monolith Shopify Engineering
A large-scale case for a modular monolith over a move to services.
- StranglerFigApplication Martin Fowler
Replacing part of a system gradually while it keeps running.
HOW NEURALSHARKS CAN HELP
Related NeuralSharks services
- Engineering
Custom software development
Software shaped around your users, operations, and ambitions.
- Product discovery and technical architecture
- Application and backend development
- Release planning and ongoing evolution
- Engineering
Web development
Fast, accessible web experiences built for real use.
- Information architecture and interface development
- Responsive applications and content systems
- Performance and accessibility checks
- Platforms
Data & integrations
Connect the systems and information your business depends on.
- Integration and data-flow mapping
- API connectors and pipeline development
- Validation, reconciliation, and failure recovery