Learn how to structure software so business rules stay independent from frameworks, databases, and external services, with practical TypeScript examples.
As software systems grow, keeping the codebase maintainable becomes increasingly difficult.
Features accumulate, business rules become more complex, and dependencies between components can make even small changes risky.
Clean Architecture is an approach to structuring software so that business rules remain independent from frameworks, databases, user interfaces, and external services.
The goal is not to create more layers or more files. The goal is to make the system easier to understand, test, change, and evolve.
The central idea behind Clean Architecture is dependency direction.
Business rules should not depend on infrastructure details.
Instead, external concerns should depend on the core of the application.
A simplified structure looks like this:
+-----------------------------------+
| Frameworks & UI |
+-----------------------------------+
| Infrastructure |
+-----------------------------------+
| Application Logic |
+-----------------------------------+
| Domain / Business Rules |
+-----------------------------------+
The closer a component is to the center, the less it should know about external implementation details.
For example, a business rule should not need to know whether data comes from PostgreSQL, MongoDB, an HTTP API, or an in-memory repository.
Consider an application that creates orders.
A tightly coupled implementation might look like:
async function createOrder(data) {
const connection = await postgres.connect();
const result = await connection.query(
"INSERT INTO orders (...) VALUES (...)"
);
return result;
}
The business operation is now directly coupled to PostgreSQL.
If the database changes, the business logic has to change as well.
A better approach is to define an abstraction:
interface OrderRepository {
save(order: Order): Promise<Order>;
}
The application logic depends on the interface rather than a specific database implementation.
The PostgreSQL implementation can live outside the core:
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<Order> {
// PostgreSQL-specific implementation
}
}
Now the business logic does not need to know how persistence works.
A useful way to structure application logic is around use cases.
Instead of organizing everything around technical components such as controllers and database services, organize important application operations around what the system actually does.
For example:
CreateOrder
CancelOrder
PayOrder
GetOrder
ListOrders
A use case might look like:
class CreateOrder {
constructor(
private readonly orders: OrderRepository
) {}
async execute(input: CreateOrderInput) {
const order = Order.create(input);
return this.orders.save(order);
}
}
The use case coordinates the operation without depending directly on HTTP, SQL, or a specific framework.
This makes the application's behavior easier to test independently.
Controllers should translate external input into application-level operations.
A controller should generally not contain complex business rules.
For example:
class CreateOrderController {
constructor(
private readonly createOrder: CreateOrder
) {}
async handle(request: Request) {
const order = await this.createOrder.execute({
customerId: request.body.customerId,
items: request.body.items
});
return {
status: 201,
body: order
};
}
}
The controller handles the HTTP-specific concerns.
The use case handles the application behavior.
This separation makes both components easier to reason about.
Business rules should live close to the domain objects they describe.
Suppose an order cannot be canceled after it has been shipped.
Avoid putting this rule exclusively inside a controller:
if (order.status === "shipped") {
throw new Error("Order cannot be canceled");
}
Instead, the domain model can enforce the invariant:
class Order {
cancel() {
if (this.status === "shipped") {
throw new Error("Order cannot be canceled");
}
this.status = "canceled";
}
}
Now every caller receives the same business behavior.
The rule is not tied to an HTTP endpoint or a particular user interface.
Dependency inversion is one of the most important principles behind Clean Architecture.
High-level application logic should not depend directly on low-level implementation details.
Instead:
Application
↓
Interface
↑
Infrastructure
For example:
interface PaymentGateway {
charge(amount: number): Promise<void>;
}
The application depends on PaymentGateway.
A concrete implementation can then use Stripe, Adyen, PayPal, or another provider:
class StripePaymentGateway implements PaymentGateway {
async charge(amount: number) {
// Stripe implementation
}
}
The payment provider becomes a replaceable detail rather than a core dependency.
Dependency injection is a practical way to provide implementations to the application.
For example:
const paymentGateway = new StripePaymentGateway();
const processPayment = new ProcessPayment(
paymentGateway
);
The use case does not create its own infrastructure dependencies.
This has several advantages:
It also makes architectural boundaries visible in the code.
One of the biggest benefits of Clean Architecture is improved testability.
Because business logic does not depend directly on infrastructure, tests can use simple in-memory implementations.
For example:
class InMemoryOrderRepository implements OrderRepository {
private orders: Order[] = [];
async save(order: Order) {
this.orders.push(order);
return order;
}
}
A test can then execute the real application logic without requiring a database:
const repository = new InMemoryOrderRepository();
const createOrder = new CreateOrder(repository);
const order = await createOrder.execute({
customerId: "customer-123",
items: []
});
This makes tests faster and reduces the amount of infrastructure required to verify business behavior.
Clean Architecture does not mean that every class needs an interface.
Creating abstractions without a real reason can make the codebase unnecessarily complicated.
For example, this may provide little value:
interface DateService {
now(): Date;
}
class SystemDateService implements DateService {
now() {
return new Date();
}
}
If there is no realistic need to replace the implementation, the abstraction may simply add noise.
Use abstractions where they provide meaningful architectural value, such as:
The goal is useful decoupling, not maximum abstraction.
Frameworks are useful, but they should not define the entire architecture of the application.
A common problem is allowing framework concepts to leak into every layer.
For example, if business logic requires framework-specific decorators, request objects, or database models, replacing the framework becomes extremely difficult.
A healthier structure is:
src/
├── domain/
├── application/
├── infrastructure/
└── interfaces/
The exact folder structure is less important than the dependency boundaries.
The framework should be an implementation detail rather than the center of the system.
Not every application needs a complex architecture from day one.
A small application may only need:
Controller
↓
Service
↓
Repository
As the domain becomes more complex, stronger boundaries may become valuable.
Architecture should therefore evolve based on actual complexity.
Prematurely introducing dozens of abstractions can slow development without providing meaningful benefits.
The goal is not to predict every future requirement.
The goal is to make important changes safe and manageable.
The most important lesson is that Clean Architecture is not primarily about folders, interfaces, or patterns.
It is about boundaries.
A good architecture makes it clear:
When these boundaries are clear, developers can change infrastructure without rewriting business logic.
A database migration should not require rewriting domain rules.
Changing an HTTP framework should not require changing the application's core behavior.
Replacing an external provider should not require modifying unrelated business logic.
Clean Architecture provides a practical way to build software that can evolve without becoming increasingly difficult to maintain.
The most important principles are simple:
Clean Architecture is not a recipe that every project must follow exactly.
Its real value comes from understanding the underlying principle: important business decisions should not depend on replaceable technical details.
When that boundary is maintained, software becomes easier to test, easier to change, and significantly more resilient as the system grows.
You are in reading mode. Open the discussions tab to explore threads about this article.