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.jsexports, 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
// 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 directlyEverything 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
// 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
// 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
// userModule/api/user.js
import { logActivity } from '../../analyticsModule/index.js';
// analyticsModule/api/analytics.js
import { getUser } from '../../userModule/index.js'; // WrongBundlers 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"
// 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
// 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 apiUrlDuplication 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
// 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
// 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
// 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 allowedEach step is small and independently testable. No need for a dedicated refactor sprint.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.