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.tsCAITS 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
- Make the folder
src/backend/<context>/application/command/<use-case>/, orquery/<use-case>/for a use case that only reads. - In
<use-case>.test.ts, write the application tests first (see Test each layer). Keepnpm run tddrunning: it runs them at each save. - In
<use-case>.dto.ts, write the Input DTO and the Output DTO. The Input DTO extendsInput<Output>. - In
<use-case>.handler.ts, write the handler: one class withexecute(input), which givesnew <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).
- 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
- In
src/backend/<context>/presentation/<use-case>-http/, write the test of the controller first, withpresentationScenario(see Test each layer). - Write the controller,
<use-case>-http.controller.ts. It extendsController<Input, Output>:toDto(request)turns the request into the Input DTO. When the request is not valid, it throwsInvalidInputError: 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, withproblem(…). It gives the other errors tosuper: 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.
- 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.
- 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:MessageAdditionRequestedin the contextmessagesis"messages.addition-requested". - Add each event to the union of the context,
<Context>Event.ts.
- Each event has
- Make the folder
application/command/<use-case>/orapplication/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 extendsInput<void>. - In
<use-case>.handler.ts, write the handler. It publishes events, and gives nothing back.
- In
- 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.
- in
- 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;
}