Skip to main content

Modular architecture

Modular architecture divides an application into independent, self-contained units (modules) that communicate only through defined public interfaces, so teams can develop, test, and scale each piece without breaking the others.

Theory

TL;DR

  • Think of it like a restaurant kitchen: each station (prep, grill, sauce) owns its tools, handles its job fully, and passes finished work through a pass window (public API), never reaching into another station's storage.
  • The core principle: modules are black boxes. External code only sees what's explicitly exported.
  • Modules can depend on each other, but only through public index.js exports, never through internal file paths.
  • Decision rule: if you have multiple teams, features with independent lifecycles, or a codebase growing past 10k lines, modular architecture pays off.

Quick example

javascript
// userModule/index.js (PUBLIC API - the only door in) export { UserProfile } from './components/UserProfile.js'; export { useUser } from './hooks/useUser.js'; export { fetchUser } from './api/userService.js'; // App.js (external code) import { UserProfile, useUser } from './userModule/index.js'; // OK: using public API import { cacheUser } from './userModule/internal/store.js'; // Wrong: reaching into internals directly

Everything inside userModule/internal/ is the module's private business. Other modules have no idea it exists.

Key difference

Modular architecture isn't just organizing files into folders. The real test is whether changing one module's internal file structure breaks other modules. If it does, you have organized code, not modular code. The difference is hard boundaries: modules interact only through explicit contracts, so you can rewrite internals, swap implementations, or delete a module entirely without touching code outside it.

When to use

  • Multiple teams on the same codebase, where each team owns a module and merges don't conflict
  • Features with independent lifecycles, so payments can update without touching auth
  • Codebase scaling beyond one developer, to prevent "everything depends on everything"
  • Plugin-like features that can be enabled or disabled at will
  • Code you want to reuse across web and mobile apps

How bundlers handle module boundaries

When Webpack or Vite processes your code, it traces imports from entry points and builds a dependency graph. Each module gets its own scope, so variables inside don't leak globally. Tree-shaking then strips out anything not exported through the public API. At runtime, attempting to import from userModule/internal/store.js fails if that path isn't exposed in index.js. The boundary isn't just a convention. It's enforced.

Common mistakes

1. Importing from internal folders

javascript
// Wrong import { userCache } from './userModule/internal/store.js'; // Right import { getUserData } from './userModule/index.js';

When the module owner refactors internals, your code breaks. The public API is the contract. The internal structure is not.

2. Shortcuts through internals

javascript
// paymentModule/components/PaymentForm.js // Wrong: bypassing public API to "save time" import { validateToken } from '../../authModule/internal/tokenValidator.js'; // Right import { validateToken } from '../../authModule/index.js';

This creates hidden dependencies. Code reviewers see paymentModule and authModule as separate. In production, a refactor in authModule silently breaks PaymentForm.

3. Circular dependencies

javascript
// userModule/api/user.js import { logActivity } from '../../analyticsModule/index.js'; // analyticsModule/api/analytics.js import { getUser } from '../../userModule/index.js'; // Wrong

Bundlers can't resolve circular imports cleanly. You get undefined exports or build failures. Modules should form a directed acyclic graph (DAG). If two modules both need something, extract it to a third shared module.

4. Exporting everything "to be safe"

javascript
// Wrong: exposes internal helpers export { getUser } from './api/user.js'; export { validateEmail } from './internal/validators.js'; // Internal! export { userCache } from './internal/store.js'; // Internal! // Right: only what's intentionally public export { getUser } from './api/user.js';

Once something is exported, external code starts depending on it. You can't change it without breaking consumers. Treat your index.js like a public API contract.

5. Shared config as a shortcut around duplication

javascript
// Module A exports config export const config = { apiUrl: 'https://api.example.com' }; // Module B imports it import { config } from './moduleA/index.js'; // Module B now breaks if Module A changes the config shape // Better: each module manages its own config // moduleA/config.js and moduleB/config.js both define their own apiUrl

Duplication between modules is better than hidden coupling.

Real-world usage

  • React: feature modules (auth, dashboard, settings) each export components, hooks, and services through index.js
  • Express: route modules (users, payments, admin) export routers, combined in app.js
  • Redux: feature slices (user, cart, notifications) are independent reducers with their own actions and selectors
  • Next.js: App Router treats each route as a self-contained module with its own layout and data fetching
  • npm packages: every package is a module you consume through its public API, never through internal source paths

One pattern that causes the most grief: teams treating shared TypeScript types as a reason to import from internals. "It's just a type, it won't break anything" until the module owner moves that type and the build breaks across six other modules at once.

Follow-up questions

Q: How do you prevent circular dependencies between modules?
A: Use a dependency graph tool like Madge or Dpdm in CI to catch them before merge. Design dependencies to flow in one direction: UI modules depend on feature modules, feature modules depend on core/shared, never the reverse. If two modules need to share logic, extract it to a third module both can depend on.

Q: What's the difference between modular architecture and microservices?
A: Modular architecture lives within a single codebase and process, modules share memory and run together. Microservices are separate processes that communicate over a network. Modular is easier to start with. Microservices add deployment complexity but allow independent scaling and deployment.

Q: How do you handle shared state between modules without coupling them?
A: Use a state management layer (Redux, Zustand, or Context) that no module owns outright. Modules dispatch actions and subscribe to state slices. Or use an event emitter: modules emit events, others listen, without knowing who's listening. Avoid direct imports of state objects between modules.

Q: Can modules depend on each other directly, or should everything go through a central hub?
A: Direct dependencies are fine as long as they're acyclic. A depends on B, B depends on C works fine. A depends on B, B depends on A doesn't. A central hub like a Redux store or event bus works but creates a bottleneck where every module must know about it.

Q (senior level): How would you migrate a codebase where everything imports from everything into a modular structure without breaking production?
A: Start by identifying natural domain boundaries: auth, payments, users, notifications. Create index.js files that define public APIs without changing any internal code yet. Then gradually migrate imports from internal paths to public APIs. Add a linter rule (eslint-plugin-import or similar) that forbids imports from non-index paths. Run this alongside regular feature work, not as a big-bang refactor.

Examples

Basic module structure in React

javascript
// userModule/index.js - public API, the only file outsiders import export { UserProfile } from './components/UserProfile.js'; export { useUser } from './hooks/useUser.js'; export { fetchUser } from './api/userService.js'; // userModule/internal/store.js - private, never exported from index.js const userCache = new Map(); export function cacheUser(id, data) { userCache.set(id, data); } // userModule/api/userService.js - private, used internally only import { cacheUser } from '../internal/store.js'; export async function fetchUser(id) { const response = await fetch('/api/users/' + id); const data = await response.json(); cacheUser(id, data); // caching is an internal detail return data; } // App.js - external code import { UserProfile, useUser } from './userModule/index.js'; // fetchUser is available too. cacheUser and userCache are not.

The caching strategy is an internal detail. If the team replaces the Map with a Redis client, zero external files change.

Preventing circular dependencies

javascript
// paymentModule/index.js export { PaymentForm } from './components/PaymentForm.js'; // paymentModule/components/PaymentForm.js import { notifyUser } from '../../notificationModule/index.js'; // OK: importing from another module's public API // notificationModule/api/notify.js // Wrong - creates circular dependency back to paymentModule: // import { PaymentForm } from '../../paymentModule/index.js'; // Right: notificationModule knows nothing about paymentModule export function notifyUser(message, type) { console.log('[' + type + '] ' + message); } // Payment depends on notifications. Notifications don't depend on payment. // Each module can be tested in isolation.

Neither module can accidentally break the other's build. That's the point.

Incremental migration to module boundaries

javascript
// Before: internal imports scattered across the app import { validateToken } from './authModule/internal/tokenValidator.js'; import { userCache } from './userModule/internal/store.js'; // Step 1: create public APIs without moving any files // authModule/index.js export { validateToken } from './internal/tokenValidator.js'; // userModule/index.js export { getUserFromCache } from './internal/store.js'; // Step 2: update call sites to use public APIs import { validateToken } from './authModule/index.js'; import { getUserFromCache } from './userModule/index.js'; // Step 3: add linter rule to block internal imports // .eslintrc: no direct imports from */internal/* paths allowed

Each step is small and independently testable. No need for a dedicated refactor sprint.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?