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.

after/domain/order/order.repository.ts
typescript
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.

after/use-cases/order/place-order.ts
typescript
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.

after/controllers/order.controller.ts
typescript
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โ€ฆ