Clean Architecture in Node.js and TypeScript: From HTTP Controller to Database
Learn Clean Architecture in Node.js and TypeScript with domain models, use cases, ports, adapters, repositories, controllers, dependency injection, and practical trade-offs.
25 min read
The Controller That Slowly Became the Entire Application
Many Node.js applications start with something simple:
app.post("/orders", async (request, response) => {
const total = calculateTotal(request.body.items);
const payment = await stripe.charges.create({
amount: total,
});
const result = await pool.query("INSERT INTO orders ...");
await queue.publish("order.created", result.rows[0]);
response.status(201).json(result.rows[0]);
});At first, this feels convenient.
Everything is in one place.
But look at what this route is actually doing:
POST /orders
↓
Parse HTTP request
↓
Calculate order total
↓
Call Stripe
↓
Write PostgreSQL
↓
Publish message
↓
Build HTTP responseOne function now knows about:
HTTP
business rules
Stripe
PostgreSQL
messaging
response formattingThat creates several problems.
If we change Express to another framework, business logic may need to change.
If we want to test order rules, we may need Stripe and PostgreSQL.
If Stripe succeeds but the database fails, the workflow has an unclear recovery path.
If the SQL schema changes, a route handler changes.
The problem is not that the function is long.
The bigger problem is that unrelated parts of the system depend directly on one another.
Clean Architecture tries to fix that by controlling dependency direction.
What Clean Architecture Is Really About
Clean Architecture is often shown using circles and diagrams.
But the main idea is much simpler:
Business rules should not depend on frameworks, databases, or external services.
Instead, dependencies should point toward the business logic.
Conceptually:
HTTP
Database
Stripe
Kafka
Redis
Frameworks
│
│ depend inward
↓
Application Use Cases
↓
Domain / Business RulesAnother way to show it:
Infrastructure ─┐
HTTP delivery ──┼──> Application ───> Domain
External APIs ──┘The inner layers know very little about the outer layers.
The outer layers know how to talk to the inner layers.
The Dependency Rule
Suppose we have an Order domain.
The domain should not import:
express;or:
PrismaClient;or:
Stripe;or:
Kafka;The domain should understand concepts such as:
Order
OrderItem
Money
Customer
Payment status
Business rulesnot infrastructure technology.
This is the key rule:
Frameworks depend on business logic.
Business logic does not depend on frameworks.Why Does Dependency Direction Matter?
Imagine our business rule says:
An order must contain at least one item.That rule should remain valid whether we use:
Express
Fastify
Next.js
NestJS
CLI
background workerIt should also remain valid whether data is stored in:
PostgreSQL
MongoDB
MySQL
in-memory storageThe rule belongs to the business.
It should not be controlled by technology choices.
A Practical Folder Structure
A simple structure might look like:
src/
├── domain/
│ └── order/
│ ├── Order.ts
│ ├── OrderItem.ts
│ └── Money.ts
│
├── application/
│ └── place-order/
│ ├── PlaceOrder.ts
│ ├── PlaceOrderCommand.ts
│ └── ports.ts
│
├── infrastructure/
│ ├── database/
│ │ └── PostgresOrderRepository.ts
│ │
│ ├── payments/
│ │ └── StripePaymentAdapter.ts
│ │
│ └── messaging/
│ └── OutboxEventPublisher.ts
│
├── http/
│ └── OrderController.ts
│
└── composition-root.tsYou do not have to use these exact folder names.
You could use:
core
use-cases
adapters
deliveryinstead.
The names matter less than this:
Who owns this code?
What is it allowed to depend on?The Main Layers
For this example, we will use four main areas:
Domain
Application
Infrastructure
Delivery / HTTPPlus:
Composition Rootfor wiring everything together.
1. Domain Layer
The domain contains core business rules.
Example:
export class Order {
private constructor(
readonly id: string,
readonly customerId: string,
readonly items: OrderItem[],
private currentStatus: "pending" | "paid",
private paymentId?: string,
) {}
static create(customerId: string, items: OrderItem[]) {
if (items.length === 0) {
throw new EmptyOrderError();
}
return new Order(crypto.randomUUID(), customerId, items, "pending");
}
get total() {
return Money.sum(this.items.map((item) => item.subtotal));
}
markPaid(paymentId: string) {
if (this.currentStatus !== "pending") {
throw new InvalidOrderTransitionError();
}
this.currentStatus = "paid";
this.paymentId = paymentId;
}
}This class owns business rules.
For example:
Order cannot be empty.and:
Only a pending order can become paid.Those rules belong here because they describe what an Order is allowed to do.
What the Domain Layer Should Not Do
The domain should not do this:
await pool.query(...)or:
await stripe.charges.create(...)or:
response.status(201);Why?
Because these are not business rules.
They are technical concerns.
The domain should not know:
where data is stored
which HTTP framework is used
which payment provider is usedDomain Object vs Database Row
This distinction is important.
A database row may look like:
type OrderRow = {
id: string;
customer_id: string;
status: string;
total_minor: number;
currency: string;
};The domain object may look like:
Order;with methods like:
markPaid();and:
cancel();The database row represents:
how data is storedThe domain object represents:
business behavior and invariantsThey are related, but they are not automatically the same thing.
Domain Layer Mental Model
Think:
"What rules are true regardless
of Express, PostgreSQL, Stripe,
or any other technology?"Those rules probably belong in the domain.
2. Application Layer
The application layer coordinates a business operation.
For example:
Place an orderThis is a use case.
The domain knows:
what an Order isThe application layer knows:
how the Place Order workflow happensThese are slightly different responsibilities.
Define a Use Case
For example:
export class PlaceOrder {
async execute(command: PlaceOrderCommand) {
// workflow
}
}A use case typically represents one application action.
Examples:
PlaceOrder
CancelOrder
GetOrder
GenerateInvoice
RegisterUser
ApproveApplicationThe Use Case Needs External Capabilities
Placing an order may require:
save order
charge payment
publish eventBut the application should not directly depend on:
PostgreSQL
Stripe
KafkaInstead, it defines what it needs.
Define Ports
export interface OrderRepository {
save(order: Order): Promise<void>;
}The application says:
I need something that can save an Order.
It does not say:
I need PostgreSQL.
For payments:
export interface PaymentGateway {
charge(input: { idempotencyKey: string; amount: Money }): Promise<{
paymentId: string;
}>;
}The application says:
I need something that can charge money.
It does not say:
I need Stripe.
For events:
export interface EventPublisher {
publish(events: DomainEvent[]): Promise<void>;
}Again:
capability
not technologyThese interfaces are often called:
portsWhat Is a Port?
A port describes a capability that the application needs.
For example:
Application needs to save orders
↓
OrderRepositoryApplication needs to collect payment
↓
PaymentGatewayApplication needs to publish events
↓
EventPublisherA port belongs to the inner layer because the inner layer defines what it needs.
Infrastructure later implements it.
PlaceOrder Use Case
Now the use case can be written using only these contracts:
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway,
private readonly events: EventPublisher,
) {}
async execute(command: PlaceOrderCommand) {
const order = Order.create(
command.customerId,
command.items.map(OrderItem.create),
);
const payment = await this.payments.charge({
idempotencyKey: order.id,
amount: order.total,
});
order.markPaid(payment.paymentId);
await this.orders.save(order);
await this.events.publish([
{
type: "order.paid",
orderId: order.id,
},
]);
return {
orderId: order.id,
status: "paid" as const,
};
}
}Look carefully at what is missing.
There is no:
SQL
Stripe SDK
Express Request
HTTP status
Kafka producerThe use case understands the workflow.
Application Layer Responsibility
The application layer answers:
What steps make up this use case?For example:
Create order
↓
charge payment
↓
mark order paid
↓
save order
↓
publish eventIt coordinates.
It does not own every business invariant.
That still belongs to the domain.
Domain vs Application
This distinction can be confusing.
Easy mental model:
Domain
→ business rulesApplication
→ business workflowFor example:
"An order cannot be empty"
→ Domainwhile:
"Create order, charge payment,
save order, publish event"
→ Application3. Infrastructure Layer
The application defines interfaces.
Infrastructure implements them.
For example:
OrderRepository
↑
PostgresOrderRepositoryand:
PaymentGateway
↑
StripePaymentAdapterPostgreSQL Repository
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
await this.pool.query(
`
INSERT INTO orders (
id,
customer_id,
total,
currency,
status
)
VALUES ($1, $2, $3, $4, $5)
`,
[
order.id,
order.customerId,
order.total.amountInMinorUnits,
order.total.currency,
order.status,
],
);
}
}This class knows:
SQL
PostgreSQL
column names
database mappingThat is fine.
Those are infrastructure concerns.
Stripe Adapter
export class StripePaymentAdapter implements PaymentGateway {
constructor(private readonly stripe: StripeClient) {}
async charge(input: ChargePayment) {
const charge = await this.stripe.createCharge({
amount: input.amount.amountInMinorUnits,
currency: input.amount.currency,
idempotencyKey: input.idempotencyKey,
});
return {
paymentId: charge.id,
};
}
}The adapter translates:
application languageinto:
Stripe languageWhy Is It Called an Adapter?
The application expects:
PaymentGateway;Stripe provides its own SDK with its own API.
The adapter sits between them:
PlaceOrder
↓
PaymentGateway
↑
StripePaymentAdapter
↓
Stripe SDKIt translates one interface into another.
That is the Adapter pattern.
Repository Is Also a Boundary
The same idea applies to persistence:
Application
↓
OrderRepository
↑
PostgresOrderRepository
↓
PostgreSQLThe application knows:
save orderInfrastructure knows:
INSERT INTO orders ...This separation is extremely useful when persistence logic becomes complex.
Does This Mean You Can Easily Replace PostgreSQL With MongoDB?
Technically, the abstraction can reduce direct coupling.
But do not misunderstand the benefit.
Switching databases may still require major changes because databases have different:
transactions
query models
constraints
index behavior
consistency modelsThe real value is not:
"We can switch databases tomorrow."The bigger value is:
"Business logic does not contain SQL
or ORM-specific behavior."4. Delivery Layer
The delivery layer receives input from the outside world.
In our example:
HTTPOther delivery mechanisms could be:
GraphQL
CLI
Kafka consumer
Cron job
WebSocketFor HTTP, this layer usually contains controllers or route handlers.
Controller Example
export class OrderController {
constructor(private readonly placeOrder: PlaceOrder) {}
async handle(request: Request): Promise<Response> {
const body = await request.json();
const parsed = placeOrderSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{
error: "Invalid order",
details: parsed.error.flatten(),
},
{
status: 400,
},
);
}
try {
const result = await this.placeOrder.execute(parsed.data);
return Response.json(result, {
status: 201,
});
} catch (error) {
return mapApplicationErrorToResponse(error);
}
}
}What Should the Controller Do?
A controller usually owns:
Read HTTP request
Validate transport input
Call a use case
Map errors
Build HTTP responseFor example:
HTTP request
↓
Controller
↓
PlaceOrder.execute()
↓
HTTP responseWhat Should the Controller Not Do?
Avoid putting:
price calculation
business transitions
SQL
Stripe calls
Kafka publishing
complex domain validationinside the controller.
Bad:
if (order.status === "pending") {
await stripe...
await db...
}Better:
await placeOrder.execute(command);The controller should stay focused on delivery concerns.
DTOs and Commands
The HTTP request shape and application command do not have to be identical.
For example, public request:
{
"customerId": "customer-1",
"items": [
{
"productId": "product-1",
"quantity": 2
}
]
}The controller may map this into:
type PlaceOrderCommand = {
tenantId: string;
actorId: string;
customerId: string;
items: Array<{
productId: string;
quantity: number;
}>;
};Why?
Because:
tenantId
actorIdmay come from authentication, not the body.
The application should receive a trusted application command.
Do Not Create Mappers Everywhere
This architecture does not mean every layer needs:
RequestDto
RequestMapper
CommandDto
CommandMapper
DomainMapper
PersistenceDto
PersistenceMapper
ResponseMapperfor every endpoint.
Create mappings when the models genuinely differ.
For example:
HTTP body
≠
application commandbecause tenant information comes from auth.
Or:
database row
≠
domain modelbecause the domain contains behavior and value objects.
But if two local structures are identical and there is no useful boundary, copying them through five files creates ceremony.
5. Composition Root
We have:
PlaceOrder
OrderRepository
PaymentGateway
EventPublisherand concrete implementations:
PostgresOrderRepository
StripePaymentAdapter
OutboxEventPublisherSomething still has to create them.
That place is the:
Composition RootComposition Root Example
const pool = new Pool({
connectionString: env.DATABASE_URL,
});
const stripe = new StripeClient(env.STRIPE_SECRET_KEY);
const orders = new PostgresOrderRepository(pool);
const payments = new StripePaymentAdapter(stripe);
const events = new OutboxEventPublisher(pool);
const placeOrder = new PlaceOrder(orders, payments, events);
export const orderController = new OrderController(placeOrder);This is where concrete technologies meet application abstractions.
Why Keep Construction in One Place?
Without a composition root, classes may create their own dependencies:
class PlaceOrder {
private stripe =
new StripeClient(...);
private db =
new PrismaClient();
}Now PlaceOrder controls its infrastructure.
Testing becomes harder.
Replacing implementations becomes harder.
Instead:
Composition Root
↓
creates implementations
↓
injects them into applicationThis Is Dependency Injection
new PlaceOrder(orders, payments, events);The dependencies are passed into PlaceOrder.
That is dependency injection.
You do not need a DI framework to do this.
Manual constructor injection is often enough.
Clean Architecture + Dependency Inversion
The dependency graph now looks like:
PlaceOrder
↓
OrderRepository
↑
PostgresOrderRepositoryand:
PlaceOrder
↓
PaymentGateway
↑
StripePaymentAdapterThe important part is:
PlaceOrder does NOT depend
on PostgresOrderRepository.Instead:
PostgresOrderRepository
depends on the application's contract.This is Dependency Inversion in practice.
Follow One Request From HTTP to Database
Let's trace the complete flow.
The client sends:
POST /ordersThe architecture looks like:
POST /orders
↓
OrderController
↓
validate HTTP input
↓
create PlaceOrderCommand
↓
PlaceOrder
↓
Order.create()
↓
PaymentGateway
↓
StripePaymentAdapter
↓
Stripe
↓
Order.markPaid()
↓
OrderRepository
↓
PostgresOrderRepository
↓
PostgreSQL
↓
EventPublisher
↓
OutboxEventPublisher
↓
HTTP responseEach layer has a specific purpose.
Another View
┌──────────────────────────────┐
│ HTTP Layer │
│ │
│ OrderController │
│ - parse request │
│ - validate schema │
│ - return HTTP response │
└──────────────┬───────────────┘
│
↓
┌──────────────────────────────┐
│ Application Layer │
│ │
│ PlaceOrder │
│ - coordinate workflow │
│ - call required ports │
└──────────────┬───────────────┘
│
↓
┌──────────────────────────────┐
│ Domain Layer │
│ │
│ Order │
│ Money │
│ OrderItem │
│ - enforce business rules │
└──────────────────────────────┘
Infrastructure implements
the ports needed by Application:
PostgreSQL ← PostgresOrderRepository
Stripe ← StripePaymentAdapter
Outbox ← OutboxEventPublisherWhy the Flow Is Longer
The original code was:
one controllerThe Clean Architecture version may involve:
controller
use case
domain
port
adapterThat is more code.
So why do it?
Because each part now changes for a narrower reason.
For example:
Change Express
→ controller changesChange pricing rule
→ domain changesChange payment provider
→ payment adapter changesChange PostgreSQL schema
→ repository changesThe goal is not fewer files.
The goal is:
safer change boundariesClean Architecture Does Not Solve Transactions Automatically
Suppose the use case does:
charge payment
↓
save orderWhat happens if payment succeeds but saving fails?
Stripe
✓ payment charged
PostgreSQL
✗ insert failsThe architecture is clean.
But the customer was still charged without a saved order.
Clean Architecture does not solve this automatically.
Architecture vs Reliability
Clean Architecture helps us see boundaries.
It does not magically give us:
distributed transactions
exactly-once delivery
automatic rollback across Stripe + PostgreSQLThose are separate distributed-systems problems.
Possible Payment Workflow Designs
One approach:
Authorize payment
↓
save order
↓
capture paymentif the provider supports authorization and capture separately.
Another:
Create pending order
↓
commit database
↓
charge payment
↓
update orderAnother:
charge payment
↓
if database fails
↓
refund / void paymentAnother:
use stable order ID
as payment idempotency keyThe correct solution depends on business requirements and provider capabilities.
Use an Outbox for Events
Consider:
save order
↓
publish Kafka eventWhat happens if:
database save succeeds
Kafka publish failsNow the database knows about the order, but other services never receive the event.
A transactional outbox changes the flow:
Database transaction
├── save order
└── save outbox event
↓
commit
↓
background worker
↓
publish eventThis makes local database state and event intent atomic.
Again, Clean Architecture makes the boundary clear, but the outbox solves the reliability problem.
Where Should Transactions Live?
This is a common question.
A transaction usually represents an application workflow.
For example:
Create order
+
reserve stock
+
write outbox eventshould commit together.
The application layer should define that requirement.
Infrastructure should implement the actual PostgreSQL transaction mechanism.
One possible contract:
interface TransactionManager {
run<T>(work: (context: TransactionContext) => Promise<T>): Promise<T>;
}But do not create a transaction abstraction unless the workflow actually needs it.
Test at the Right Layer
One big benefit of this architecture is that different tests can focus on different things.
Domain Tests
Domain tests verify business rules.
For example:
expect(() => Order.create("customer-1", [])).toThrow(EmptyOrderError);No:
database
HTTP server
Stripe
Redisneeded.
These tests should be extremely fast.
Use Case Tests
Use-case tests verify workflow behavior.
We can inject fake implementations.
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway();
const events = new RecordingEventPublisher();
const useCase = new PlaceOrder(orders, payments, events);
await useCase.execute(validCommand);
expect(orders.saved).toHaveLength(1);
expect(events.published[0].type).toBe("order.paid");No real Stripe.
No real PostgreSQL.
The test verifies:
workflownot infrastructure.
Adapter Tests
Adapters should be tested against the technologies they translate.
For example:
PostgresOrderRepository
→ real PostgreSQL test databaseVerify things such as:
SQL mapping
constraints
transactions
column typesFor:
StripePaymentAdapteruse:
provider sandbox
contract fixture
or realistic mock serverdepending on the integration.
End-to-End Tests
A smaller number of E2E tests should verify the assembled system.
For example:
HTTP route
↓
authentication
↓
controller
↓
use case
↓
databaseE2E tests are useful but slower.
You usually do not need thousands of them if domain and use-case tests already cover business logic.
Testing Pyramid for This Architecture
Conceptually:
E2E
/ \
/ \
Adapter Tests
/ \
Use Case Tests
/ \
Domain Unit TestsUsually:
many fast inner tests
few slower outer testsInterfaces: When Should You Create Them?
Clean Architecture does not mean:
every class needs an interfaceGood interface:
interface PaymentGatewaybecause the application needs a boundary around an external payment system.
Good interface:
interface OrderRepositorybecause persistence is infrastructure.
Interface That May Be Unnecessary
Imagine:
class OrderTotalCalculator {
calculate() {}
}and there is:
one implementation
local pure logic
no external boundary
no substitution needCreating:
interface IOrderTotalCalculatormay add no value.
The class can simply be used directly.
A Good Rule for Interfaces
Create an interface when at least one of these is true:
You are crossing an architectural boundary.
You need multiple implementations.
You need to substitute behavior in tests.
The dependency belongs to infrastructure.
The contract is important independently
of one implementation.Do not create interfaces just to follow a diagram.
Where Clean Architecture Becomes Overengineering
Architecture becomes harmful when simple code turns into ceremony.
Warning signs:
one interface per classDTO copied unchanged through
five layersgeneric BaseRepository<T>BaseUseCase<TRequest, TResponse>empty domain models
with only getters/settersten mapper classes
for one simple field renameDI container configuration
that nobody understandsAt that point, the architecture may be harder to maintain than the original code.
Example of Unnecessary Layers
Suppose we have:
GET /app-versionwhich returns:
{
"minimumVersion": "2.3.0"
}Do we really need:
AppVersionController
↓
GetAppVersionUseCase
↓
AppVersionService
↓
AppVersionRepository
↓
AppVersionDomainEntity
↓
AppVersionMapperProbably not.
A simple handler may be enough.
Clean Architecture should solve real change pressure.
Start With the Smallest Useful Separation
A practical starting point is often:
Domain
Application
Infrastructure
HTTPYou can add more boundaries later if needed.
For example:
domain services
CQRS
ports split by capability
read models
event handlersonly when the application actually benefits.
Do Not Confuse Folder Count With Architecture
This:
controllers/
services/
repositories/does not automatically mean Clean Architecture.
Why?
Because a service could still import:
Prisma
Stripe
Express Requestdirectly.
Clean Architecture is about dependency rules.
Not folder names.
Traditional Layered Architecture vs Clean Architecture
Traditional layered architecture often looks like:
Controller
↓
Service
↓
Repository
↓
DatabaseThe dependency direction keeps moving downward toward infrastructure.
Clean Architecture changes that.
The application defines abstractions:
Controller
↓
Use Case
↓
Repository Interface
↑
Postgres RepositoryInfrastructure points inward toward the application contract.
That is the important difference.
Clean Architecture and Hexagonal Architecture
You will often hear:
Clean Architecture
Hexagonal Architecture
Ports and Adapters
Onion ArchitectureThese are not identical, but they share a similar goal:
Keep business logic independent from external technology.
The terminology differs.
For example:
Hexagonal
→ ports and adaptersClean
→ use cases and dependency ruleOnion
→ dependency direction toward domainIn a practical TypeScript backend, these ideas often overlap.
Do not spend too much time trying to make your folder structure perfectly match one architecture diagram.
Focus on the dependency boundaries.
Clean Architecture and SOLID
Clean Architecture also connects directly with SOLID.
SRP
Each layer has a focused reason to change.
Controller
→ HTTP changes
Use case
→ workflow changes
Domain
→ business-rule changes
Adapter
→ infrastructure changesOCP
We can add:
PayPalPaymentAdapterwithout rewriting:
PlaceOrderif it satisfies the same payment contract.
LSP
Every:
PaymentGateway;implementation must genuinely behave according to that contract.
ISP
PlaceOrder should depend only on:
charge paymentif that is all it needs.
It should not depend on one giant provider interface containing:
refund
payout
save card
create customer
chargeDIP
The application depends on:
PaymentGatewaynot:
Stripeand:
OrderRepositorynot:
PostgreSQLThat is Dependency Inversion.
Clean Architecture and Design Patterns
Several design patterns naturally appear.
Repository Pattern
→ persistence boundaryAdapter Pattern
→ translate Stripe/PostgreSQL/etc.Strategy Pattern
→ interchangeable business behaviorFactory / Composition Root
→ construct application dependenciesBut do not apply patterns just because they have names.
Use them when they make change easier.
Clean Architecture Works Well With a Modular Monolith
You do not need microservices to benefit from clean boundaries.
A modular monolith might look like:
src/
├── orders/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
│
├── users/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
│
└── billing/
├── domain/
├── application/
└── infrastructure/Each module can own its behavior.
You still deploy one application.
This gives you:
clear boundaries
without
distributed-system complexityClean Boundaries Can Help Future Extraction
Suppose Billing later needs to become a separate service.
If the monolith already has:
clear billing module
clear contracts
limited cross-module couplingextraction is easier.
But do not build microservices just because you have Clean Architecture.
Architecture boundaries and deployment boundaries are different decisions.
Adopt Clean Architecture Incrementally
You do not need to rewrite an entire backend.
Start with one painful workflow.
For example:
PlaceOrderbecause it currently mixes:
controller
payment
database
email
business logicThen refactor gradually.
Incremental Refactoring Plan
1. Find one difficult workflow.For example:
POST /orders2. Move business rules
out of the controller.For example:
Order.create()
Order.markPaid()3. Create one application use case.PlaceOrder;4. Define contracts around
external dependencies.OrderRepository;
PaymentGateway;
EventPublisher;5. Implement adapters.PostgresOrderRepository
StripePaymentAdapter
OutboxEventPublisher6. Create a visible composition root.Wire all concrete objects together.
7. Add tests around each boundary.8. Repeat only where useful.Do not rewrite stable simple endpoints just to make the codebase look architecturally pure.
Before and After
Before:
Controller
├── HTTP
├── pricing
├── Stripe
├── SQL
├── events
└── responseAfter:
Controller
↓
PlaceOrder
↓
Domain
PlaceOrder
├── PaymentGateway
├── OrderRepository
└── EventPublisher
Infrastructure
├── StripePaymentAdapter
├── PostgresOrderRepository
└── OutboxEventPublisherWho Owns What?
| Layer | Responsibility |
|---|---|
| Domain | Business rules and invariants |
| Application | Use-case workflow |
| Ports | Capabilities required by application |
| Infrastructure | Database, provider, queue implementations |
| HTTP/Delivery | Request/response translation |
| Composition Root | Object construction and wiring |
A Simple Mental Model
When you are unsure where code belongs, ask these questions.
Is this a business rule
that remains true regardless
of technology?Then:
DomainIs this coordinating the steps
of one application operation?Then:
Application / Use CaseIs this describing something
the application needs from outside?Then:
Port / InterfaceDoes this code talk directly to:
PostgreSQL
Stripe
Kafka
Redis
filesystem
external APIs?Then:
Infrastructure / AdapterDoes this code understand:
HTTP headers
status codes
request body
route params?Then:
Delivery / ControllerDoes this code create all
the concrete implementations?Then:
Composition RootThis mental model is often more useful than memorizing architecture diagrams.
Production Checklist
Before calling a backend "Clean Architecture," check:
□ Domain does not import
HTTP frameworks.
□ Domain does not import
database clients.
□ Domain does not import
provider SDKs.
□ Business invariants live
close to domain behavior.
□ Use cases represent
application operations.
□ Use cases coordinate workflows
rather than HTTP details.
□ External dependencies are
represented by useful contracts.
□ Infrastructure implements
those contracts.
□ Controllers mainly translate
transport input/output.
□ Tenant/auth context is mapped
into application commands.
□ Concrete dependencies are
wired in a composition root.
□ Tests can exercise domain rules
without infrastructure.
□ Use cases can be tested
with fakes where useful.
□ Database adapters are tested
against real database behavior.
□ Transaction boundaries
are explicit.
□ Distributed-system failures
are handled separately.
□ Interfaces exist because
they provide a useful boundary.
□ Mapping exists because
models genuinely differ.
□ Simple endpoints remain simple.
□ Developers can trace
the object graph.
□ Architecture reduces coupling
instead of adding ceremony.Conclusion
Clean Architecture is not about maximizing:
layers
interfaces
files
mappersIt is about controlling dependencies.
The most important rule is:
Business logic should not be
controlled by infrastructure.For our Order API:
Domain
→ protects Order rulesApplication
→ coordinates PlaceOrderPorts
→ describe what PlaceOrder needsInfrastructure
→ implements PostgreSQL,
Stripe, and messaging behaviorController
→ translates HTTPComposition Root
→ connects everythingThe final dependency direction looks like:
HTTP ───────────┐
PostgreSQL ─────┤
Stripe ─────────┼──> Application ───> Domain
Kafka/Outbox ───┘not:
Domain
↓
Express
↓
Prisma
↓
StripeThat is the real value of Clean Architecture.
It lets us change:
HTTP framework
database implementation
payment provider
messaging technologywithout forcing those technical choices into the core business rules.
But the architecture should remain practical.
If a simple endpoint needs only one function, use one function.
If a workflow mixes business logic, database access, external providers, and complex testing concerns, stronger boundaries can help.
The goal is not:
"Does this code perfectly match the Clean Architecture diagram?"
The better question is:
"Can business behavior change independently from infrastructure, and can we test the important rules without booting the whole system?"
If the answer is yes, Clean Architecture is doing its job.
The SOLID principles guide develops the design principles behind these boundaries. The production-ready REST API guide shows how the delivery layer applies them to validation, authorization, persistence, and failure handling.
References
Related
Written by
Faisal
Software engineer writing about backend systems, Node.js, system design, scalable applications, and modern web and mobile development.