01 · Full-Stack Architecture Patterns¶
At Master level, the hard problems stop being "how do I write this function" and become "how do I structure a codebase so a team of engineers can change it safely for years." This module covers the architectural decisions that shape everything else in Level 4.
Layered architecture¶
A layered (n-tier) architecture separates a full-stack JavaScript app into distinct responsibilities, each depending only on the layer(s) beneath it. This keeps business logic testable without a database or HTTP server.
src/
routes/ # HTTP layer — parses requests, calls services, formats responses
users.routes.js
controllers/ # Thin glue between routes and services
users.controller.js
services/ # Business logic — framework-agnostic, easy to unit test
users.service.js
repositories/ # Data access — the only layer that knows about SQL/ORM
users.repository.js
models/ # Domain types / schema shapes
user.model.js
// services/users.service.js — pure business logic, no HTTP or SQL here
export function createUsersService(usersRepository) {
return {
async registerUser({ email, password }) {
if (!email.includes("@")) {
throw new Error("invalid email");
}
const existing = await usersRepository.findByEmail(email);
if (existing) {
throw new Error("email already registered");
}
return usersRepository.create({ email, password });
},
};
}
// controllers/users.controller.js — translates HTTP <-> service calls
export function createUsersController(usersService) {
return {
async register(req, res) {
try {
const user = await usersService.registerUser(req.body);
res.status(201).json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
},
};
}
Because usersService only depends on an abstract usersRepository
interface, you can swap Postgres for an in-memory fake in tests without
touching business logic — this is dependency injection in practice, not just
a buzzword.
Monorepos vs. polyrepos¶
A monorepo holds multiple related packages (API, web app, shared types, CLI tools) in one repository with one version-controlled history. A polyrepo splits each into its own repository. Neither is universally "right" — the decision depends on team size, deployment independence, and how much code is genuinely shared.
| Concern | Monorepo | Polyrepo |
|---|---|---|
| Sharing code (types, utils) | Trivial — direct import | Needs a published package |
| Atomic cross-package changes | One commit, one PR | Coordinated PRs across repos |
| CI build time | Can grow large; needs caching/filtering | Each repo's CI stays small |
| Independent deploy cadence | Requires tooling discipline | Natural — each repo deploys alone |
| Onboarding | One clone, one setup | Multiple clones, multiple setups |
| Tooling maturity needed | Higher (Nx, Turborepo, workspaces) | Lower |
A typical Node/JS monorepo uses npm/pnpm/yarn workspaces to link local packages without publishing them to a registry:
// package.json (repo root)
{
"name": "acme-platform",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}
acme-platform/
apps/
api/ # Express/Fastify backend
web/ # React/Next.js frontend
packages/
shared-types/ # TypeScript types shared by api and web
ui/ # Shared component library
package.json
turbo.json # or nx.json — build orchestration/caching
// apps/api/package.json (dependency on an internal workspace package)
{
"name": "@acme/api",
"dependencies": {
"@acme/shared-types": "workspace:*"
}
}
workspace:* tells the package manager "resolve this from the local
workspace, not the npm registry" — changes to shared-types are picked up
immediately by anything that imports it, with no publish step.
Structuring a full-stack app for maintainability¶
A few principles keep a full-stack JavaScript codebase navigable as it grows:
- Feature folders over type folders at scale. Grouping by feature
(
features/billing/,features/orders/) rather than by type (controllers/,services/) keeps related code together and makes deletion safe — removing a feature means removing one folder. - A shared "core" package for cross-cutting concerns — logging, config loading, error classes — imported by every service so behavior stays consistent.
- Explicit module boundaries. Each feature module exports a small public
API (usually via an
index.js/index.tsbarrel file) and treats its own internals as private, even within the same repo.
// features/billing/index.js — the only file other features may import from
export { createInvoice } from "./billing.service.js";
export { InvoiceStatus } from "./billing.types.js";
// billing.repository.js and internal helpers are NOT re-exported —
// nothing outside this folder should reach into billing's internals.
// features/orders/order.service.js
import { createInvoice } from "../billing/index.js"; // uses billing's public API only
Choosing an architecture style¶
| Style | Best fit | Trade-off |
|---|---|---|
| Layered (n-tier) monolith | Small-to-mid teams, one deployable | Can become a "big ball of mud" without discipline |
| Modular monolith (feature folders) | Mid-size teams, single deploy, clear ownership | Requires enforcing boundaries (lint rules, code review) |
| Microservices | Large orgs, independent scaling/deploy | Operational overhead: networking, observability, data consistency |
| Serverless functions | Spiky/unpredictable load, small discrete tasks | Cold starts, vendor lock-in, harder local debugging |
Most teams should start with a modular monolith and only split into microservices once a specific, measured pain (deploy coupling, scaling one piece independently, team ownership boundaries) justifies the operational cost — covered in depth in Module 3.
How It Actually Works¶
A "full-stack" JS architecture runs on two separate JS engines with different capabilities: the browser's V8 (sandboxed, DOM access, no filesystem) and Node's V8 embedding (no DOM, full filesystem/network access). Server-side rendering exists because these two contexts can execute the same component code but produce different outputs — on the server, rendering means synchronously walking your component tree and serializing it to an HTML string (no DOM APIs available, no event listeners actually attached yet); on the client, "hydration" re-runs that same component tree against the already-present server-rendered DOM nodes, attaching real event listeners and internal state to existing elements instead of creating new ones — which is why a mismatch between server output and client's first render throws hydration warnings: the client assumed a DOM shape that doesn't match what's actually there.
API boundaries between a frontend and backend are enforced entirely by the network layer, not the language: even though both sides might be TypeScript, there is no shared runtime — a type used on the server has been fully erased and has zero presence in the compiled client bundle unless you explicitly share type definition files (a common "full-stack TypeScript" pattern), and the real contract enforced at runtime is whatever bytes actually cross the wire as JSON, validated (or not) independently on each side.
Exercise¶
You're joining a team maintaining a full-stack JavaScript app that currently
has one giant server.js file (2,000+ lines) mixing routes, SQL queries, and
business logic. Sketch a target folder structure (layered or feature-folder
style — your choice) you'd migrate it to, and write a short paragraph
explaining the order you'd migrate pieces in so the app keeps working at every
step (no "big bang rewrite").