Modular System Design: Build Modules That Evolve Independently

Modular System Design: Build Modules That Evolve Independently
Published

15 Jun 2026

Author
Binisha Sharma

Binisha Sharma

Table of Contents

A simple change to one part of the system shouldn't take three weeks. When it does, the cause is rarely the engineer doing the work — it's the architecture they inherited. A refund flow that should be a one-day fix touches the order module, the inventory module, the payments module, the notifications module, and a customer-tier discount calculation buried in the loyalty module because someone needed that logic urgently in 2022. The change ships eventually. The squad's velocity, which felt healthy in month six, has been quietly dropping ever since.

Modular system design is the architectural discipline that prevents that pattern. The principle is straightforward — modules with clear boundaries, narrow contracts, and the ability to evolve independently. The practice is harder than the principle, because the temptation to call across the boundary "just this once" is constant, and every shortcut compounds. This article shows what real module boundaries look like at the code and contract level, the failure modes when they're violated, and the implementation moves that keep a system fast past the 18-month mark.

The Cost of the Everything-Touches-Everything Trap

A monolithic codebase isn't a problem at month one. It's a problem at month eighteen. The shortcut that saved a sprint in month three becomes the dependency that costs a fortnight in month fifteen, and the squad starts allocating "complexity tax" to every estimate without anyone naming what's actually happening.

The cost arrives along three axes. The first is calendar time: features that should take a day take a week, because every change requires reading code paths that have nothing to do with the work being shipped. The second is risk: every pull request touches more files than it should, every release rolls back more often than it should, and the team starts avoiding changes to "scary" areas because no one fully understands what depends on them. The third is people: the engineers who built the tangled parts move on, and new engineers spend their first six weeks doing archaeology instead of shipping.

In Built to Last™ 2.0, modular system design sits in P03 — The Right Architecture. It is the difference between a system that absorbs two years of feature requests and one that needs a rebuild at month eighteen. Across 600+ products shipped since 2004, the codebases that stay fast share one structural property: their modules can be reasoned about and changed in isolation.

What Modular System Design Actually Is

Modular system design isn't a label you attach to an existing architecture. It's a set of structural commitments enforced at the code, contract, and process level. A module is a unit of code with a defined responsibility, a published interface that other modules depend on, and a private implementation that other modules cannot depend on. Three properties make a module real rather than nominal.

A defined responsibility. Each module owns one coherent slice of the domain — payments, identity, inventory, notifications, search. The slice is named in business language, not framework language. "User service" is a hint; "identity and authentication module" is a definition. If you can't explain what a module is responsible for in one sentence without using the word "and" twice, the boundary is wrong.

A published interface. Modules talk to each other through a contract — a typed API, an event schema, a documented set of messages on a queue. The contract is the only surface external code can rely on. Internal types, helper functions, database tables, and class hierarchies belong to the module; nobody else gets to import them. The contract changes with versioning discipline. Internal code changes with whatever cadence the module's owners want.

A private implementation. This is the property most often violated. The implementation behind the contract can be a single function, a service, or a subsystem of services — it doesn't matter, because no other module is allowed to know. The day another module starts reaching into the implementation, the boundary is dead, and the module's owners lose the right to change anything without asking permission.

What a Real Module Boundary Looks Like

Take a payments module. Its responsibility is to authorise, capture, refund, and reconcile transactions. Its published interface is a typed API: createPayment, capturePayment, refundPayment, getPaymentStatus. Its private implementation owns the connection to the payment gateway, the retry logic, the idempotency keys, the reconciliation jobs, and the database tables that store transaction history. Other modules — order, customer, notifications — call the four interface functions. They do not read the payments database. They do not import internal payments types.

When a refund flow needs to change to handle a new partial-refund scenario, the change happens inside the payments module. The interface gets a new optional field, the owners ship it, and downstream modules adopt it when they're ready. The order module's schema doesn't change. The notifications templates don't change. The loyalty calculation doesn't change. It is, strictly, a local change. Every local change is a few days of work; every system change is a few weeks. The same separation underwrites how we structure long-running custom software engagements.

Where Boundaries Get Violated

Three patterns destroy module boundaries, and engineers under deadline pressure are prone to all three.

The first is the shared database. Two modules read and write the same tables because "they need the same data anyway." ( AWS Well-Architected Framework ) Within six months, schema changes require coordinating updates to both modules; within twelve months, one module owns the writes and the other reads inconsistent state. The fix is non-trivial: separate the schemas, expose data through the published contract.

The second is the direct internal call. A feature needs logic that lives inside another module. The contract doesn't expose it, but the function is right there, so someone imports it. The boundary is now leaking. Every internal change in the source module risks breaking the consumer. The fix is to add the capability to the contract or duplicate the logic locally — both honest options; the cross-module import is not.

The third is the shared model class. Order and inventory both reference a Product class defined in a common library. When inventory needs a new attribute, order is forced to upgrade in lockstep. Shared model classes are a coupling vector dressed up as a convenience. The honest version is deliberate duplication — each module defines its own representation containing only the fields it actually needs.

Failure Modes Even When Modularisation Is Present

A codebase can be modular on paper and still behave like a monolith. Two failure modes are particularly common.

The first is overly chatty contracts. A module exposes fifty interface methods because every internal function got a public wrapper. The contract is no longer a contract — it's the implementation with a thin alias. Real boundaries expose a small surface. If your module has more than around a dozen public operations, the responsibility is probably too broad.

The second is event-spaghetti. Modules communicate by emitting events, which on the surface looks decoupled, but the events carry implementation-specific payloads and consumers reach into them as though they were function arguments. The decoupling is cosmetic. The fix is to design events as part of the contract — versioned, documented, with stable schemas, and payloads at the level of business meaning rather than database rows.

How to Implement Modular System Design

The implementation effort divides into two cases: a greenfield build and a codebase that's already tangled. Both are tractable, but the moves are different.

Greenfield: Lock the Boundaries Before Sprint One

For a new build, the work happens in the Architecture Session — the structured pre-sprint workshop where the Architecture Decision Records get written. Three artefacts come out of it.

A module map: every module named, its responsibility stated in one sentence, its consumers and dependencies documented. Most builds need three to seven modules in their initial architecture, not thirty. Over-decomposition is expensive.

A contract sketch for each module: the public operations, the inputs and outputs, the events emitted, the events consumed. The goal at this stage is the shape, not the fields. Detail accrues during sprint planning.

A dependency rule: a diagram showing which modules are allowed to depend on which. Most domains form a directed acyclic graph; cycles almost always indicate two modules are really one. The rule is enforced by lint or code review, not hope.

By month six, the system has the property the architecture promised: changes happen inside modules, not across them.

Brownfield: Refactoring Tangled Code

Most teams aren't starting greenfield. They have a codebase that's already three years old, partly modular, partly tangled, with revenue depending on its continued operation. The temptation is to rewrite. The honest answer is almost always to refactor in place.

The first move is to draw the map of what exists, not what was intended. Read the codebase, identify the natural seams, document them. Most tangled codebases have more structure than the team gives them credit for; the structure is just unmarked.

The second move is to pick one boundary and harden it. Not all of them. One. Choose the module with the highest change frequency or defect rate — the place where the cost of tangling is most visible. Define its contract. Move its dependencies behind that contract. Replace direct database reads from consumers with API calls. This is unglamorous work that pays back across every future change to that module.

The third move is to gate further damage. Add lint rules or architecture tests that prevent new cross-module dependencies from sneaking in. Make the cost of violating the boundary visible at pull-request time, when it's cheap to push back.

Throughout, the production system keeps shipping. The modular work happens in parallel with feature work, paid for as a percentage of every sprint — typically 10–20% — rather than as a quarter-long rewrite. For embedded squads inside a client's existing codebase, our TaaS engagement model treats this as ongoing operational discipline, not a separate project. For mobile builds, where deploy cycles are constrained by app store review, the boundaries matter even more; our mobile delivery approach treats feature modules and the cross-cutting kernel as separate artefacts from sprint one.

A Tangled Marketplace, Rebuilt

An Australian retail business at the Scale stage we worked with ran an e-commerce marketplace whose payments logic had grown across five modules over three years. Refund flow lived in the order module. Currency conversion sat in a shared library imported by inventory. Loyalty discount adjustments to the refund amount happened inside the customer-tier module. The original payment provider integration was wrapped in a service that two other modules called directly for non-payment reasons.

A regulatory change required the refund flow to support partial refunds with itemised tax breakdown. The team scoped the change at one sprint. It took three weeks. Every module that touched payments had to be modified, regression tested, and re-deployed.

The team paused new feature work for two sprints to draw the existing payments boundary, define a payments contract — createPayment, capturePayment, refundPayment, getPaymentStatus, plus a partial-refund variant — and migrate consumers behind it. Order, inventory, and loyalty each adopted the contract over the following month. The currency-conversion logic moved inside payments. The next regulatory change shipped inside a single sprint, touched no other module, and required no cross-team coordination. A similar contract-discipline pattern is visible in the embedded engineering work we did with Nuvei, where provider changes became sprint-level work rather than quarter-level.

The lesson wasn't that the team was sloppy. The original architecture didn't enforce its own boundaries, and three years of small decisions made under deadline pressure compounded.

When Modularisation Is Critical, When You Can Defer

Modular system design is critical when the build will be alive for more than 18 months under active development. That covers most products. Past the 18-month mark, the velocity gap between modular and tangled codebases becomes structural. Tangled codebases either get refactored or get rewritten — both expensive, the refactor cheaper.

It is also critical when the squad composition will change, and it always will. Modular boundaries are how knowledge transfers. A new engineer can become productive inside one module without understanding the whole system; in a tangled codebase, they need to read everything before they can change anything.

Where you might defer: a genuine throwaway prototype, an experiment whose only purpose is to test a riskiest assumption, or a build with a defined sunset date inside twelve months. Be honest about which case you're in — most "throwaway" builds outlive their original intent.

The microservices question deserves a direct answer. Microservices are one expression of modular system design, not a synonym for it. A modular monolith can be every bit as boundary-disciplined as a microservices architecture, with none of the operational cost. Reach for microservices when independent deployability, language heterogeneity, or team autonomy at scale actually justify the overhead — not because the word "modular" was in the brief.

What to Do Next

Pick one module — the one where changes hurt most — and draw its boundary this week. Define the contract. Identify the violations. Build the refactor into the next two sprints.

For the broader delivery context, read our project delivery framework. If you're planning a new build and want to lock the boundaries before sprint one, the custom software service is where the Architecture Session lives.

Frequently Asked Questions

How do we avoid the everything-touches-everything trap?

By naming module boundaries in business language, publishing narrow contracts, and enforcing the dependency rules with lint or architecture tests rather than goodwill. The trap arrives when "just this once" cross-module access is allowed under deadline pressure and never undone. Make the boundary cheaper to respect than to violate, and the trap mostly takes care of itself.

What's a real module boundary versus a fake one?

A real boundary is a contract: a documented set of operations and events other modules depend on, with everything else hidden. A fake boundary is a folder structure — code grouped by feature, but consumers reach into internal types, share database tables, or import helper functions across the line. If you can rename or rewrite a module's internals without breaking any other module, the boundary is real.

When is microservices the right answer instead of a modular monolith?

When you need independent deployability across teams that ship on different cadences, when language or runtime heterogeneity has a clear cause, or when team autonomy at organisational scale outweighs the operational cost. For most product builds — small to mid-size squads, single language, shared deployment cadence — a modular monolith delivers the same boundary discipline without the network, observability, and deployment overhead microservices add.

How do we refactor existing tangled code without stopping feature delivery?

Pick one module, define its contract, migrate its consumers behind it, and gate further damage with architecture tests. Pay for the work as a fixed percentage of every sprint — typically 10 to 20 percent. Resist the urge to refactor all modules at once; the gain compounds when one boundary at a time gets fully hardened.

How do we measure whether modularisation is working?

Three signals. First, the proportion of changes that touch one module versus multiple — over time, single-module changes should grow as a share of total. Second, time from pull request opened to pull request merged — modular codebases hold this stable past month 18 while tangled ones drift upward. Third, onboarding time for new engineers contributing meaningfully to a single module, measured in days, not weeks.