Skip to main content
Use cases represent the core business logic of our application. They act as orchestrators, coordinating the interactions between different parts of the system to accomplish specific business goals. Each use case corresponds to a specific action or operation that a user can perform within our application.

Purpose

Use cases serve several important purposes in our architecture:
1

Business Logic Encapsulation

• They encapsulate the business rules and workflows of our application
2

Orchestration

• They coordinate interactions between different domain entities and infrastructure services
3

Independence

• They keep the business logic separate from infrastructure concerns like HTTP, databases, or external services
4

Reusability

• They can be called from different controllers or even from other use cases

Implementation

1

Structure

Use cases follow a consistent structure:• Single Responsibility: Each use case performs one specific action• Explicit Dependencies: All dependencies are injected through the constructor• Single Entry Point: Each use case exposes a single execute method• Domain-Oriented: They work with domain entities, not DTOs or other external representations
2

Example

export class CreateOrderUseCase {
  constructor(
    @Inject(IOrderRepository)
    private readonly orderRepository: IOrderRepository,
    @Inject(ILocationRepository)
    private readonly locationRepository: ILocationRepository,
    @Inject(ICustomerRepository)
    private readonly customerRepository: ICustomerRepository,
    @Inject(IHubriseClient)
    private readonly hubriseClient: IHubriseClient,
    @Inject(IStripeClient)
    private readonly stripeClient: IStripeClient,
    @Inject(IEventEmitter)
    private readonly eventEmitter: IEventEmitter
  ) {}

  async execute(order: Order): Promise<Order> {
    // Validate location exists
    const location = await this.locationRepository.findById({
      locationId: order.locationId,
      includeCatalog: false,
    });
    if (!location) {
      throw new LocationNotFoundError(order.locationId);
    }

    // Validate customer exists
    const customer = await this.customerRepository.get(order.customerId);
    if (!customer) {
      throw new CustomerNotFoundError(order.customerId);
    }

    // Create order in database
    const createdOrder = await this.orderRepository.create(order);

    // Sync with Hubrise
    await this.hubriseClient.createOrder(createdOrder);

    // Create Stripe checkout session
    await this.stripeClient.createCheckoutSession(createdOrder);

    // Emit order created event
    await this.eventEmitter.emit("order.created", {
      orderId: createdOrder.id,
      locationId: createdOrder.locationId,
      customerId: createdOrder.customerId,
      totalAmount: createdOrder.totalAmount,
    });

    return createdOrder;
  }
}

Key Principles

1

Input and Output

• Input: Use cases accept domain entities as input, not DTOs• Output: Use cases return domain entities or specific result objects, not DTOs• Transformation: Any transformation between DTOs and domain entities should happen in controllers or dedicated mappersThis approach makes use cases more reusable and prevents them from being coupled to specific API contracts.
2

Error Handling

• Use cases should throw domain-specific errors that represent business rule violations• These errors should be descriptive and provide enough context to understand the issue• The presentation layer is responsible for translating these errors into appropriate HTTP responses
3

Dependency Inversion

• Use cases should depend on interfaces, not concrete implementations• This makes them easier to test and less coupled to specific infrastructure choices• All dependencies should be explicitly declared in the constructor
4

No Side Effects

• Use cases should avoid side effects that aren’t part of their core responsibility• Any side effects (like sending emails or notifications) should be handled through events or dedicated services• This keeps use cases focused and easier to test
5

Composability

• Complex workflows can be composed of multiple use cases.• Smaller, focused use cases are easier to test and maintain.• Use cases can call other use cases when needed.
export class PlaceOrderUseCase {
  constructor(
    private readonly createOrderUseCase: CreateOrderUseCase,
    private readonly notifyCustomerUseCase: NotifyCustomerUseCase
  ) {}

  async execute(order: Order): Promise<Order> {
    // Create the order
    const createdOrder = await this.createOrderUseCase.execute(order);

    // Notify the customer
    await this.notifyCustomerUseCase.execute({
      customerId: createdOrder.customerId,
      orderId: createdOrder.id,
    });

    return createdOrder;
  }
}

Best Practices

1

Keep Use Cases Small and Focused

• Each use case should do one thing well
2

Validate Inputs Early

• Check that inputs are valid before performing operations
3

Use Descriptive Names

• Names should clearly indicate what the use case does
4

Document Public APIs

• Document the inputs, outputs, and possible errors of each use case
5

Write Thorough Tests

• Test happy paths, error cases, and edge cases
6

Don't Mix Concerns

• Keep business logic separate from infrastructure concerns
7

Domain First

• Let domain rules guide the implementation, not infrastructure or frameworks

Organization

Use cases are organized by domain entities or functional areas:
src/
  application/
    use-cases/
      order/
        create-order.usecase.ts
        update-order.usecase.ts
        cancel-order.usecase.ts
      customer/
        register-customer.usecase.ts
        update-customer.usecase.ts
      payment/
        process-payment.usecase.ts
        refund-payment.usecase.ts
This organization makes it easier to find related use cases and understand the system’s capabilities.

Testing

Use cases are tested through unit tests that mock all dependencies. We’ll determine later if we want to do integration tests on use cases.
describe("CreateOrderUseCase", () => {
  let useCase: CreateOrderUseCase;
  let mockOrderRepository: MockOrderRepository;
  let mockLocationRepository: MockLocationRepository;
  let mockCustomerRepository: MockCustomerRepository;
  let mockHubriseClient: MockHubriseClient;
  let mockStripeClient: MockStripeClient;
  let mockEventEmitter: MockEventEmitter;

  beforeEach(() => {
    mockOrderRepository = new MockOrderRepository();
    mockLocationRepository = new MockLocationRepository();
    mockCustomerRepository = new MockCustomerRepository();
    mockHubriseClient = new MockHubriseClient();
    mockStripeClient = new MockStripeClient();
    mockEventEmitter = new MockEventEmitter();

    useCase = new CreateOrderUseCase(
      mockOrderRepository,
      mockLocationRepository,
      mockCustomerRepository,
      mockHubriseClient,
      mockStripeClient,
      mockEventEmitter
    );
  });

  it("should create order successfully", async () => {
    // Arrange
    const order = OrderFactory.createValid();
    mockLocationRepository.findById.mockResolvedValue(
      LocationFactory.createValid()
    );
    mockCustomerRepository.get.mockResolvedValue(CustomerFactory.createValid());
    mockOrderRepository.create.mockResolvedValue(order);

    // Act
    const result = await useCase.execute(order);

    // Assert
    expect(result).toEqual(order);
    expect(mockOrderRepository.create).toHaveBeenCalledWith(order);
    expect(mockHubriseClient.createOrder).toHaveBeenCalledWith(order);
    expect(mockStripeClient.createCheckoutSession).toHaveBeenCalledWith(order);
    expect(mockEventEmitter.emit).toHaveBeenCalledWith(
      "order.created",
      expect.any(Object)
    );
  });
});