✅ Code Architecture
Application architecture patterns and code organization — clean architecture, hexagonal architecture, feature-based structure, dependency inversion, module boundaries, SOLID principles at module scale. Activate on "clean architecture", "hexagonal architecture", "feature-based structure", "dependency inversion", "module boundaries", "architecture pattern", "folder structure", "fat controller", "circular dependency", "IoC container". NOT for microservice decomposition (use microservices-patterns) or database schema design (use database-design-patterns).
Allowed Tools
Read, Write, Edit, Bash, Grep, Glob
Tags
code architecture clean-architecture hexagonal-architecture feature-based-structure
🤝 Pairs Great With
- Refactoring Surgeon: Refactoring implements the architectural patterns that code-architecture prescribes
- Monorepo Management: Monorepo workspace boundaries enforce architectural module separation
- Microservices Patterns: Service decomposition decisions depend on application architecture patterns
- Typescript Advanced Patterns: TypeScript type system features enforce architectural boundaries at compile time
References
Code Architecture
Architecture patterns and code organization that survive contact with reality. The goal is code you can still understand, test, and change 18 months after it was written. This covers the decisions made at the module and application level — not how to split services, not how to design schemas, but how to organize the code within a single deployable unit.
When to Use
Use for:
- Choosing an architecture pattern for a new project (clean, hexagonal, feature-based, vertical slice)
- Diagnosing architectural problems in existing code (fat controllers, circular deps, tangled business logic)
- Applying dependency inversion to untangle tightly coupled code
- Deciding on folder structure and module organization
- Applying SOLID principles at the module scale (not just class scale)
- Setting up dependency injection containers
- Defining module boundaries and barrel exports
- Designing the test pyramid relative to architecture layers
NOT for:
- Splitting a monolith into microservices (use
microservices-patterns) - Database schema design, normalization, query optimization (use
database-design-patterns) - Framework-specific routing or middleware setup
- CI/CD pipeline architecture
Core Decision: Which Architecture Pattern?
flowchart TD
Start[New project or restructuring?] --> Team{Team size?}
Team -->|Solo / 2 dev| Size{App complexity?}
Team -->|3-8 dev| MedComplex{Domain complexity?}
Team -->|8+ dev| Large[Feature-based / Vertical Slice]
Size -->|Simple CRUD, low domain logic| Simple[Flat + MVC or Feature-Based]
Size -->|Complex domain, long-lived| Hex[Hexagonal Architecture]
MedComplex -->|Low: thin business layer| FeatureBased[Feature-Based Organization]
MedComplex -->|High: rich business rules| Clean[Clean Architecture]
MedComplex -->|Mixed: CRUD + some complex| Vertical[Vertical Slice Architecture]
Simple --> SCheck{Will it grow?}
SCheck -->|No, short-lived| MVC[Layer-based MVC is fine]
SCheck -->|Yes| FeatureBased
Large --> Testing{Testing rigor?}
Testing -->|Low: mostly E2E| Vertical
Testing -->|High: unit + integration| Clean
Clean --> ClNote[Entities → Use Cases → Interface Adapters → Frameworks]
Hex --> HexNote[Domain core ↔ Ports ↔ Adapters]
FeatureBased --> FBNote[features/X contains all layers for X]
Vertical --> VSNote[Each slice owns its full stack top to bottom]
Clean Architecture Layers
The classic onion — each ring depends only inward, never outward.
graph TB
subgraph Frameworks ["Frameworks & Drivers (outer)"]
DB[(Database)]
HTTP[HTTP Server]
UI[UI / CLI]
External[External APIs]
end
subgraph Adapters ["Interface Adapters"]
Controllers[Controllers / Routes]
Presenters[Presenters]
Gateways[Repository Implementations]
DTOs[DTOs / View Models]
end
subgraph UseCases ["Use Cases (Application Layer)"]
UC1[CreateOrder]
UC2[ProcessPayment]
UC3[NotifyUser]
Ports[Repository Interfaces]
end
subgraph Entities ["Entities (Domain)"]
Order[Order]
User[User]
Payment[Payment]
Rules[Business Rules]
end
HTTP --> Controllers
Controllers --> UC1
UC1 --> Ports
Gateways --> Ports
DB --> Gateways
UC1 --> Order
Dependency rule: Source code dependencies point inward only. Entities know nothing about use cases. Use cases know nothing about controllers. Controllers know nothing about database drivers.
What belongs where:
| Layer | Contains | Examples |
|---|---|---|
| Entities | Enterprise business rules | Order, Payment, domain events, value objects |
| Use Cases | Application-specific business logic | CreateOrderUseCase, RefundPaymentUseCase |
| Interface Adapters | Convert data between use cases and external forms | Controllers, Presenters, Repository implementations |
| Frameworks | External tools, databases, UI frameworks | Express, Postgres, React |
Hexagonal Architecture (Ports and Adapters)
Simpler mental model than clean architecture for many teams: everything connects through ports (interfaces), and adapters implement them.
graph LR
subgraph Domain ["Domain (Hexagon)"]
Logic[Business Logic]
DPorts[Driven Ports<br/>interfaces the domain calls]
end
subgraph Driving ["Driving Adapters<br/>(what drives the app)"]
HTTP2[HTTP Controller]
CLI2[CLI Command]
Test[Test Harness]
end
subgraph Driven ["Driven Adapters<br/>(what the app drives)"]
DB2[(PostgreSQL Adapter)]
Email[SendGrid Adapter]
Cache[Redis Adapter]
end
HTTP2 -->|calls| Logic
CLI2 -->|calls| Logic
Test -->|calls| Logic
Logic -->|calls port| DPorts
DPorts -.->|implemented by| DB2
DPorts -.->|implemented by| Email
DPorts -.->|implemented by| Cache
When hexagonal beats clean: When you need to swap infrastructure easily (test with in-memory, prod with Postgres), or when your business logic is rich enough to warrant isolation but doesn't need the full layer separation of clean architecture.
Feature-Based vs Layer-Based Organization
Layer-Based (Traditional MVC)
src/
controllers/
user.controller.ts
order.controller.ts
payment.controller.ts
services/
user.service.ts
order.service.ts
payment.service.ts
repositories/
user.repository.ts
order.repository.ts
models/
user.model.ts
order.model.ts
Problem: Adding or modifying the "orders" feature requires touching files in four directories. Understanding the orders domain requires context-switching across folders.
Feature-Based (Recommended for Growing Apps)
src/
features/
orders/
orders.controller.ts
orders.service.ts
orders.repository.ts
orders.schema.ts
orders.types.ts
orders.test.ts
index.ts ← public API (barrel export)
users/
users.controller.ts
users.service.ts
...
index.ts
payments/
...
shared/
database/
logger/
config/
Benefits: All order-related code is co-located. Deleting a feature is one folder deletion. Onboarding a developer to the orders domain is one directory.
Rule: Features import from shared/ and from each other's public index.ts only. They never reach into each other's internals.
Anti-Pattern: Business Logic in Controllers (Fat Controller)
Novice: "Controllers are where requests come in, so I'll put the logic there too. It's convenient."
// Fat controller — don't do this
app.post('/orders', async (req, res) => {
const { userId, items } = req.body;
// Business logic #1: calculate total
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
// Business logic #2: apply discount
const user = await db.users.findById(userId);
const discount = user.memberSince < oneYearAgo ? 0.1 : 0;
const finalTotal = total * (1 - discount);
// Business logic #3: check inventory
for (const item of items) {
const stock = await db.inventory.findById(item.productId);
if (stock.quantity < item.qty) {
return res.status(409).json({ error: 'Out of stock' });
}
}
// Side effects mixed in
const order = await db.orders.create({ userId, items, total: finalTotal });
await emailService.send(user.email, 'order-confirmation', { order });
await inventory.decrement(items);
res.json(order);
});
Expert: Controllers are HTTP adapters. They translate HTTP → domain, call a use case or service, then translate domain → HTTP response. All business logic belongs in a use case or domain service that can be tested without an HTTP context.
// Lean controller
app.post('/orders', async (req, res) => {
try {
const command = CreateOrderCommand.fromRequest(req.body); // Validate + map
const order = await createOrderUseCase.execute(command); // All logic here
res.status(201).json(OrderPresenter.toJSON(order)); // Map to response
} catch (error) {
errorHandler(res, error);
}
});
// Use case: testable, framework-agnostic
class CreateOrderUseCase {
constructor(
private readonly orderRepo: OrderRepository,
private readonly inventoryService: InventoryService,
private readonly discountPolicy: DiscountPolicy,
private readonly notifications: NotificationPort,
) {}
async execute(command: CreateOrderCommand): Promise<Order> {
const user = await this.orderRepo.findUser(command.userId);
const discount = this.discountPolicy.calculate(user);
await this.inventoryService.reserveItems(command.items);
const order = Order.create(command.items, discount);
await this.orderRepo.save(order);
await this.notifications.orderCreated(order, user);
return order;
}
}
Detection: Controllers with more than ~20 lines of logic, controllers that import database models directly, controllers with nested if-else business conditions.
Anti-Pattern: Architecture Astronaut (Abstraction for Its Own Sake)
Novice: "I'll add a Repository interface, a Repository implementation, a Service, a ServiceInterface, a Factory to create the Service, an EventBus, and a CQRS command handler. This is enterprise-grade."
Expert: Every abstraction has a cost: more files, more indirection, harder onboarding, more to maintain. Abstractions are investments that pay off when they enable testing, swappability, or code reuse. If you're adding a UserServiceInterface with one implementation that will never change, you've paid the abstraction cost without collecting the benefit.
Ask: "What does this abstraction enable that I couldn't do otherwise?"
- Repository interface → swap real DB for in-memory in tests. Pays off immediately.
- Service interface → if there's only ever one service, this is ceremony.
- Factory pattern → pays off when object creation is complex or has multiple strategies.
- Event bus → pays off when many components need to react to domain events without knowing about each other.
Detection: Files named *Interface.ts, *Abstract.ts, *Factory.ts that have only one implementer and one caller, and that implementer never changes.
Timeline: Enterprise Java (2005-2015) made abstract-everything the default. Spring Framework encouraged this. The post-2015 Node.js and Go communities pushed back with "boring technology" principles. In 2026, the right level of abstraction is contextual — neither zero nor maximum.
Anti-Pattern: Circular Dependencies
Novice: "The Order module needs to know about Users, and the User module needs to check their orders. So I'll import each from the other."
// orders/order.service.ts
import { UserService } from '../users/user.service'; // Order → User
// users/user.service.ts
import { OrderService } from '../orders/order.service'; // User → Order
// Node.js will silently give you `undefined` at runtime
// Jest will give you cryptic "Cannot access before initialization" errors
Expert: Circular dependencies indicate a domain modeling problem. Two modules that genuinely need each other should either be merged into one module, or share a third module that both depend on, or communicate via events/interfaces.
Resolution strategies:
- Merge: If Order and User are truly inseparable, put them in
accounts/ - Extract shared: Create
order-summary/that both can import from - Invert with interface: User module defines
OrderSummaryPortinterface; Orders implements it; User never imports from Orders - Event-driven: User reacts to
OrderCreatedevent rather than calling OrderService directly
# Detect circular deps
npx madge --circular src/
npx dpdm --circular src/index.ts
# ESLint rule (add to .eslintrc)
# "import/no-cycle": "error"
Detection: Runtime errors where a module value is undefined at startup, ESLint import/no-cycle violations, madge circular output.
Dependency Inversion in Practice
The D in SOLID: depend on abstractions, not concretions. Applied at module scale:
// Bad: Use case is coupled to Postgres
class CreateOrderUseCase {
constructor(private readonly db: PostgresConnection) {}
async execute(cmd: CreateOrderCommand) {
await this.db.query('INSERT INTO orders ...');
}
}
// Good: Use case depends on an interface
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}
class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
async execute(cmd: CreateOrderCommand) {
const order = Order.create(cmd);
await this.orderRepo.save(order); // No SQL, no Postgres, no coupling
}
}
// Production: Postgres implements the interface
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> { /* Postgres SQL */ }
async findById(id: OrderId): Promise<Order | null> { /* Postgres SQL */ }
}
// Tests: in-memory implements the same interface
class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async save(order: Order) { this.orders.set(order.id, order); }
async findById(id: OrderId) { return this.orders.get(id) ?? null; }
}
When DI containers are worth it: When you have many dependencies that need wiring, and wiring manually becomes error-prone or repetitive. NestJS, InversifyJS (TypeScript), Python's dependency-injector, Spring (Java).
When DI containers are overkill: Simple scripts, small services with few dependencies, serverless functions, Go projects (constructor injection is idiomatic and sufficient).
Module Boundaries and Barrel Exports
Each feature/module should expose a public API via index.ts:
// features/orders/index.ts — public API
export { CreateOrderUseCase } from './create-order.use-case';
export { OrderRepository } from './order.repository.interface';
export type { Order, OrderStatus } from './order.entity';
// NOT exported: internal helpers, SQL queries, implementation details
// Other modules import from the public API only
import { CreateOrderUseCase } from '@/features/orders';
// NOT: import from '@/features/orders/create-order.use-case'
Enforce with ESLint:
// .eslintrc
{
"rules": {
"import/no-internal-modules": ["error", {
"allow": ["**/*.test.ts", "**/index.ts"]
}]
}
}
Testing Architecture (Test Pyramid Placement)
Each architecture layer has a natural test type:
| Layer | Test Type | Speed | Coverage |
|---|---|---|---|
| Entities / Domain | Unit tests | Instant | 90%+ |
| Use Cases / Application | Unit tests with mocks | Fast | 80%+ |
| Interface Adapters | Integration tests | Medium | 70%+ |
| Frameworks / External | E2E / contract tests | Slow | Key paths |
Rule: Business logic tests should not require starting a server, connecting to a database, or making network calls. If your use case tests require a real database, your architecture has leaked infrastructure concerns into the domain.
References
references/architecture-patterns.md— Consult for detailed patterns: clean architecture layers, hexagonal ports and adapters, vertical slice, feature-based organization with worked examplesreferences/dependency-inversion.md— Consult for IoC containers in TypeScript and Python, constructor injection patterns, when DI is overkill, common DI anti-patterns