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.

AUTHOR
NeuralSharks EngineeringEngineering team
AUTHOR EXPERTISE
  • AI engineering
  • Software architecture
  • Quality engineering
  • Cloud & DevOps
PUBLISHED
UPDATED
READING TIME
6 min1,393 words

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.

Shell
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 -40
Counts how often each pair of files changed in the same commit over the last year. It uses only POSIX awk, so it runs on Linux and macOS.

Read 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.

  1. 01Entry points

    • HTTP API
    • Background jobs
    • Admin UI
  2. 02Public APIs

    • checkout/index.ts
    • pricing/index.ts
    • invoicing/index.ts
  3. 03Internals

    • Checkout flow
    • Price rules and promotions
    • Invoice rendering and tax
  4. 04Owned data

    • checkout.*
    • pricing.*
    • invoicing.*
FIG. 1Each module exposes one public entry file and owns its own schema. Entry points may call any module's public API; modules never reach into each other's internals or tables.

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.

.dependency-cruiser.cjsJavaScript
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.

docs/adr/0007-pricing-module.mdMarkdown
# 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.
An example record. Note the uncomfortable consequences; they are the part future readers need most.

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.

Reasons to extract a service, and reasons that are not enough
SignalExtract?Why
A different scaling profile, such as CPU-heavy document processingYesScaling the whole application for one workload wastes money and couples failures.
A different release cadence, owned by a separate teamYesIndependent deployment removes coordination that slows both teams down.
Different isolation needs for failures or securityYesA crash or compromise in one area should not reach payment or identity code.
The codebase feels largeNoSize is a navigation problem. Modules and clear ownership solve it more cheaply.
Microservices are the target architectureNoAn architecture is a means. Extract when a specific constraint demands it.
The boundary is still movingNot yetWait 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

  1. 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.

  2. How Do Committees Invent? Melvin E. Conway, Datamation, 1968

    The paper behind Conway's law.

  3. MonolithFirst Martin Fowler

    Why boundaries are easier to discover and move inside one codebase.

  4. Documenting Architecture Decisions Michael Nygard, 2011

    The original architecture decision record format.

  5. dependency-cruiser rules reference dependency-cruiser

    Rule syntax, including group matching with $1.

  6. Deconstructing the Monolith Shopify Engineering

    A large-scale case for a modular monolith over a move to services.

  7. StranglerFigApplication Martin Fowler

    Replacing part of a system gradually while it keeps running.

HOW NEURALSHARKS CAN HELP

Related NeuralSharks services