Architecture

Modular Architecture: Boundaries, Dependencies, and Cohesion

A module boundary earns its place when it keeps a change local. If adjusting an invoice rule means editing order queries, database mappings, and several unrelated services, the system’s boundaries are telling you more than its directory tree is.

Published October 30, 2026

Imagine a developer asked to change how overdue invoices are calculated. In a well-separated system, the work belongs mostly inside billing. In a tangled one, the developer may need to inspect order creation, customer records, database tables, and a set of utility classes whose names offer few clues about who owns the rule. The difference is not the number of folders. It is whether the code that changes together has been given a boundary that makes sense.

Modular architecture is the practice of grouping related behavior and controlling how groups depend on one another. Good modules expose a small, useful interface while keeping internal decisions private. The aim is not to create the maximum number of components; it is to let a team understand, test, and change one part of a system without dragging the whole system along.

That distinction matters because almost any codebase can be divided into folders. A directory called billing is not automatically a billing module if every caller can reach into its persistence classes, recalculate its totals, and update its tables directly. A genuine boundary limits those routes and makes ownership visible.

A boundary is a change-control mechanism

Think of a module boundary as an agreement: this module owns a set of rules and state; other modules may ask it to perform supported operations, but they do not get to rely on every internal detail. That agreement reduces the number of places a change can ripple through.

For example, a billing module might expose issueInvoice and recordPayment. A caller should not need to know whether invoices are stored in one table or several, how tax is rounded internally, or which query retrieves an account’s balance. Those details can change without requiring every caller to change with them.

A module’s interface is therefore more than a public method signature. It is the set of assumptions other code makes about it: the meaning of its data, the timing of its operations, its error behavior, and the stability of the identifiers it returns. A method called issueInvoice that sometimes creates a draft and sometimes sends a final invoice has a boundary problem even if its API is technically small.

A useful test is to name a likely change and trace its expected path. If changing a billing policy requires edits across orders, reporting, and database adapters, ask whether those components truly own part of the policy or have become coupled to billing internals. The point is not to force every change into one file. It is to make the places that must change reflect the behavior’s real ownership.

Cohesion: what belongs together?

Cohesion describes how closely the responsibilities inside a module relate to one another. A cohesive module groups code because the code participates in the same capability or protects the same set of rules—not merely because it was written by the same team or happens to use the same framework.

Consider a CustomerService that validates shipping addresses, calculates invoice tax, sends marketing email, and changes passwords. The shared noun “customer” is not enough to make those behaviors a coherent unit. Each may have different reasons to change, different external dependencies, and different access rules. Putting them together can turn a convenient service into a central junction that everyone must modify.

On the other hand, splitting every method into a separate module is not a cure. If InvoiceNumberFormatter, InvoiceLineLoader, and InvoiceTotalCalculator are separate packages but always change together and have no independent clients, the additional boundaries may create navigation and coordination costs without providing meaningful independence.

A practical signal of cohesion is shared change. If a set of behaviors repeatedly changes together because they implement one policy, that is evidence they may belong together. Another signal is the kind of question a module can answer: “How is an invoice issued?” has a clearer owner than “Where do we put miscellaneous customer-related code?”

High cohesion does not mean a module has one tiny responsibility in the literal sense. It means its responsibilities fit a comprehensible purpose. A billing module can contain invoice issuance, payment recording, and credit handling if those behaviors form a consistent billing capability and their rules are owned together.

Dependency direction should follow ownership

Dependencies are the paths through which one part of a system relies on another. When module A imports a class from module B, changes to B’s interface can affect A. If many modules depend on B’s internal details, B is not just a module; it is a source of system-wide change.

Dependency direction is healthier when it points toward the owner of the rule. Suppose order placement needs to request a credit check. The order workflow should call a billing-owned operation, rather than reproduce the billing policy or read billing tables directly. The billing module can then evolve its implementation while retaining a stable operation for clients.

In code, the difference might look like this:

// Fragile: the order module knows billing's storage and policy details.
function placeOrder(order, billingDatabase) {
  const account = billingDatabase.accounts.find(order.accountId);

  if (account.balance > account.creditLimit) {
    throw new Error("Credit limit exceeded");
  }

  // Continue placing the order...
}

The order workflow is now coupled to the billing database structure and credit rule. If that rule changes, or billing changes how accounts are stored, the order module may need a corresponding edit.

// Clearer: billing owns the decision.
function placeOrder(order, creditPolicy) {
  creditPolicy.assertCanPlaceOrder(order.accountId, order.total);

  // Continue placing the order...
}

The second version does not require an elaborate framework. The important change is ownership: order placement asks for a decision through an operation whose meaning is explicit. In a larger system, that operation might be an interface, a function passed into the workflow, or a message sent to another service. The right form depends on deployment and operational needs; the boundary principle is the same.

This is closely related to the Dependency Inversion Principle, one of the SOLID principles. The principle is often flattened into “use interfaces everywhere.” That misses the useful point: high-level policy should not be forced to depend on the details that implement it. An interface helps only when it represents a meaningful, stable contract. A generic interface with one implementation and no clear boundary can add ceremony rather than independence.

Dependency cycles make ownership blurry

A dependency cycle exists when modules eventually depend on one another. The simplest example is direct: orders imports billing, and billing imports orders. An indirect cycle can be harder to spot: orders depends on customers, customers depends on billing, and billing depends back on orders.

Cycles are troublesome because they make independent change difficult. They can also complicate build order, testing, and understanding. A developer opening one module cannot confidently tell which other modules it needs, because the dependency path eventually circles back. In package systems that restrict circular imports, the issue may appear as a build failure. In systems that permit them, it can remain a design problem hidden behind successful compilation.

Take a billing module that needs order details to calculate a charge, while orders imports billing to obtain the final charge. The question is not simply, “Which import should we delete?” First ask who owns the charging rule and what information it genuinely needs. Perhaps orders should supply a compact charge request to billing. Perhaps a shared domain concept has been placed in the wrong module. Or perhaps an application-level workflow should coordinate both capabilities without making either module own the other.

One repair is to move a stable contract to the side that needs the capability. For instance, an order workflow can depend on a small CreditApproval contract, while billing provides its implementation. The contract should describe the decision the workflow needs, not expose billing’s internal classes. Another repair is to extract a shared concept into a genuinely independent module when both modules use it and it has a clear, stable meaning.

Be careful with the “shared” module. A package named common, core, or shared can become a dumping ground where dependencies accumulate and ownership disappears. Put something there because it has a coherent role and a stable interface—not simply because two modules currently import it.

A worked example: breaking an orders–billing cycle

Suppose orders contains a checkout function that imports a billing class. Billing, in turn, imports the order model to inspect line items. The dependency graph is circular, and each module knows the other’s implementation. A targeted refactoring can untangle it without redesigning the whole application.

  1. Write down the business decision. Identify whether billing owns the calculation, whether orders owns the cart contents, and what the caller needs to know before it proceeds.
  2. Replace internal data access with a narrow request. Instead of passing an entire order object into billing, define the minimum stable input, such as an account identifier and a list of chargeable amounts.
  3. Give one module ownership of the rule. If tax and payment eligibility are billing rules, keep them there. Do not duplicate them in the checkout workflow to eliminate an import.
  4. Let an outer workflow coordinate the sequence. The application layer can collect order information, call billing, and record the result. It coordinates the modules without turning their internal models into shared public property.
  5. Test the boundary from both sides. Verify that the checkout workflow sends the expected request and that billing applies its rule correctly. Avoid tests that reach into billing’s private storage simply to assert an internal implementation detail.

For example, the contract might accept an InvoiceRequest containing a customer identifier and line items represented by stable values. Billing returns an invoice identifier or a typed rejection. Orders does not need to know whether billing uses a relational table, a ledger, or a third-party tax service. It needs to know what it can request and what outcomes to handle.

This is a boundary improvement, not an instruction to create a new service or package for every contract. If both modules live in one deployable application, a function or interface may be enough. Adding network calls, serialization, and operational failure modes to solve a source-code dependency problem would be an expensive overreaction.

Split modules by responsibility, not by fashion

Teams often notice a large module and reach for a familiar split: by technical layer, by database table, or by class size. Those approaches can help, but none guarantees useful boundaries. A module made of separate controller, service, and repository folders may still scatter one business capability across the codebase. Conversely, a small application may be easier to understand with a modest number of capability-oriented modules.

Before splitting, look for concrete pressure. Do different parts of the module change for unrelated reasons? Do unrelated teams repeatedly collide in the same files? Are clients relying on internal representations because no useful interface exists? Does the module have a test setup so broad that a small behavior change requires running a large suite? These are stronger signals than a line-count threshold.

Then draw the proposed boundary in terms of what crosses it. List the operations clients need, the data they must supply, and the outcomes they need to handle. If the interface is as wide as the current implementation, the split may only relocate complexity. If the boundary hides useful variation and leaves callers with a clear task, the split may genuinely reduce coupling.

Refactor in small, behavior-preserving steps. Move one cohesive behavior, update its callers to use the new contract, and run the relevant tests before extracting another piece. If the change becomes difficult to verify, stop and reconsider the boundary. Refactoring is not a contest to produce the cleanest-looking package diagram; it is controlled change with the system’s observable behavior kept intact.

The warning signs: God objects and false boundaries

The God object anti-pattern appears when one class or module accumulates knowledge and responsibilities that belong in several places. It may have methods for authentication, billing, reporting, and account management; callers become dependent on it because it is the easiest route to everything. A God object often masquerades as a convenient facade, but unlike a deliberate facade it tends to own too much policy and state.

Splitting a God object means identifying the rules it has gathered and assigning each to a coherent owner. Extracting methods into separate classes is not enough if all classes still share mutable state and call one another freely. The real improvement comes when the new modules have purposeful interfaces and fewer reasons to change together.

A false boundary can also emerge from excessive abstraction. If every internal operation passes through multiple wrappers, factories, adapters, and interfaces, developers pay a navigation cost without gaining a change boundary. The key question is not “Could this be an interface?” but “Which decision does this interface let a module keep private?” If there is no good answer, the abstraction may not be earning its keep.

Another warning sign is a public interface that leaks representation. Returning a database entity with mutable fields, for example, lets callers make assumptions about persistence structure and possibly change state without invoking the owner’s rules. Prefer a result shaped around the caller’s need, while avoiding the opposite extreme of inventing a custom data-transfer type for every trivial value.

Testing the seam, not every private detail

Tests can reveal whether a boundary is useful. If testing one module requires a live database, a queue, and several unrelated modules, its dependencies may be too broad—or the test may be testing the entire application rather than that module. A focused module test should exercise behavior through the public interface and replace external collaborators where that gives meaningful isolation.

Do not mock every class by default. Excessive mocks can make a test pass against an imaginary design while breaking as soon as real collaborators are assembled. Test the contract that clients rely on: an accepted payment is recorded, a rejected credit request returns a clear outcome, or an invoice receives the correct total. Keep a smaller number of integration tests to verify that the modules are wired together correctly.

When a module’s public interface changes, tests should make the consequences visible. A narrow interface gives clients fewer assumptions to update; an accidental change to a private calculation should not force unrelated caller tests to be rewritten. This is one of the practical rewards of a real boundary: tests can follow ownership instead of mirroring internal file structure.

A practical boundary review

Before adding a module or extracting an existing one, use a short review to check whether the proposed boundary reduces coupling rather than merely moving it.

  • Can the module’s purpose be described in terms of a capability or a set of rules it owns?
  • Do the behaviors inside it tend to change together for understandable reasons?
  • Can callers complete their work through a small interface rather than reaching into internal classes or tables?
  • Does dependency direction point toward the owner of the relevant policy?
  • Have direct and indirect dependency cycles been checked?
  • Can the module be tested through its contract without reproducing the full application?
  • Does the proposed split remove a real maintenance cost, or does it introduce abstractions without making change safer?

These questions are more useful than aiming for a prescribed number of modules. A small codebase may need only a handful of well-defined boundaries. A larger system may need more, particularly when teams, deployment units, or regulatory constraints differ. The scale should follow the actual sources of change and risk.

Keep the boundary honest

Modules are not permanent monuments. As requirements shift, a once-cohesive set of behavior may develop distinct reasons to change, or two modules may prove so tightly connected that their separation creates needless coordination. Treat the boundary as a design decision that can be revisited, not a verdict imposed by the initial package layout.

Watch how changes travel through the system. If a change repeatedly crosses a boundary, investigate whether the boundary is misplaced, whether a stable operation is missing, or whether the change is genuinely a cross-cutting workflow. Sometimes the answer is to move behavior. Sometimes the answer is to improve an interface. And sometimes the system is correctly expressing a business process that spans multiple capabilities.

Good modular architecture makes ordinary work less surprising. A developer can find the rule’s owner, understand the supported way to use it, and make a change without learning every internal detail of neighboring code. That is the standard worth pursuing: not the most elegant diagram, but boundaries that keep related behavior together, dependencies pointed in sensible directions, and routine changes close to the code that owns them.

Theme