Add a port
A port is an interface of the domain: what the business needs from the outside, such as keeping messages or calling an API. An adapter implements it, in the infra layer. This page shows how to add a port, its contract, and its adapters.
Why a port
The application never knows where the data goes. A handler of the example takes a MessageRepository: in production, di.ts gives the adapter that keeps the messages in a file; in the tests, test.di.ts gives an adapter that keeps them in memory. Another adapter, such as a database, changes nothing in the domain and the application. This is the hexagonal architecture, also called ports and adapters.
Each port has a contract: the tests that each of its adapters must pass. So the fake adapter of the tests behaves like the real one, and the application tests can trust it.
The files of a port
src/backend/messages/
├── domain/port/message-repository/
│ ├── MessageRepository.ts the port, and its token
│ └── MessageRepository.contract.ts the tests that each adapter passes
├── infra/message-repository/
│ ├── file-message-repository.ts the adapter of production
│ ├── file-message-repository.test.ts runs the contract on it
│ ├── in-memory-message-repository.ts the adapter of the tests
│ └── in-memory-message-repository.test.ts runs the contract on it
├── di.ts binds the adapter of production
└── test.di.ts binds the adapter of the testsThe steps
- In
src/<side>/<context>/domain/port/<port>/<Port>.ts, write the interface, and its token with the same name. Export nothing else.<port>is the name of the port in kebab case:message-repository.- The methods give entities or value objects of the domain, not plain data.
- Next to the port, in
<Port>.contract.ts, write the contract: a functiondescribe<Port>Contract(name, create). - Write each adapter in
infra/<port>/<adapter>-<port>.ts. It implements the port.- An HTTP gateway reads the JSON of the answer, and makes the objects of the domain from it.
- In the frontend, an adapter that calls the API takes a
Fetcher: see An adapter that calls the API.
- Give each adapter a test,
<adapter>-<port>.test.ts, that runs the contract. It can add tests for what only this adapter does. - Bind the adapter of production in
di.ts:bind(<Port>).to(<Adapter>).- A parameter that has no token, such as a file name, gets its value with
.with({ fileName: "…" }). - An adapter that keeps files takes
DataDirectory, the folder of the files. The example binds it in thedi.tsofmessages. When a second context keeps files, move this binding tosrc/backend/shared/di.ts: else the tests fail withNothing is bound to … → DataDirectory. - The mutation tests change the values of
.with({ … })too. An integration test kills these mutants: it checks that the file is where the binding says.
- A parameter that has no token, such as a file name, gets its value with
- Bind the adapter of the tests in
test.di.ts: it replaces the binding ofdi.tsin the tests.
The architecture rules check each step: a port without a contract, a contract that no adapter runs, or an adapter in a folder that is not named after its port, fails npm run lint:architecture.
The port
src/backend/messages/domain/port/message-repository/MessageRepository.ts
import { Token } from "@ingenioz-it/caits/Token";
import type { Message } from "#context/domain/entity/Message.js";
/** The messages that the app keeps. */
export interface MessageRepository {
/** An id that no message has yet. */
nextId(): Promise<string>;
/** The messages, in the order they were added. */
list(): Promise<Message[]>;
find(id: string): Promise<Message | undefined>;
/** Adds the message, or replaces the message that has its id. */
save(message: Message): Promise<void>;
/** Removes the message that has this id. An id that no message has changes nothing. */
remove(id: string): Promise<void>;
}
export const MessageRepository = new Token<MessageRepository>();The token lets the container find the adapter of the port: a handler names the type MessageRepository, and CAITS gives the adapter that di.ts binds to the token.
The contract
The contract gets the name of the adapter, and a function that makes a new one for each test:
src/backend/messages/domain/port/message-repository/MessageRepository.contract.ts
import { describe, expect, test } from "@ingenioz-it/caits/testing/backend/infra";
import { Message } from "#context/domain/entity/Message.js";
import type { MessageRepository } from "./MessageRepository.js";
const contentOf = async (repository: MessageRepository) => (await repository.list()).map(({ id, text }) => [id, text]);
/** What every adapter of MessageRepository does. The test of each adapter runs it. */
export function describeMessageRepositoryContract(name: string, create: () => Promise<MessageRepository>) {
describe(`MessageRepository contract: ${name}`, () => {
test("starts with no message", async () => {
expect(await (await create()).list()).toEqual([]);
});
test("finds a message by its id", async () => {
const repository = await create();
await repository.save(Message.create("1", "first"));
await repository.save(Message.create("2", "second"));
expect(await repository.find("2")).toStrictEqual(Message.create("2", "second"));
expect(await repository.find("3")).toBeUndefined();
});
// … the other tests: the ids, the order, the replacement, the removal
test("saves that run at the same time keep all their messages, in the order of the calls", async () => {
const repository = await create();
const ids = Array.from({ length: 20 }, (_, index) => String(index));
await Promise.all(ids.map(id => repository.save(Message.create(id, `message ${id}`))));
expect(await contentOf(repository)).toEqual(ids.map(id => [id, `message ${id}`]));
});
// … the removals that run at the same time
});
}The contract also tells what an adapter can easily miss. Removing an id that no message has changes nothing, and is not an error. Two changes that run at the same time keep both: the file adapter reads the file, changes it, then writes it, so it runs its changes one after the other.
The adapters and their tests
The test of each adapter runs the contract on it. The file adapter gets a new temporary folder for each test:
src/backend/messages/infra/message-repository/file-message-repository.test.ts
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, test, useTempDirectories } from "@ingenioz-it/caits/testing/backend/infra";
import { Message } from "#context/domain/entity/Message.js";
import { describeMessageRepositoryContract } from "#context/domain/port/message-repository/MessageRepository.contract.js";
import { FileMessageRepository } from "./file-message-repository.js";
const freshDirectory = useTempDirectories();
describeMessageRepositoryContract("File", async () => new FileMessageRepository(join(await freshDirectory(), "not-made-yet"), "messages.json"));
// … the tests of the file only: a damaged file, a change that fails, two repositories on the same filesrc/backend/messages/infra/message-repository/in-memory-message-repository.test.ts
describeMessageRepositoryContract("InMemory", async () => new InMemoryMessageRepository());
describe("InMemoryMessageRepository", () => {
test("gives the ids message-1, message-2…: the tests can tell them in advance", async () => {
const repository = new InMemoryMessageRepository();
expect([await repository.nextId(), await repository.nextId()]).toEqual(["message-1", "message-2"]);
});
});An adapter that calls the API
In the frontend, an adapter that calls the API of the backend takes a Fetcher (@ingenioz-it/caits/di/Fetcher): a function with the parameters of fetch. Do not bind it: CAITS gives it to each frontend context.
- On the server, the fetcher is the fetch of the request: the page calls the backend in the same process.
- In the browser, the fetcher is the
fetchof the browser. - In the tests of the frontend application (
@ingenioz-it/caits/testing/frontend/application), the fetcher has no network: it refuses each request with the errorA test frontend has no network: replace the dependency that asked for …. Bind a fake adapter of the port intest.di.ts.
src/frontend/messages/infra/messages-gateway/http-messages-gateway.ts
import type { Fetcher } from "@ingenioz-it/caits/di/Fetcher";
import { failureOf } from "@ingenioz-it/caits/http/failureOf";
// … the other imports
export class HttpMessagesGateway implements MessagesGateway {
constructor(private readonly fetcher: Fetcher = (input, init) => fetch(input, init)) {}
// … list, add, edit and remove call send
private async send(path: string, init?: RequestInit): Promise<Response> {
const response = await this.fetcher(path, init);
if (!response.ok) throw await failureOf(response);
return response;
}
}A test can make the adapter itself. Without a fetcher, the adapter uses the global fetch, which the test of the contract replaces. With a fetcher, new HttpMessagesGateway(fetcher), the test checks each request.
Check the error itself, not only that there is one: await expect(gateway.remove("m1")).rejects.toThrow("HTTP 503"). An answer that is not JSON throws an error too, so rejects.toThrow() alone lets the mutant of if (!response.ok) survive.
The bindings
src/backend/messages/di.ts
import { bind } from "@ingenioz-it/caits/di/Container";
import { DataDirectory } from "@ingenioz-it/caits/di/DataDirectory";
import { MessageRepository } from "./domain/port/message-repository/MessageRepository.js";
import { FileMessageRepository } from "./infra/message-repository/file-message-repository.js";
/**
* The dependencies of the context, in production. The messages go in the file tmp/messages.json, or in the folder DATA_DIR when it is
* set and not empty. When a second context keeps files, the binding of DataDirectory moves to src/backend/shared/di.ts.
*/
export default [
bind(MessageRepository).to(FileMessageRepository).with({ fileName: "messages.json" }),
bind(DataDirectory).toFactory(() => process.env.DATA_DIR || "tmp")
];src/backend/messages/test.di.ts
import { bind } from "@ingenioz-it/caits/di/Container";
import { MessageRepository } from "./domain/port/message-repository/MessageRepository.js";
import { InMemoryMessageRepository } from "./infra/message-repository/in-memory-message-repository.js";
/** The dependencies of the tests: they replace those of di.ts. */
export default [
bind(MessageRepository).to(InMemoryMessageRepository)
];A binding is bind(<Token or class>), then .to(<Class>), .toValue(<value>) or .toFactory(() => <value>), and .with({ … }) for the parameters that have no token. di.ts holds only these bindings.