CAITSGet started

Add a use case

A use case is one thing that the app does: add a message, list the messages. This page gives the steps for a use case of the backend, then for a use case of the frontend. For a first time, follow the tutorial: it makes a backend use case, step by step.

Where a use case goes

Each use case has its own folder, in the application layer of its context: command/ for a use case that changes something, query/ for a use case that only reads.

A use case of the backend

src/backend/messages/application/
├── command/add-message/
│   ├── add-message.dto.ts         the Input DTO and the Output DTO
│   ├── add-message.handler.ts     the handler
│   └── add-message.test.ts        the application tests
└── query/list-messages/
    ├── list-messages.dto.ts
    ├── list-messages.handler.ts
    └── list-messages.test.ts

CAITS finds each handler by the name of its file, next to its DTO: you register nothing. A folder holds one use case: one DTO file with one Input DTO, one handler file with one handler, and its test.

A use case of the backend

  1. Make the folder src/backend/<context>/application/command/<use-case>/, or query/<use-case>/ for a use case that only reads.
  2. In <use-case>.test.ts, write the application tests first (see Test each layer). Keep npm run tdd running: it runs them at each save.
  3. In <use-case>.dto.ts, write the Input DTO and the Output DTO. The Input DTO extends Input<Output>.
  4. In <use-case>.handler.ts, write the handler: one class with execute(input), which gives new <Name>Output(…).
    • Its constructor takes the ports that it uses, by their types. CAITS gives them. To make a new port, see Add a port.
    • To publish events, it also takes an EventPublisher (see Add a context).
  5. Run npm run lint:architecture: the rules check the place and the shape of each file.

src/backend/messages/application/command/add-message/add-message.dto.ts

import { Input } from "@ingenioz-it/caits/messaging/Input";

export class AddMessageCommand extends Input<AddMessageOutput> {
  constructor(readonly text: string) { super(); }
}

export class AddMessageOutput {
  constructor(readonly id: string, readonly text: string) {}
}

The handler asks the domain, which refuses what is not valid, then keeps the result through its port:

src/backend/messages/application/command/add-message/add-message.handler.ts

import { Message } from "#context/domain/entity/Message.js";
import type { MessageRepository } from "#context/domain/port/message-repository/MessageRepository.js";
import { AddMessageOutput, type AddMessageCommand } from "./add-message.dto.js";

export class AddMessageHandler {
  constructor(private readonly messages: MessageRepository) {}

  async execute(command: AddMessageCommand): Promise<AddMessageOutput> {
    const message = Message.create(await this.messages.nextId(), command.text);
    await this.messages.save(message);
    return new AddMessageOutput(message.id, message.text);
  }
}

Answer it over HTTP

  1. In src/backend/<context>/presentation/<use-case>-http/, write the test of the controller first, with presentationScenario (see Test each layer).
  2. Write the controller, <use-case>-http.controller.ts. It extends Controller<Input, Output>:
    • toDto(request) turns the request into the Input DTO. When the request is not valid, it throws InvalidInputError: the answer is a 422, and the message bus receives nothing.
    • toResponse(output) turns the Output DTO into the response.
    • toErrorResponse(error, request) turns the errors of the domain into responses, with problem(…). It gives the other errors to super: a body that does not say it is JSON (content-type) is a 415, a body larger than 1 MB a 413, a body that is not valid JSON a 400, and an unknown error a 500, which CAITS reports.
  3. Add the route, src/routes/_backend/api/…/+handler.ts. It only gives the request to the controller:

src/routes/_backend/api/messages/+handler.ts

import { controller } from "@ingenioz-it/caits/di/backend";
import { AddMessageHttpController } from "#backend/messages/presentation/add-message-http/add-message-http.controller.js";

export const POST = Run.POST(async (ctx): Promise<Response> => controller(AddMessageHttpController).handle(ctx.request));

The controller of the example refuses a body without a text, and turns the error of the domain into a 422:

src/backend/messages/presentation/add-message-http/add-message-http.controller.ts

import { Controller, InvalidInputError, problem, readBody } from "@ingenioz-it/caits/messaging/Controller";
import { AddMessageCommand, type AddMessageOutput } from "#context/application/command/add-message/add-message.dto.js";
import { InvalidMessageError } from "#context/domain/error/InvalidMessageError.js";

/** The body that the API reads: `{ "text": "…" }`. */
const isBody = (body: unknown): body is { text: string } => typeof (body as { text?: unknown } | null)?.text === "string";

export class AddMessageHttpController extends Controller<AddMessageCommand, AddMessageOutput> {
  protected async toDto(request: Request): Promise<AddMessageCommand> {
    const body = await readBody(request);
    if (!isBody(body)) throw new InvalidInputError("Invalid body", [{ path: ["text"], message: "The text must be a string." }]);
    return new AddMessageCommand(body.text);
  }

  protected toResponse(output: AddMessageOutput): Response {
    return Response.json(output, { status: 201 });
  }

  protected override toErrorResponse(error: unknown, request: Request): Response | Promise<Response> {
    if (error instanceof InvalidMessageError) return problem(422, "invalid-message", "Invalid message", error.message);
    return super.toErrorResponse(error, request);
  }
}

The errors are application/problem+json responses, with a type such as urn:caits:problem:invalid-message.

A use case of the frontend

In the frontend, a use case does not answer: its handler publishes events, and the store turns them into the state that the views show. The way of an action is drawn in How CAITS works.

  1. Write the events, one file each, in src/frontend/<context>/application/event/<Name>.ts.
    • Each event has readonly type = "<context>.<name>" as const. The name is the name of the class in kebab case, without the name of the context: MessageAdditionRequested in the context messages is "messages.addition-requested".
    • Add each event to the union of the context, <Context>Event.ts.
  2. Make the folder application/command/<use-case>/ or application/query/<use-case>/:
    • In <use-case>.test.ts, write the application tests first: which events the handler publishes for a DTO.
    • In <use-case>.dto.ts, write the DTO. It extends Input<void>.
    • In <use-case>.handler.ts, write the handler. It publishes events, and gives nothing back.
  3. In the store, presentation/store/<context>Store.ts:
    • in reduce, change the state for each new event;
    • in send, give the DTO that an event asks for.
  4. In the view, presentation/view/<view>/, publish the event of the user action. Add a presentation test next to the view.

src/frontend/messages/application/event/MessageAdditionRequested.ts

/** The View publishes it when the user adds a message. */
export class MessageAdditionRequested {
  readonly type = "messages.addition-requested" as const;
  constructor(readonly text: string) {}
}

src/frontend/messages/application/command/add-message/add-message.handler.ts

export class AddMessageHandler {
  constructor(private readonly gateway: MessagesGateway, private readonly events: EventPublisher<MessagesEvent>) {}

  async execute(command: AddMessageCommand): Promise<void> {
    try {
      await this.gateway.add(command.text);
      this.events.publish(new MessagesChanged());
    } catch (error) {
      this.events.publish(new MessagesChangeFailed("addition", (error as Error).message));
    }
  }
}

The store is the only place that sends a DTO: a view never imports one. send reads the state before the event, so it can refuse a second change while the first one is on its way:

src/frontend/messages/presentation/store/messagesStore.ts

send(state, event) {
  if (event.type === "messages.requested" || event.type === "messages.changed") return new ListMessagesQuery();
  if (state.change.status === "pending") return undefined;
  if (event.type === "messages.addition-requested") return new AddMessageCommand(event.text);
  …
  return undefined;
}
Next pageAdd a port An interface of the domain, its contract, and its adapters: a file, a database, an API.