Repositories and use cases
Welcome back! Your domain entities are pure, but right now nothing actually saves an order or runs the workflow. Let's give each job its own home: the repository owns SQL, the use case owns orchestration, and the domain stays pure. Once these three roles are clean, every new feature drops into a predictable place.
The four layers
HTTP calls a use case. The use case orchestrates domain, repository, and side effects. The domain is pure. The repository is the only thing that knows SQL.
import { db } from '../../db';
import { orders } from '../../db/schema';
import { OrderEntity } from './order.types';
export async function insertOrder(order: OrderEntity): Promise<OrderEntity> {
const result = await db
.insert(orders)
.values({
userId: order.userId,
bookId: order.bookId,
quantity: order.quantity,
status: order.status,
})
.returning();
return result[0] as OrderEntity;
}The only place that imports db. No business rules, just queries.
import { CreateOrderInputSchema, OrderEntity } from '../../domain/order/order.types';
import { createNewOrder } from '../../domain/order/order.entity';
import { insertOrder } from '../../domain/order/order.repository';
import { checkBookExists } from '../../domain/book/book.repository';
import { getOrderQueue } from '../../queue';
export class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = 'DomainError';
}
}
export async function placeOrderUseCase(input: unknown): Promise<OrderEntity> {
// 1. Validate
const parsed = CreateOrderInputSchema.safeParse(input);
if (!parsed.success) {
throw new DomainError('Invalid order input: ' + parsed.error.message);
}
const data = parsed.data;
// 2. Business rule
const bookExists = await checkBookExists(data.bookId);
if (!bookExists) {
throw new DomainError('Book ' + data.bookId + ' does not exist');
}
// 3. Create entity (pure)
const newOrder = createNewOrder(data);
// 4. Persist (repository)
const saved = await insertOrder(newOrder);
// 5. Side effect (queue)
try {
await getOrderQueue().add('sendConfirmationEmail', { orderId: saved.id });
} catch (err) {
console.warn('Could not enqueue: ' + (err as Error).message);
}
return saved;
}The workflow in one place. Validate, check, create, persist, side effect.
Every use case follows the same recipe: validate, check, create, persist, side effect. Read the function top to bottom and the workflow is obvious. When a business requirement changes, you know exactly where to edit. When you add a CLI or a GraphQL endpoint, you reuse the same use case without touching any business logic.
import { Request, Response, NextFunction } from 'express';
import { placeOrderUseCase, DomainError } from '../use-cases/order/place-order';
export async function placeOrderHandler(req: Request, res: Response, next: NextFunction) {
try {
const order = await placeOrderUseCase({
userId: req.user!.userId,
bookId: req.body.bookId,
quantity: req.body.quantity,
});
res.status(202).json(order);
} catch (err) {
if (err instanceof DomainError) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
}The controller shrinks to nothing. Extract, delegate, map the result.
Quiz: Quiz
Loading practiceโฆ