Warming up the neural circuits...
By the end of this chapter you will:
A codebase without architecture is a garage without shelves — everything works until you need to find the 10mm socket at 2 AM.
Picture your garage. Tools, paint cans, Christmas decorations, that treadmill you swore you'd use — all on one giant pile. When you need a Phillips screwdriver, you dig for 20 minutes. When something new arrives, you toss it on the pile. This is a Node.js app where every file lives in src/ or routes/, HTTP handlers call the database directly, and "business logic" is copy-pasted across three controllers.
Architecture patterns are the shelving system for your garage. They answer: Where does THIS piece go? They make the answer predictable — not for the computer (it doesn't care), but for the human who debugs this at 3 AM. A well-architected codebase means a new team member can look at a feature request, know exactly which 4 files to touch, and ship without breaking payment processing.
This chapter covers the three families of patterns you'll encounter: MVC (the gateway drug), Clean Architecture (the dependency-inversion heavyweight), and Hexagonal / Ports & Adapters (the testability champion). You'll learn folder structures, tradeoffs, and — most importantly — when to not use the fancy one.
MVC — Model, View, Controller — is the grandparent of web frameworks. Rails, Laravel, Django, and ASP.NET MVC all trace back to Trygve Reenskaug's 1979 paper. But in backend APIs (where the "View" is JSON), the pattern often degrades into something unrecognizable.
The original contract:
User model knows that email must be unique and passwords must be hashed. It does NOT know about HTTP status codes.User object into { "id": 1, "name": "Sharma" } and omitting the password_hash.The anti-pattern you'll see everywhere:
// DO NOT DO THIS
router.post('/users', async (req, res) => {
const { email, password, name } = req.body;
//
This 30-line controller does , business logic, database access, email sending, AND response formatting. When you need to create a user from a background job, CLI script, or another service — you can't. The logic is trapped inside an HTTP handler.
Robert C. Martin's "Clean Architecture" (2012) introduced a rule that changes everything: dependencies point inward. The outer layers (HTTP, databases, frameworks) depend on the inner layers (business rules, entities). Never the reverse.
┌──────────────────────────────────────────┐
│ Frameworks & Drivers │
│ Express routes, PostgreSQL, Redis, S3 │
│ ┌────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ Controllers, Repositories, DTOs │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Application / Use Cases │ │ │
│ │ │ CreateUser, ProcessPayment │ │ │
│ │ │ ┌────────────────────────┐ │ │ │
│ │ │ │ Domain / Entities │ │ │ │
│ │ │ │ User, Order, Payment │ │ │ │
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘Here's how to structure an Express app in Clean Architecture:
src/
├── domain/ # Innermost layer — zero dependencies
│ ├── entities/
│ │ └── User.ts # Plain TS class/interface, no ORM decorators
│ ├── value-objects/
│ │ └── Email.ts
│ └── errors/
│ └── ValidationError.ts
├── application/ # Use cases — depends ONLY on domain
│ ├── use-cases/
│ │ ├── CreateUser.ts
│ │ └── GetUserProfile.ts
│ └── ports/ # Interfaces that outer layers implement
│ ├── IUserRepository.ts
│ ├── IEmailService.ts
│ └── IHashService.ts
├── infrastructure/ # Outer layer — implements ports
│ ├── persistence/
│ │ └── PostgresUserRepository.ts
│ ├── services/
│ │ ├── BcryptHashService.ts
│ │ └── ResendEmailService.ts
│ └── config/
│ └── database.ts
└── presentation/ # HTTP concerns only
├── controllers/
│ └── UserController.ts
├── middleware/
│ └── errorHandler.ts
└── routes/
└── userRoutes.tsThe CreateUser use case — pure business logic, no HTTP, no database driver:
import { User } from '../../domain/entities/User';
import { Email } from '../../domain/value-objects/Email';
import { IUserRepository } from '../ports/IUserRepository';
import { IHashService } from '../ports/IHashService';
import
The controller becomes a thin adapter:
import { Request, Response } from 'express';
import { CreateUser } from '../../application/use-cases/CreateUser';
export class UserController {
constructor(private createUser: CreateUser) {}
async register(
The CreateUser use case has zero imports from Express, PostgreSQL, bcrypt, or Resend. You can test it with 100% mock implementations in under 50ms per test. You can swap PostgreSQL for MongoDB by writing one new repository. You can call CreateUser from a CLI migration script or a background job processor — it doesn't care about HTTP.
Alistair Cockburn's Hexagonal Architecture (2005) is Clean Architecture's spiritual predecessor. The core idea: your application is a hexagon. Each face is a "port" — an interface that the outside world connects to via "adapters."
┌─── HTTP Controller (primary adapter)
│
┌────▼────┐ ┌─────────────┐
│ PORT │◄─────┤ Use Case │
│ (in) │ │ (pure TS) │
└─────────┘ └──────┬──────┘
│
┌─────────┐ ┌──────▼──────┐
│ PORT │──────► Adapter │──► PostgreSQL
│ (out) │ │ (repo) │
└─────────┘ └─────────────┘Hexagonal architecture uses the same ports/interfaces pattern as Clean Architecture but emphasizes testability above all: you can drive your entire application from a test harness by plugging test adapters into the ports.
This is a practical, non-academic decision you make on Day 1 of a project. It affects every import statement for the life of the codebase.
Layer-based (group by technical role):
src/
├── controllers/
│ ├── UserController.ts
│ ├── OrderController.ts
│ └── ProductController.ts
├── services/
│ ├── UserService.ts
│ ├── OrderService.ts
│ └── ProductService.ts
├── repositories/
│ ├── UserRepository.ts
│ └── ...
└── models/
└── ...When you modify the "user" feature, you touch files across 4+ folders. In a PR with 8 files changed, a reviewer can't tell if you changed users, orders, or both. At 50+ features, the services/ folder alone has 100+ files — git blame becomes your homepage.
Feature-based (group by business capability):
src/
├── features/
│ ├── users/
│ │ ├── UserController.ts
│ │ ├── UserService.ts
│ │ ├── UserRepository.ts
│ │ ├── User.test.ts
│ │ └── types.ts
│ ├── orders/
│ │ ├── OrderController.ts
│ │ ├── OrderService.ts
│ │ ├── OrderRepository.ts
│ │ └── ...
│ └── products/
│ └── ...
├── shared/
│ ├── database.ts
│ ├── errors.ts
│ └── middleware/
└── config/
└── index.tsShopify uses feature-based organization in their Rails ("component-based Rails"). Each business domain — orders/, products/, payments/ — is a self-contained module with its own models, controllers, views, and tests. When a developer picks up a "cart checkout" ticket, they work inside app/components/checkout/ for 90% of their changes.
Start feature-based. When shared patterns emerge (authentication, logging, error formatting), extract to shared/. Don't pre-extract — premature abstraction is the root of much backend suffering.
Clean Architecture adds 3x the files of a simple Express app. For these scenarios, it's overkill:
| Scenario | Why skip it |
|---|---|
| Hackathon / MVP (< 2 weeks) | Speed > structure; refactor after validation |
| Single-developer CLI tool | No team coordination benefit |
| Lambda/Cloud Functions (single-purpose) | One function = one use case already |
| with zero business logic | The abstraction doesn't pay for itself |
| Learning project | You need to understand the problem before abstracting it |
Shopify's core application is a Ruby on Rails monolith that processes over $200 billion in GMV annually. With 5,000+ engineers contributing to the same codebase, folder structure isn't academic — it's how they prevent merge conflicts and ship 40+ deploys per day.
In 2017, Shopify adopted component-based Rails — their term for feature-based organization. Before this, their app/models/ folder had over 800 files. No single developer understood the full model graph. Changing Order could silently break Refund, Fulfillment, or GiftCard.
Their solution: each business domain becomes a "component" with an explicit public API. A component declares what it exports (models, services, libraries) and what it depends on. The payments component can depend on orders but not vice versa — cyclic dependencies are caught at CI time.
Key lessons from Shopify's architecture journey:
public/ folder — only files in public/ can be imported by other components. Everything else is private by convention.You don't need a monolith the size of Shopify's to benefit from feature-based organization. The principle scales down: if your src/ folder has 30+ files, organize them by what they DO, not by what they ARE.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Fat controllers (100+ lines) | Business logic trapped in HTTP handlers; can't reuse from CLI, jobs, or tests | Move logic to use cases/services; controllers should be ≤ 15 lines |
| Circular dependencies between features | orders imports from users, users imports from orders — app won't start or explodes at runtime | Use dependency inversion: both depend on a shared interface in shared/ |
| Putting entities in the domain layer | Your domain now depends on TypeORM/Prisma; swapping databases requires rewriting business logic | Domain entities are plain objects; ORM entities live in infrastructure |
| Layer-based folders past 10 features | 100 files in services/ with no visible grouping; impossible to scope PRs | Switch to feature-based at ~10 features; one folder per business domain |
| Skipping the port/interface for "simple" dependencies | "We'll never swap from PostgreSQL" — until you need SQLite for testing, or a read replica, or a layer | Always program to an interface; it costs 5 lines of today |
| Over-abstracting too early | 4 layers for a TODO app; more time maintaining abstractions than building features | Start with MVC; extract use cases when you feel the pain of duplication |
| Ignoring the dependency rule in tests | Unit tests that spin up PostgreSQL are NOT unit tests; they're 5-second integration tests mislabeled | Use mock adapters for use case tests; reserve DB tests for repository integration tests |
tsyringe or inversify only when the wiring code exceeds 100 lines. DI frameworks add magic that makes traces harder to read.index.ts re-exports are convenient but can cause circular imports. Use explicit imports (from './entities/User') until you're confident in your dependency graph.USER_ALREADY_EXISTS (machine-readable, i18n-ready) rather than "A user with this email already exists" (hard to switch languages, hard to match in frontend logic).CreateOrder touches orders, order_items, and inventory, wrap all three in one transaction. Partial commits in a use case are a data integrity nightmare.GetOrders calls orderRepo.findById() in a loop, you've hidden the N+1 behind an abstraction. Repository methods should accept batch operations: findByIds(ids: string[]).GetUserByIdUseCase AND GetUserByEmailUseCase — parameterize.IUserRepository interface forces you to think about what data leaves the domain layer. You won't accidentally return password_hash in a query because the domain entity simply doesn't have that field.if (user.role !== 'admin') across use cases. Use or decorators at the controller level. The use case should receive a pre-authorized context.process.env.DATABASE_URL belongs in infrastructure/config/, not in domain/entities/User.ts. The domain layer should be importable without any environment setup.Refactor a fat controller. Take the routes/users.ts fat controller example from this chapter. Extract the business logic into a CreateUser use case and rewrite the controller to be ≤ 10 lines. Write one unit test for the use case with mock dependencies.
Folder reorganization. Given a flat Express app with 12 route files in routes/, 8 "helper" files in utils/, and raw in every route — design a feature-based folder structure. Draw the tree and explain which files go where.
Port & Adapter implementation. Implement the IUserRepository port with two adapters: PostgresUserRepository (real DB) and InMemoryUserRepository (for tests). Make the CreateUser use case work with both without changing a single line of the use case.
Circular dependency breaker. Design two features — Orders and Users — where Orders needs user data but Users must NOT import from Orders. Implement the dependency inversion: both depend on a shared interface. Show the import graph.
Multi-transport application. Your CreateOrder use case currently runs via HTTP. Add a second "primary adapter": a RabbitMQ consumer that calls the same use case when an order.created message arrives. The use case itself must not change.
Architecture Decision Record. Write a 1-page ADR for a hypothetical e-commerce startup choosing between MVC (Rails-style) and Clean Architecture (NestJS-style). Include context, decision, consequences, and when they'd revisit the decision.
Q1: What is MVC and what does each letter actually do?
Answer: Model manages data and business rules (validation, relationships). View handles presentation — in APIs, this means JSON serialization and response formatting. Controller is the traffic director — it receives the HTTP request, delegates to models/services, and returns a response. The key insight: the model should never know about HTTP status codes or response headers.
Q2: What is the difference between layer-based and feature-based folder structure?
Answer: Layer-based groups files by technical role (controllers/, services/, models/). Feature-based groups by business domain (users/, orders/, payments/). Layer-based is simpler for < 10 features; feature-based scales better because a developer working on "orders" touches files in one folder, not four.
Q3: What problem does the dependency inversion principle solve in backend architecture?
Answer: It prevents high-level business logic from depending on low-level implementation details. Without it, changing from PostgreSQL to MongoDB means rewriting every use case. With it, you write one new repository adapter and the use cases stay untouched. It also makes testing trivial — inject mocks instead of real databases.
Q4: When would you choose Clean Architecture for a greenfield project, and when would you explicitly avoid it?
Answer: I'd choose it when: (a) the domain has complex business rules that will evolve independently of the framework, (b) there are 3+ developers who need clear module boundaries, (c) the application will live 3+ years and likely swap infrastructure (database, message queue, cloud provider). I'd avoid it for: (a) an MVP that needs validation in 2 weeks, (b) a single-purpose serverless function, (c) a team unfamiliar with the pattern (the learning curve will slow delivery more than the architecture will help). The real answer: I'd start MVC and extract Clean Architecture patterns as the pain appears — fat controllers, untestable logic, duplicated validation.
Q5: How do you enforce architectural boundaries in a TypeScript codebase without a framework like NestJS?
Answer: Three techniques: (1) ESLint import/no-restricted-paths — prevent domain/ from importing from infrastructure/. (2) Dependency-cruiser (dpdm or madge) in CI to detect circular dependencies. (3) Barrel file discipline — each layer exports only through an index.ts that explicitly lists public symbols; anything not exported is private by convention. Bonus: TypeScript paths in tsconfig.json can create import aliases (@domain, @application) that make violations visually obvious in code review.
Q6: Shopify moved to component-based Rails. What's one architectural pattern they use that a 5-person Express team should adopt?
Answer: Explicit public APIs per module — every feature folder has a public.ts (or index.ts) that exports only what other features need. Everything else is private. In TypeScript, you can enforce this with barrel files and a lint rule. This prevents the "everything depends on everything" spaghetti that happens when any file can import any other file. A 5-person team benefits because code review can focus on public API changes — internal refactors of a feature don't risk breaking other features.
Architecture patterns aren't about writing more code — they're about writing code that survives team growth, requirement changes, and the developer who inherits it 2 years later. MVC gives you a starting point. Clean Architecture and Hexagonal Architecture add the dependency inversion that makes business logic testable and infrastructure swappable. Feature-based folder organization keeps related code together, matching how humans think about features ("I'm working on orders") rather than technical layers ("I'm working on controllers"). The key is pragmatism: start simple, extract patterns when you feel the friction, and never let the architecture become more important than the product it serves.
users/, orders/, payments/ — group by what code DOEScontrollers/, services/, repositories/ — group by what code ISCreateUser depends on IUserRepository (interface), not PostgresUserRepository (concrete)In Clean Architecture, which layer should have zero dependencies on external frameworks? Answer: The domain/entities layer.
What is the maximum recommended line count for a well-structured controller method? Answer: 15 lines.
A feature-based folder puts UserController, UserService, and UserRepository in which folder?
Answer: features/users/ (or users/).
What is the "fat controller" anti-pattern? Answer: Controllers that contain business logic, validation, database access, and side effects — making the logic impossible to reuse outside HTTP.
In Hexagonal Architecture, an HTTP controller is a ______ adapter, and a PostgreSQL repository is a ______ adapter. Answer: Primary (driving), Secondary (driven).
Why does Shopify use component-based organization in their Rails monolith? Answer: To scope changes to a single domain folder, reduce merge conflicts, and make dependency violations visible at CI time.
You're building a 2-week MVP for a hackathon. Should you use Clean Architecture? Why or why not? Answer: No — the abstraction overhead (3x files, interface definitions) slows delivery. Start MVC and refactor if the project survives past the hackathon.