Your first use case
In this tutorial, you add a use case to the example app: the backend counts the messages, and answers the count at GET /api/messages/count. You write each test first, and the harness tells you what is missing at each step. It takes about 20 minutes.
Before you start
Make the example app, and check that it passes (see Installation):
Terminal
npx @ingenioz-it/caits init my-app --example
cd my-app
npm run checkThen start the tests of the files that you change. Keep this terminal open: at each save, it runs the tests again.
Terminal, in my-app
npm run tddWrite the test first
A use case that only reads goes in query/; a use case that changes something goes in command/. Make the folder src/backend/messages/application/query/count-messages/, and write the test of the use case in it:
src/backend/messages/application/query/count-messages/count-messages.test.ts
import { applicationScenario, describe, test } from "@ingenioz-it/caits/testing/backend/application";
import { Message } from "#context/domain/entity/Message.js";
import { MessageRepository } from "#context/domain/port/message-repository/MessageRepository.js";
import { CountMessagesOutput, CountMessagesQuery } from "./count-messages.dto.js";
describe("count messages", () => {
test("answers with the number of messages", async ({ app }) => {
await app.get(MessageRepository).save(Message.create("m1", "first"));
await app.get(MessageRepository).save(Message.create("m2", "second"));
await applicationScenario(app)
.givenTheDto(new CountMessagesQuery())
.expectTheOutputToBe(new CountMessagesOutput(2));
});
});appis a backend for this test only.app.get(MessageRepository)gives the repository of the tests: the in-memory adapter thattest.di.tsbinds. The test puts two messages in it.applicationScenariogives the DTO to the message bus, then compares the answer with the Output DTO that you expect.
The test fails: the DTO does not exist yet.
npm run tdd
Error: Cannot find module './count-messages.dto.js' imported from …/count-messages.test.tsWrite the DTOs
The Input DTO is what the use case receives; the Output DTO is what it answers. The Input DTO extends Input<Output>: the type of its answer.
src/backend/messages/application/query/count-messages/count-messages.dto.ts
import { Input } from "@ingenioz-it/caits/messaging/Input";
export class CountMessagesQuery extends Input<CountMessagesOutput> {}
/** The number of messages that the app keeps. */
export class CountMessagesOutput {
constructor(readonly count: number) {}
}The test runs, and still fails. Nothing answers the query yet:
npm run tdd
Error: No context owns CountMessagesQueryWrite the handler
The handler does the work. Its constructor names the ports that it needs: CAITS gives them. You register nothing: CAITS finds the handler by the name of its file, next to the DTO.
src/backend/messages/application/query/count-messages/count-messages.handler.ts
import type { MessageRepository } from "#context/domain/port/message-repository/MessageRepository.js";
import { CountMessagesOutput, type CountMessagesQuery } from "./count-messages.dto.js";
export class CountMessagesHandler {
constructor(private readonly messages: MessageRepository) {}
async execute(_query: CountMessagesQuery): Promise<CountMessagesOutput> {
return new CountMessagesOutput((await this.messages.list()).length);
}
}The test passes. The handler knows the port MessageRepository, not the file where the messages are: in production, di.ts gives the file adapter; in the tests, test.di.ts gives the in-memory one.
Break a rule, on purpose
See what the harness does when the code goes to the wrong place. In the handler, use the file adapter of the infra layer in place of the port: change the first line, and the type of the parameter of the constructor.
src/backend/messages/application/query/count-messages/count-messages.handler.ts
import type { FileMessageRepository } from "#context/infra/message-repository/file-message-repository.js";
import { CountMessagesOutput, type CountMessagesQuery } from "./count-messages.dto.js";
export class CountMessagesHandler {
constructor(private readonly messages: FileMessageRepository) {}The tests fail: they give the port its in-memory adapter, and nothing gives the handler a file.
npm run tdd
Error: CountMessagesHandler → FileMessageRepository needs a value for its parameter fileName: give it with bind(FileMessageRepository).with({ fileName: … })npm run tdd keeps the first terminal busy. In a second terminal, run the architecture rules alone, which takes less than a second. They name the real problem:
Terminal, in my-app
npm run lint:architecturenpm run lint:architecture
src/backend/messages/application/query/count-messages/count-messages.handler.ts → src/backend/messages/infra/message-repository/file-message-repository.js: application must not depend on infra (layer-direction)
1 architecture violation.The output names the file, the import, and the rule. Put the port back: the tests and the rules pass again.
Answer over HTTP
The backend answers HTTP requests with a controller, in the presentation layer. Write its test first, in a new folder src/backend/messages/presentation/count-messages-http/:
src/backend/messages/presentation/count-messages-http/count-messages-http.test.ts
import { describe, presentationScenario, test } from "@ingenioz-it/caits/testing/backend/presentation";
import { CountMessagesOutput, CountMessagesQuery } from "#context/application/query/count-messages/count-messages.dto.js";
describe("counting the messages over HTTP", () => {
test("asks the application for the count, and answers with it", async ({ app }) => {
await presentationScenario(app)
.givenTheRequest("GET", "/api/messages/count")
.expectItToSend(new CountMessagesQuery())
.givenTheApplicationReplies(new CountMessagesOutput(2))
.expectTheResponseToBe(200, { count: 2 });
});
});The scenario sends the request, checks the DTO that the controller sends, gives the answer of the application, and checks the response. Then write the controller: from the request to the DTO, and from the Output DTO to the response.
src/backend/messages/presentation/count-messages-http/count-messages-http.controller.ts
import { Controller } from "@ingenioz-it/caits/messaging/Controller";
import { CountMessagesQuery, type CountMessagesOutput } from "#context/application/query/count-messages/count-messages.dto.js";
/** GET /api/messages/count. */
export class CountMessagesHttpController extends Controller<CountMessagesQuery, CountMessagesOutput> {
protected toDto(): CountMessagesQuery {
return new CountMessagesQuery();
}
protected toResponse(output: CountMessagesOutput): Response {
return Response.json(output);
}
}The test still fails, with expected 404 to be 200: no route leads to the controller yet. A route only gives the request to its controller. Run comes from Marko Run, the router of the app: the route needs no import of it.
src/routes/_backend/api/messages/count/+handler.ts
import { controller } from "@ingenioz-it/caits/di/backend";
import { CountMessagesHttpController } from "#backend/messages/presentation/count-messages-http/count-messages-http.controller.js";
export const GET = Run.GET(async (ctx): Promise<Response> => controller(CountMessagesHttpController).handle(ctx.request));The test passes. Try it in the app: in the second terminal, start it with npm run dev. Add a message on the page Example (http://localhost:3000/messages), then open http://localhost:3000/api/messages/count. The count is 1, if the app had no message before:
http://localhost:3000/api/messages/count
{"count":1}Run all the checks
Before a commit, run the whole harness. In the second terminal, stop npm run dev with Ctrl+C, then run:
Terminal, in my-app
npm run checknpm run check
6 checks at once: typecheck, lint:code, lint:architecture, test:coverage, test:mutation, test:e2e
✓ lint:architecture 0s
✓ typecheck 4s
✓ lint:code 4s
✓ test:coverage 5s
✓ test:e2e 9s
✓ test:mutation 53s
All 6 checks passed in 53s.Your use case has its tests, 100% coverage, every mutant killed, and the right place in the layers. If a check fails, its output tells you what to fix: see When a check fails.
What you did
- You wrote the test of the use case, then its DTOs, then its handler, in its own folder of the application layer.
- The handler used a port of the domain, and never an adapter: the architecture rules checked it.
- A controller turned the use case into an HTTP answer, and a route of one line led to it.
npm run checkproved the whole change.
Next, show the count on the page: the frontend has the same layers, with a store and a view. Add a use case gives the steps for the backend and for the frontend.