CAITSGet started

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 check

Then 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 tdd

Write 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));
  });
});
  • app is a backend for this test only. app.get(MessageRepository) gives the repository of the tests: the in-memory adapter that test.di.ts binds. The test puts two messages in it.
  • applicationScenario gives 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.ts

Write 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 CountMessagesQuery

Write 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:architecture

npm 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 check

npm 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

  1. You wrote the test of the use case, then its DTOs, then its handler, in its own folder of the application layer.
  2. The handler used a port of the domain, and never an adapter: the architecture rules checked it.
  3. A controller turned the use case into an HTTP answer, and a route of one line led to it.
  4. npm run check proved 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.

Next pageAdd a use case A command or a query, in the backend or in the frontend, and its HTTP endpoint.