
Code Architecture
- 111 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Design scalable code architecture patterns and system design for large applications.
About
Code Architecture teaches system design, architectural patterns, and scalability principles. Build maintainable, modular systems using proven architectural approaches.
- Scalable architecture patterns.
- System design principles.
Code Architecture by the numbers
- 111 all-time installs (skills.sh)
- Ranked #2,929 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill code-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 111 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Design scalable code architecture patterns and system design for large applications.
Files
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 --> OrderDependency 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| CacheWhen 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.tsProblem: 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" errorsExpert: 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: 1. Merge: If Order and User are truly inseparable, put them in accounts/ 2. Extract shared: Create order-summary/ that both can import from 3. Invert with interface: User module defines OrderSummaryPort interface; Orders implements it; User never imports from Orders 4. Event-driven: User reacts to OrderCreated event 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
Architecture Patterns Deep Dive
Detailed reference for the major architecture patterns covered in SKILL.md, with worked TypeScript examples for each.
---
Clean Architecture — Full Example
Clean Architecture (Robert Martin, 2012) enforces a strict dependency rule: code dependencies point inward only. The outer layers (frameworks, databases) depend on the inner layers (domain, use cases), never the reverse.
Layer Definitions
Entities (innermost): Enterprise-wide business rules. These are the things your business is about, independent of any application concern. Order, User, Payment, Invoice. If you had no computer, these concepts would still exist. They contain business rules that apply across many applications.
Use Cases: Application-specific business logic. Each use case represents one action a user or system can take. CreateOrder, ProcessRefund, GenerateInvoice. Use cases orchestrate entity behavior. They can fail — they know about errors, exceptions, and edge cases. They know nothing about HTTP, databases, or UI.
Interface Adapters: Convert data between use cases and frameworks. Controllers convert HTTP requests to use case inputs and use case outputs to HTTP responses. Repository implementations convert domain entities to database rows and back. This layer knows about both the domain and the external tools, but its job is translation, not logic.
Frameworks and Drivers (outermost): Express, Fastify, Postgres, Redis, Stripe. These are details. They can be swapped without changing your domain or use cases.
Worked Example: Order System (TypeScript)
src/
domain/
entities/
order.ts
order-item.ts
value-objects/
money.ts
order-id.ts
events/
order-created.event.ts
repositories/
order.repository.ts ← Interface (port)
application/
use-cases/
create-order/
create-order.use-case.ts
create-order.command.ts
create-order.result.ts
cancel-order/
cancel-order.use-case.ts
ports/
notification.port.ts ← Interface for notifications
inventory.port.ts ← Interface for inventory check
infrastructure/
database/
postgres-order.repository.ts ← Implements OrderRepository
order.mapper.ts
messaging/
sendgrid-notification.ts ← Implements NotificationPort
http/
order.controller.ts
order.router.ts
ioc/
container.ts ← Wires everything togetherEntity (no framework imports):
// domain/entities/order.ts
import { OrderItem } from './order-item';
import { Money } from '../value-objects/money';
import { OrderId } from '../value-objects/order-id';
import { OrderCreatedEvent } from '../events/order-created.event';
export type OrderStatus = 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
export class Order {
private readonly _events: unknown[] = [];
private constructor(
public readonly id: OrderId,
public readonly userId: string,
private readonly items: OrderItem[],
private _status: OrderStatus,
private readonly _createdAt: Date,
) {}
static create(userId: string, items: OrderItem[]): Order {
if (items.length === 0) throw new Error('Order must have at least one item');
const order = new Order(
OrderId.generate(),
userId,
items,
'PENDING',
new Date(),
);
order._events.push(new OrderCreatedEvent(order.id, userId));
return order;
}
get total(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('USD'),
);
}
cancel(): void {
if (this._status === 'SHIPPED') throw new Error('Cannot cancel shipped order');
this._status = 'CANCELLED';
}
get status(): OrderStatus { return this._status; }
get domainEvents(): unknown[] { return [...this._events]; }
}Use Case (no database, no HTTP):
// application/use-cases/create-order/create-order.use-case.ts
import { Order } from '../../../domain/entities/order';
import { OrderRepository } from '../../../domain/repositories/order.repository';
import { InventoryPort } from '../../ports/inventory.port';
import { NotificationPort } from '../../ports/notification.port';
import { CreateOrderCommand } from './create-order.command';
import { CreateOrderResult } from './create-order.result';
export class CreateOrderUseCase {
constructor(
private readonly orderRepo: OrderRepository,
private readonly inventory: InventoryPort,
private readonly notifications: NotificationPort,
) {}
async execute(cmd: CreateOrderCommand): Promise<CreateOrderResult> {
// Check inventory for each item
for (const item of cmd.items) {
const available = await this.inventory.isAvailable(item.productId, item.quantity);
if (!available) {
throw new InsufficientInventoryError(item.productId);
}
}
// Create the domain entity (business rules enforced here)
const order = Order.create(cmd.userId, cmd.items);
// Persist
await this.orderRepo.save(order);
// Reserve inventory
await this.inventory.reserve(order.id, cmd.items);
// Notify (async — don't fail the order if notification fails)
this.notifications.orderCreated(order).catch(console.error);
return { orderId: order.id.value, total: order.total.amount };
}
}Repository Interface (in domain layer):
// domain/repositories/order.repository.ts
import { Order } from '../entities/order';
import { OrderId } from '../value-objects/order-id';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByUserId(userId: string): Promise<Order[]>;
}Infrastructure implementation (knows about Postgres):
// infrastructure/database/postgres-order.repository.ts
import { Pool } from 'pg';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { Order } from '../../domain/entities/order';
import { OrderMapper } from './order.mapper';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
const row = OrderMapper.toPersistence(order);
await this.pool.query(
'INSERT INTO orders (id, user_id, status, total_cents, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (id) DO UPDATE SET ...',
[row.id, row.userId, row.status, row.totalCents, row.createdAt],
);
}
async findById(id: OrderId): Promise<Order | null> {
const result = await this.pool.query('SELECT * FROM orders WHERE id = $1', [id.value]);
if (!result.rows[0]) return null;
return OrderMapper.toDomain(result.rows[0]);
}
async findByUserId(userId: string): Promise<Order[]> {
const result = await this.pool.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
return result.rows.map(OrderMapper.toDomain);
}
}---
Hexagonal Architecture — Practical Example
Ports and Adapters is a simpler vocabulary: the domain defines ports (interfaces), and adapters implement them. No strict layering beyond "inside the hexagon" vs "outside."
// domain/ports/outbound/user-notification.port.ts
// The domain DEFINES this port (what it needs)
export interface UserNotificationPort {
sendOrderConfirmation(userId: string, orderId: string): Promise<void>;
sendShippingUpdate(userId: string, trackingNumber: string): Promise<void>;
}
// infrastructure/adapters/outbound/sendgrid-notification.adapter.ts
// An adapter IMPLEMENTS the port
import { UserNotificationPort } from '../../../domain/ports/outbound/user-notification.port';
import { SendGrid } from '@sendgrid/mail';
export class SendGridNotificationAdapter implements UserNotificationPort {
async sendOrderConfirmation(userId: string, orderId: string): Promise<void> {
// SendGrid-specific implementation
}
async sendShippingUpdate(userId: string, trackingNumber: string): Promise<void> {
// SendGrid-specific implementation
}
}
// tests/adapters/fake-notification.adapter.ts
// Test adapter — no real emails
export class FakeNotificationAdapter implements UserNotificationPort {
public readonly sent: Array<{ type: string; userId: string }> = [];
async sendOrderConfirmation(userId: string, orderId: string): Promise<void> {
this.sent.push({ type: 'order-confirmation', userId });
}
async sendShippingUpdate(userId: string, trackingNumber: string): Promise<void> {
this.sent.push({ type: 'shipping-update', userId });
}
}Driving Adapters (Primary Ports)
// These drive the application from the outside
// HTTP adapter:
class OrderHttpController {
constructor(private readonly createOrder: CreateOrderUseCase) {}
async handlePost(req: Request, res: Response) {
const result = await this.createOrder.execute(CreateOrderCommand.fromRequest(req.body));
res.json(result);
}
}
// CLI adapter (same use case, different entry point):
class OrderCliAdapter {
constructor(private readonly createOrder: CreateOrderUseCase) {}
async run(args: string[]) {
const command = CreateOrderCommand.fromCLIArgs(args);
const result = await this.createOrder.execute(command);
console.log(`Order created: ${result.orderId}`);
}
}
// Test adapter (fastest feedback loop):
describe('CreateOrderUseCase', () => {
it('should create an order and notify', async () => {
const repo = new InMemoryOrderRepository();
const notifications = new FakeNotificationAdapter();
const inventory = new InMemoryInventoryAdapter();
const useCase = new CreateOrderUseCase(repo, inventory, notifications);
const result = await useCase.execute(/* ... */);
expect(result.orderId).toBeDefined();
expect(notifications.sent).toHaveLength(1);
});
});---
Vertical Slice Architecture
Instead of organizing by technical layer, organize by feature slice — each slice contains everything needed to fulfill one user story.
src/
features/
create-order/
create-order.handler.ts ← Entry point (HTTP, CLI, whatever)
create-order.command.ts ← Input DTO
create-order.validator.ts ← Input validation
create-order.service.ts ← Business logic (may be thin)
create-order.repository.ts ← Data access (may call shared)
create-order.test.ts ← All tests for this slice
cancel-order/
...
get-order/
...
shared/
database/
pg-client.ts
auth/
jwt-middleware.ts
errors/
application-errors.tsWhen this wins: Teams that frequently add new features without touching old ones. Each slice is independently releasable. No team steps on another team's work. Code deletion is clean (delete the folder).
When this hurts: When slices share a lot of logic and you end up with duplicated code across them. Solution: extract to shared/ but keep the boundary explicit.
---
Feature-Based Organization — Detailed Structure
src/
features/
orders/
# Public API (only exports, never internal paths)
index.ts
# Domain (if using Clean Architecture within the feature)
domain/
order.entity.ts
order.repository.interface.ts
# Application
application/
create-order.use-case.ts
cancel-order.use-case.ts
# Infrastructure
infrastructure/
postgres-order.repository.ts
order.mapper.ts
# HTTP
http/
orders.controller.ts
orders.router.ts
# Tests
__tests__/
create-order.use-case.test.ts
orders.controller.integration.test.ts
users/
index.ts
...
payments/
index.ts
...
shared/
# Cross-cutting concerns
database/
pg-pool.ts
transaction.ts
auth/
jwt.middleware.ts
auth.guard.ts
errors/
domain-error.ts
http-error.ts
config/
app.config.ts
database.config.ts
# Entry points
main.ts
app.tsindex.ts (public API for the orders feature):
// src/features/orders/index.ts
export { CreateOrderUseCase } from './application/create-order.use-case';
export { CancelOrderUseCase } from './application/cancel-order.use-case';
export { OrderRepository } from './domain/order.repository.interface';
export { ordersRouter } from './http/orders.router';
export type { Order, OrderStatus } from './domain/order.entity';
// Never export internal implementation details:
// DO NOT: export { PostgresOrderRepository } from './infrastructure/...'
// DO NOT: export { OrderMapper } from './infrastructure/...'---
SOLID at Module Scale
SOLID principles apply beyond classes to module design:
Single Responsibility at Module Level: A module (feature folder) owns one domain concept. If you're making a change to "orders" functionality and you're touching files in three different feature folders, your module boundaries are wrong.
Open/Closed at Module Level: You should be able to add a new feature (new folder in features/) without modifying existing features. If adding payments/ requires changing orders/, you have a coupling problem.
Liskov Substitution at Module Level: Any implementation of a port/interface must be substitutable. If your PostgresOrderRepository and InMemoryOrderRepository behave differently at the contract level (not just implementation), you have a broken abstraction.
Interface Segregation at Module Level: Don't create a massive OrderService interface with 20 methods that half the consumers don't use. Create focused interfaces: OrderReader, OrderWriter, OrderCanceller. Consumers depend only on what they need.
Dependency Inversion at Module Level: Features depend on shared interfaces (ports), not on each other's concrete implementations. The payments feature doesn't import PostgresOrderRepository — it imports the OrderRepository interface.
---
Architecture Decision Record Template
Document major architecture decisions. Future-you and your teammates will thank you:
# ADR-001: Feature-Based Organization over Layer-Based
## Status
Accepted (2026-01-15)
## Context
We're starting a new order management service. The team is 4 developers.
Layer-based (MVC) is what everyone is familiar with, but feature-based
was proposed for better locality and scalability.
## Decision
We will use feature-based organization with a shared/ folder for
cross-cutting concerns.
## Consequences
Positive:
- All code for a feature is co-located; new team members can ramp up one feature at a time
- Deleting a feature is a folder deletion
- No cross-feature contamination (enforced via import rules)
Negative:
- Some duplication is acceptable within features to avoid coupling
- Requires explicit shared/ module to prevent accidental coupling
## Implementation Notes
- ESLint rule: no internal module imports (only from feature index.ts)
- Shared services must be justified; prefer feature-local implementationsDependency Inversion — IoC Containers and Injection Patterns
Dependency inversion is the D in SOLID. This reference covers concrete IoC container implementations in TypeScript and Python, constructor injection patterns, and the honest answer to when DI is overkill.
---
The Core Idea
Without DI:
// Hard-coded dependency — untestable in isolation
class OrderService {
private db = new PostgresConnection(process.env.DATABASE_URL);
private mailer = new SendGridMailer(process.env.SENDGRID_API_KEY);
async createOrder(userId: string) {
// Can't test this without a real Postgres and real SendGrid
}
}With constructor injection:
// Dependencies are injected — testable with any implementation
class OrderService {
constructor(
private readonly db: OrderRepository, // Interface
private readonly mailer: NotificationPort, // Interface
) {}
async createOrder(userId: string) {
// Test by passing in-memory implementations
}
}---
TypeScript Patterns
Manual Constructor Injection (No Container)
For small to medium applications, wire dependencies manually. Simple, explicit, no magic:
// infrastructure/ioc/composition-root.ts
import { Pool } from 'pg';
import { PostgresOrderRepository } from '../database/postgres-order.repository';
import { SendGridNotificationAdapter } from '../messaging/sendgrid-notification.adapter';
import { CreateOrderUseCase } from '../../application/use-cases/create-order.use-case';
import { OrderController } from '../http/order.controller';
export function buildDependencies() {
// Infrastructure
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Adapters
const orderRepo = new PostgresOrderRepository(pool);
const notifications = new SendGridNotificationAdapter(process.env.SENDGRID_KEY!);
// Use Cases
const createOrder = new CreateOrderUseCase(orderRepo, notifications);
// Controllers
const orderController = new OrderController(createOrder);
return { orderController };
}
// main.ts
const { orderController } = buildDependencies();
app.use('/orders', orderController.router());Pros: Zero magic, easy to trace, excellent TypeScript intellisense, no decorators. Cons: As the app grows, this file becomes a wall of wiring code.
InversifyJS (Decorator-Based IoC Container)
For large TypeScript applications needing automatic resolution:
npm install inversify reflect-metadata
# tsconfig.json: "experimentalDecorators": true, "emitDecoratorMetadata": true// Define symbols (tokens) for each type
// infrastructure/ioc/symbols.ts
export const SYMBOLS = {
OrderRepository: Symbol('OrderRepository'),
NotificationPort: Symbol('NotificationPort'),
CreateOrderUseCase: Symbol('CreateOrderUseCase'),
};
// Mark injectable classes
// application/use-cases/create-order.use-case.ts
import { inject, injectable } from 'inversify';
import { SYMBOLS } from '../../infrastructure/ioc/symbols';
@injectable()
export class CreateOrderUseCase {
constructor(
@inject(SYMBOLS.OrderRepository) private readonly orderRepo: OrderRepository,
@inject(SYMBOLS.NotificationPort) private readonly notifications: NotificationPort,
) {}
}
// infrastructure/ioc/container.ts
import { Container } from 'inversify';
import 'reflect-metadata';
const container = new Container();
container.bind(SYMBOLS.OrderRepository).to(PostgresOrderRepository).inSingletonScope();
container.bind(SYMBOLS.NotificationPort).to(SendGridNotificationAdapter).inSingletonScope();
container.bind(SYMBOLS.CreateOrderUseCase).to(CreateOrderUseCase).inTransientScope();
export { container };
// Usage
const useCase = container.get<CreateOrderUseCase>(SYMBOLS.CreateOrderUseCase);For tests (swap to in-memory implementations):
const testContainer = new Container();
testContainer.bind(SYMBOLS.OrderRepository).to(InMemoryOrderRepository).inSingletonScope();
testContainer.bind(SYMBOLS.NotificationPort).to(FakeNotificationAdapter).inSingletonScope();
testContainer.bind(SYMBOLS.CreateOrderUseCase).to(CreateOrderUseCase).inTransientScope();TSyringe (Microsoft — Lighter than InversifyJS)
npm install tsyringe reflect-metadataimport { injectable, inject, container } from 'tsyringe';
@injectable()
class CreateOrderUseCase {
constructor(
@inject('OrderRepository') private repo: OrderRepository,
@inject('NotificationPort') private notif: NotificationPort,
) {}
}
// Registration
container.register('OrderRepository', { useClass: PostgresOrderRepository });
container.register('NotificationPort', { useClass: SendGridAdapter });
// Resolution
const useCase = container.resolve(CreateOrderUseCase);NestJS DI (Framework-Level)
NestJS has DI built in. If you're using NestJS, don't use InversifyJS or TSyringe — use NestJS's built-in system:
// orders.module.ts
@Module({
imports: [TypeOrmModule.forFeature([OrderEntity])],
controllers: [OrdersController],
providers: [
CreateOrderUseCase,
{
provide: 'OrderRepository',
useClass: TypeOrmOrderRepository,
},
{
provide: 'NotificationPort',
useClass: SendGridAdapter,
},
],
exports: ['OrderRepository'],
})
export class OrdersModule {}
// For tests
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CreateOrderUseCase,
{ provide: 'OrderRepository', useClass: InMemoryOrderRepository },
{ provide: 'NotificationPort', useClass: FakeNotificationAdapter },
],
}).compile();
useCase = module.get(CreateOrderUseCase);
});
});---
Python Patterns
Manual Constructor Injection
Python doesn't require decorators for injection. Constructor injection is idiomatic:
# domain/ports/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional
from .order import Order
class OrderRepository(ABC):
@abstractmethod
async def save(self, order: Order) -> None: ...
@abstractmethod
async def find_by_id(self, order_id: str) -> Optional[Order]: ...
# application/use_cases/create_order.py
class CreateOrderUseCase:
def __init__(
self,
order_repo: OrderRepository,
notification_port: NotificationPort,
inventory_port: InventoryPort,
) -> None:
self._order_repo = order_repo
self._notification_port = notification_port
self._inventory_port = inventory_port
async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
# Business logic — no database, no HTTP, no framework
...
# infrastructure/composition_root.py
from infrastructure.database import PostgresOrderRepository
from infrastructure.messaging import SendGridAdapter
from application.use_cases import CreateOrderUseCase
def build_dependencies() -> dict:
pool = asyncpg.create_pool(os.environ["DATABASE_URL"])
order_repo = PostgresOrderRepository(pool)
notifications = SendGridAdapter(os.environ["SENDGRID_KEY"])
create_order = CreateOrderUseCase(order_repo, notifications, ...)
return {"create_order": create_order}dependency-injector (Python Library)
For larger Python apps needing autowiring:
pip install dependency-injector# infrastructure/ioc/container.py
from dependency_injector import containers, providers
from infrastructure.database import PostgresOrderRepository
from infrastructure.messaging import SendGridAdapter
from application.use_cases import CreateOrderUseCase
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
pool = providers.Singleton(
asyncpg.create_pool,
dsn=config.database.url,
)
order_repository = providers.Singleton(
PostgresOrderRepository,
pool=pool,
)
notification_port = providers.Singleton(
SendGridAdapter,
api_key=config.sendgrid.api_key,
)
create_order_use_case = providers.Factory(
CreateOrderUseCase,
order_repo=order_repository,
notification_port=notification_port,
)
# main.py
container = Container()
container.config.from_env()
create_order = container.create_order_use_case()For tests:
def test_create_order():
with container.order_repository.override(InMemoryOrderRepository()):
with container.notification_port.override(FakeNotificationAdapter()):
use_case = container.create_order_use_case()
result = asyncio.run(use_case.execute(command))
assert result.order_id is not NoneFastAPI Dependency Injection
FastAPI has built-in DI for HTTP handlers — use it for request-level dependencies, not for core application wiring:
# For HTTP-level concerns (auth, request context):
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
return verify_jwt(token)
@app.post("/orders")
async def create_order(
command: CreateOrderCommand,
current_user: User = Depends(get_current_user), # HTTP-level DI
use_case: CreateOrderUseCase = Depends(get_use_case), # App-level
):
return await use_case.execute(command.with_user(current_user.id))Anti-pattern: Putting business logic in Depends() functions. They're for request-scoped concerns (auth, rate limiting, request ID). Use Cases contain business logic and are wired at startup.
---
When DI Is Overkill
DI containers are an investment. They pay off when:
- You have many classes with complex dependency trees
- You frequently need to swap implementations (testing, multiple environments)
- Multiple developers would otherwise manually wire the same dependencies
DI containers are overkill when:
Small scripts and lambdas:
# A serverless function doesn't need DI
def handler(event, context):
order = parse_order(event)
save_to_dynamodb(order) # Just call it directly
send_email(order)
return {"statusCode": 200}Go — the community consensus is that constructor injection without a container is idiomatic Go:
// Go: just wire in main()
func main() {
db := postgres.Connect(os.Getenv("DATABASE_URL"))
orderRepo := postgres.NewOrderRepository(db)
notifications := sendgrid.NewAdapter(os.Getenv("SENDGRID_KEY"))
useCase := orders.NewCreateOrderUseCase(orderRepo, notifications)
server := http.NewServer(useCase)
server.Run(":8080")
}When you only have one implementation of each interface and no plans to change it: the interface adds ceremony without enabling flexibility. Build the interface when you need the swap, not preemptively.
Simple data pipelines: Where the "business logic" is transformations on data, not complex domain rules with invariants.
---
DI Anti-Patterns
Service Locator (Global Registry)
// Anti-pattern: Service Locator
const services = new ServiceLocator();
class CreateOrderUseCase {
execute() {
// Hidden dependency — impossible to test without the global registry
const repo = services.get<OrderRepository>('orderRepo');
const mailer = services.get<NotificationPort>('mailer');
}
}The Service Locator is a dependency inversion failure: dependencies are pulled from a global object rather than injected. It hides what the class needs, making it impossible to understand from the constructor signature alone. Test setup becomes complex ("what services do I need to register for this test?").
Constructor Parameter Explosion
// Anti-pattern: too many constructor parameters
class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private userRepo: UserRepository,
private inventoryService: InventoryService,
private paymentService: PaymentService,
private notificationService: NotificationService,
private auditLog: AuditLogService,
private discountCalculator: DiscountCalculator,
private taxService: TaxService,
) {}
}Eight constructor parameters signals the class is doing too much. It violates Single Responsibility. Solutions: 1. Split into multiple use cases (CreateOrderUseCase + ApplyDiscountUseCase) 2. Group related dependencies into a facade (OrderFulfillmentService that aggregates inventory + payment + notification) 3. Use an explicit command-handler pattern where the handler orchestrates sub-operations
Injecting Factories Instead of Dependencies
// Anti-pattern: injecting factories
class CreateOrderUseCase {
constructor(private readonly repoFactory: () => OrderRepository) {}
async execute() {
const repo = this.repoFactory(); // Why? Just inject the repo
}
}
// Only inject a factory when you genuinely need different instances
// (e.g., per-tenant database connections, scoped resources)---
Testing Without DI Containers
Even without a container, you can test with injected fakes:
// In test:
const orderRepo = new InMemoryOrderRepository();
const notifications = new SpyNotificationAdapter();
const useCase = new CreateOrderUseCase(orderRepo, notifications);
await useCase.execute({ userId: 'user-1', items: [...] });
expect(orderRepo.orders.size).toBe(1);
expect(notifications.sentMessages).toHaveLength(1);
expect(notifications.sentMessages[0].type).toBe('order-confirmation');The fake/spy classes live in src/__tests__/fakes/ or alongside their interface in src/domain/repositories/__tests__/:
// src/__tests__/fakes/in-memory-order.repository.ts
export class InMemoryOrderRepository implements OrderRepository {
public readonly orders = new Map<string, Order>();
async save(order: Order): Promise<void> {
this.orders.set(order.id.value, order);
}
async findById(id: OrderId): Promise<Order | null> {
return this.orders.get(id.value) ?? null;
}
async findByUserId(userId: string): Promise<Order[]> {
return Array.from(this.orders.values()).filter(o => o.userId === userId);
}
}This is simpler than mocking frameworks for domain-level testing. The fake is real code — it can have assertions, state inspection, and behavior customization without complex mock setup.