Want to start a new TypeScript project with clean architecture already in place? This guide shows how to generate a complete layered architecture from UML diagrams—domain entities, use cases, ports, adapters, and infrastructure.
What is Clean Architecture?
Clean Architecture (popularized by Robert C. Martin) organizes code into concentric layers:
┌─────────────────────────────────────────────┐
│ Frameworks │ ← External (Express, React, Postgres)
├─────────────────────────────────────────────┤
│ Interface Adapters │ ← Controllers, Gateways, Presenters
├─────────────────────────────────────────────┤
│ Application Layer │ ← Use Cases, Application Services
├─────────────────────────────────────────────┤
│ Domain Layer │ ← Entities, Value Objects, Domain Services
└─────────────────────────────────────────────┘
The dependency rule: code in inner layers cannot reference outer layers. This makes the domain portable and testable.
Why Generate Clean Architecture?
Setting up clean architecture manually is tedious:
- Creating the folder structure
- Defining interfaces for every port
- Wiring dependency injection
- Maintaining consistency across layers
Model-driven generation solves this:
- Model your domain in a class diagram
- Model your use cases in sequence/activity diagrams
- Generate the entire project structure
- Start coding business logic—not boilerplate
Step 1: Model the Domain Layer
Your domain layer contains entities (things with identity) and value objects (things defined by attributes).
Example: E-commerce Domain
┌─────────────────────────────┐
│ <<entity>> │
│ Order │
├─────────────────────────────┤
│ - id: OrderId │
│ - customerId: CustomerId │
│ - items: OrderItem[] │
│ - status: OrderStatus │
│ - createdAt: Date │
├─────────────────────────────┤
│ + addItem(item): void │
│ + removeItem(id): void │
│ + submit(): void │
│ + cancel(): void │
└─────────────────────────────┘
┌─────────────────────────────┐
│ <<valueObject>> │
│ OrderItem │
├─────────────────────────────┤
│ - productId: ProductId │
│ - quantity: number │
│ - price: Money │
├─────────────────────────────┤
│ + total(): Money │
└─────────────────────────────┘
┌─────────────────────────────┐
│ <<valueObject>> │
│ Money │
├─────────────────────────────┤
│ - amount: number │
│ - currency: Currency │
├─────────────────────────────┤
│ + add(m: Money): Money │
│ + multiply(n): Money │
└─────────────────────────────┘
Generated Domain Code
// src/domain/entities/Order.ts
import { Entity } from '../core/Entity';
import { OrderId } from '../valueObjects/OrderId';
import { CustomerId } from '../valueObjects/CustomerId';
import { OrderItem } from '../valueObjects/OrderItem';
import { OrderStatus } from '../valueObjects/OrderStatus';
interface OrderProps {
customerId: CustomerId;
items: OrderItem[];
status: OrderStatus;
createdAt: Date;
}
export class Order extends Entity<OrderId, OrderProps> {
get customerId(): CustomerId {
return this.props.customerId;
}
get items(): OrderItem[] {
return [...this.props.items];
}
get status(): OrderStatus {
return this.props.status;
}
addItem(item: OrderItem): void {
if (this.props.status !== OrderStatus.DRAFT) {
throw new Error('Cannot modify submitted order');
}
this.props.items.push(item);
}
removeItem(productId: ProductId): void {
if (this.props.status !== OrderStatus.DRAFT) {
throw new Error('Cannot modify submitted order');
}
this.props.items = this.props.items.filter(
i => !i.productId.equals(productId)
);
}
submit(): void {
if (this.props.items.length === 0) {
throw new Error('Cannot submit empty order');
}
this.props.status = OrderStatus.SUBMITTED;
}
cancel(): void {
if (this.props.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel shipped order');
}
this.props.status = OrderStatus.CANCELLED;
}
}
// src/domain/valueObjects/Money.ts
import { ValueObject } from '../core/ValueObject';
import { Currency } from './Currency';
interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money extends ValueObject<MoneyProps> {
get amount(): number {
return this.props.amount;
}
get currency(): Currency {
return this.props.currency;
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new Error('Cannot add different currencies');
}
return new Money({
amount: this.amount + other.amount,
currency: this.currency
});
}
multiply(factor: number): Money {
return new Money({
amount: this.amount * factor,
currency: this.currency
});
}
protected validate(): void {
if (this.props.amount < 0) {
throw new Error('Money amount cannot be negative');
}
}
}
Step 2: Model Use Cases with Sequence Diagrams
Use cases orchestrate domain objects. Model them as sequence diagrams:
Controller CreateOrderUseCase OrderRepository EventPublisher
│ │ │ │
│── execute(dto) ──▶ │ │
│ │── create(props) ───▶│ │
│ │◀─── order ──────────│ │
│ │── validate() ───────┤ │
│ │── save(order) ──────▶ │
│ │ │ │
│ │── publish(OrderCreated) ───────────────▶│
│◀─── Result ──────│ │ │
Generated Use Case
// src/application/useCases/CreateOrderUseCase.ts
import { UseCase } from '../core/UseCase';
import { Order } from '../../domain/entities/Order';
import { OrderRepository } from '../ports/OrderRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { CreateOrderDTO } from '../dtos/CreateOrderDTO';
import { OrderCreatedEvent } from '../../domain/events/OrderCreatedEvent';
export class CreateOrderUseCase implements UseCase<CreateOrderDTO, Order> {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventPublisher: EventPublisher
) {}
async execute(dto: CreateOrderDTO): Promise<Order> {
// Create domain entity
const order = Order.create({
customerId: new CustomerId(dto.customerId),
items: dto.items.map(item => OrderItem.create({
productId: new ProductId(item.productId),
quantity: item.quantity,
price: new Money({ amount: item.price, currency: Currency.USD })
})),
status: OrderStatus.DRAFT,
createdAt: new Date()
});
// Persist
await this.orderRepository.save(order);
// Publish domain event
await this.eventPublisher.publish(
new OrderCreatedEvent(order.id, order.customerId)
);
return order;
}
}
Step 3: Define Ports (Interfaces)
Ports are the contracts between your application and the outside world:
// src/application/ports/OrderRepository.ts
import { Order } from '../../domain/entities/Order';
import { OrderId } from '../../domain/valueObjects/OrderId';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
delete(id: OrderId): Promise<void>;
}
// src/application/ports/EventPublisher.ts
import { DomainEvent } from '../../domain/events/DomainEvent';
export interface EventPublisher {
publish<T extends DomainEvent>(event: T): Promise<void>;
}
Step 4: Generate Adapters
Adapters implement ports for specific technologies:
// src/adapters/persistence/PostgresOrderRepository.ts
import { Pool } from 'pg';
import { Order } from '../../domain/entities/Order';
import { OrderRepository } from '../../application/ports/OrderRepository';
import { OrderMapper } from './mappers/OrderMapper';
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: Pool) {}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.pool.query(
`INSERT INTO orders (id, customer_id, items, status, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
items = EXCLUDED.items,
status = EXCLUDED.status`,
[data.id, data.customerId, JSON.stringify(data.items), data.status, data.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.length === 0) return null;
return OrderMapper.toDomain(result.rows[0]);
}
async findByCustomerId(customerId: CustomerId): Promise<Order[]> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC',
[customerId.value]
);
return result.rows.map(OrderMapper.toDomain);
}
async delete(id: OrderId): Promise<void> {
await this.pool.query('DELETE FROM orders WHERE id = $1', [id.value]);
}
}
Generated Project Structure
Here’s the complete generated structure:
src/
├── domain/
│ ├── core/
│ │ ├── Entity.ts
│ │ ├── ValueObject.ts
│ │ ├── AggregateRoot.ts
│ │ └── DomainEvent.ts
│ ├── entities/
│ │ ├── Order.ts
│ │ ├── Customer.ts
│ │ └── Product.ts
│ ├── valueObjects/
│ │ ├── OrderId.ts
│ │ ├── CustomerId.ts
│ │ ├── Money.ts
│ │ └── OrderStatus.ts
│ ├── events/
│ │ ├── OrderCreatedEvent.ts
│ │ └── OrderShippedEvent.ts
│ └── services/
│ └── PricingService.ts
│
├── application/
│ ├── core/
│ │ └── UseCase.ts
│ ├── ports/
│ │ ├── OrderRepository.ts
│ │ ├── CustomerRepository.ts
│ │ ├── EventPublisher.ts
│ │ └── PaymentGateway.ts
│ ├── useCases/
│ │ ├── CreateOrderUseCase.ts
│ │ ├── SubmitOrderUseCase.ts
│ │ └── CancelOrderUseCase.ts
│ └── dtos/
│ ├── CreateOrderDTO.ts
│ └── OrderResponseDTO.ts
│
├── adapters/
│ ├── persistence/
│ │ ├── PostgresOrderRepository.ts
│ │ └── mappers/
│ │ └── OrderMapper.ts
│ ├── messaging/
│ │ └── RabbitMQEventPublisher.ts
│ └── payment/
│ └── StripePaymentGateway.ts
│
└── infrastructure/
├── http/
│ ├── controllers/
│ │ └── OrderController.ts
│ └── middleware/
│ └── authMiddleware.ts
├── config/
│ └── database.ts
└── di/
└── container.ts
Dependency Injection Setup
The generator also creates DI container wiring:
// src/infrastructure/di/container.ts
import { Container } from 'inversify';
import { Pool } from 'pg';
// Ports
import { OrderRepository } from '../../application/ports/OrderRepository';
import { EventPublisher } from '../../application/ports/EventPublisher';
// Adapters
import { PostgresOrderRepository } from '../../adapters/persistence/PostgresOrderRepository';
import { RabbitMQEventPublisher } from '../../adapters/messaging/RabbitMQEventPublisher';
// Use Cases
import { CreateOrderUseCase } from '../../application/useCases/CreateOrderUseCase';
const container = new Container();
// Infrastructure
container.bind<Pool>('Pool').toConstantValue(new Pool());
// Ports → Adapters
container.bind<OrderRepository>('OrderRepository').to(PostgresOrderRepository);
container.bind<EventPublisher>('EventPublisher').to(RabbitMQEventPublisher);
// Use Cases
container.bind<CreateOrderUseCase>('CreateOrderUseCase').to(CreateOrderUseCase);
export { container };
Testing Clean Architecture
Each layer has different testing strategies:
Domain Layer (Unit Tests)
describe('Order', () => {
it('should add item to draft order', () => {
const order = Order.create({ /* ... */ });
order.addItem(OrderItem.create({ /* ... */ }));
expect(order.items).toHaveLength(1);
});
it('should not allow adding items to submitted order', () => {
const order = Order.create({ /* ... */ });
order.submit();
expect(() => order.addItem(/* ... */)).toThrow();
});
});
Application Layer (Integration Tests with Mocks)
describe('CreateOrderUseCase', () => {
it('should create and persist order', async () => {
const mockRepo = { save: jest.fn() };
const mockPublisher = { publish: jest.fn() };
const useCase = new CreateOrderUseCase(mockRepo, mockPublisher);
const result = await useCase.execute({ /* dto */ });
expect(mockRepo.save).toHaveBeenCalledWith(expect.any(Order));
expect(mockPublisher.publish).toHaveBeenCalledWith(expect.any(OrderCreatedEvent));
});
});
Next Steps
- Try the generator: Interactive demo
- Learn more: Clean architecture starter from diagrams
- Explore features: Code generation
Ready to scaffold your TypeScript project? Start building free.