Architecture
Clean Architecture in TypeScript, enforced.
CAITS lays out every app the same way: bounded contexts, four layers, ports with their contracts. Then 52 rules check, at each npm run check, that it stays that way. Here is the whole map.
Two sides, many contexts
Your business lives in bounded contexts: messages, orders, billing. Each context is a folder, on the side of the backend or of the frontend, and holds the same four layers. The kernel finds the contexts by their di.ts: there is nothing to register.
src/frontend
- messages views · store · gateway
- orders views · store · gateway
The contexts don't talk: a page places their views side by side.
src/backend
- messages use cases · domain · adapters
- orders use cases · domain · adapters
The contexts talk through events: the context map turns an event of one into a command of another.
src/routes: the pages place the views, the handlers call the controllers. No business code.
The two sides are two projects that share nothing but the HTTP API. A context never imports another context of its side, and never the routes: the dependencies always point inward.
Four layers, one direction
Inside a context, every dependency points to the domain. The business rules depend on nothing, so you can test them in milliseconds, and keep them when the technology changes.
Only the presentation answers requests and renders Marko. Only the infra talks to the outside world: files, databases, other APIs. The domain and the application are plain TypeScript.
| Layer | Holds | May import |
|---|---|---|
| Domain | Entities, value objects, errors, domain events, ports | The domain, and Result, Token, DomainEvent of the kernel |
| Application | One folder per use case: its DTOs, its handler, its test | The domain |
| Presentation | Controllers in the backend; views and a store in the frontend | The application and the domain |
| Infra | The adapters of the ports | The domain (and the application, in the frontend) |
di.ts | The bindings of the ports to their adapters | The domain and the infra |
Ports and their contracts
This is the hexagonal architecture, also called ports and adapters. The domain declares what it needs as a port: an interface, and a token with the same name. The infra gives it adapters: one for production, one in memory for the tests. di.ts binds each port to its production adapter; test.di.ts binds it to the in-memory adapter or the fake.
domain/port/message-repository/MessageRepository.ts
/** 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>();Next to each port, a contract says what every adapter must do: give a new id each time, list the messages in their order, replace a message with the same id. The test of each adapter runs it. The fake of your tests and the real adapter pass the same contract, so a test that passes in memory tells the truth about production.
infra/message-repository/*.test.ts
// in-memory-message-repository.test.ts: the adapter of the tests
describeMessageRepositoryContract("InMemory", async () => new InMemoryMessageRepository());
// file-message-repository.test.ts: the adapter of production
describeMessageRepositoryContract("File", async () =>
new FileMessageRepository(join(await freshDirectory(), "not-made-yet"), "messages.json"));How a request flows
A new message of the example app, from the click to the file. Each step is one small file, in its layer. The answer goes back the same way.
MessageBoard.markoFrontend presentationThe view publishes MessageAdditionRequested.
messagesStore.tsFrontend presentationThe store turns the event into an AddMessageCommand.
add-message.handler.tsFrontend applicationThe handler calls the MessagesGateway port.
http-messages-gateway.tsFrontend infraThe adapter posts the text to /api/messages.
routes/_backend/api/messagesRouteThe route gives the request to the controller.
add-message-http.controller.tsBackend presentationThe controller sends an AddMessageCommand.
add-message.handler.tsBackend applicationThe handler asks the domain, then the MessageRepository port.
Message.tsBackend domainThe entity refuses an empty text, or one that is too long.
file-message-repository.tsBackend infraThe adapter keeps the message in a JSON file.
Small files, one shape
A use case of the example app, and its test. Every use case has this shape, so you, your teammates and your agent always know where to look. The handler gets its port from the container; the test uses the in-memory adapter, through test.di.ts.
application/command/add-message/add-message.handler.ts
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);
}
}application/command/add-message/add-message.test.ts
const kept = async (app: TestApplication) => (await app.get(MessageRepository).list()).map(({ id, text }) => [id, text]);
describe("add message", () => {
test("keeps the text without the spaces around it, under a new id, and answers with the message", async ({ app }) => {
await applicationScenario(app)
.givenTheDto(new AddMessageCommand(" Hello "))
.expectTheOutputToBe(new AddMessageOutput("message-1", "Hello"));
expect(await kept(app)).toEqual([["message-1", "Hello"]]);
});
// … each message gets an id of its own; a text of the maximum length is accepted
test.for([
["", "A message cannot be empty."],
[" ", "A message cannot be empty."],
["x".repeat(Message.MAX_LENGTH + 1), "A message has at most 280 characters."]
])("the domain refuses %j, and keeps nothing", async ([text, reason], { app }) => {
await applicationScenario(app)
.givenTheDto(new AddMessageCommand(text))
.expectTheErrorToBe(new InvalidMessageError(reason));
expect(await kept(app)).toEqual([]);
});
});A test for each layer
Each layer has its kind of test, and CAITS gives each layer its test helper. The end-to-end tests run Playwright, set up for you. No mocking library: the tests use the in-memory adapters, which pass the same contracts as the real ones.
| Test | What it checks |
|---|---|
| Application | The output or the error for a DTO, and what the repository keeps. In the frontend: the events that each handler publishes. |
| Presentation | The response for a request, and the DTO that the controller sends. In the frontend: the DTO that each user action sends, and the HTML for each state of the store. |
| Infra | Each adapter runs the contract of its port. |
| Integration | The backend on the bindings of production. |
| End-to-end | The pages in a real browser, on the production build. |
| Mutation | That the tests above really check the code: every mutant killed. |
The 52 rules
npm run check checks each rule on src/ and test/, at every run. A rule that breaks names the file, the import when there is one, and the rule. Here they are, as CAITS 0.5 checks them.
Contexts and sides 7
sides-hold-contexts- src/backend holds only the contexts and the context map (context-map.ts and its tests). src/frontend holds only the contexts.
context-content- A context holds only its four layers (domain, application, infra, presentation), its di.ts and its test.di.ts (and its package.json, which gives the shortcut #context).
context-has-di- Each folder of src/backend and src/frontend is a context, and has a di.ts. The kernel finds the contexts by their di.ts.
contexts-isolated- A context does not import another context of its side, except the backend context shared. Backend contexts talk through events and the context map; frontend contexts do not talk: a route places their views side by side.
sides-apart- A frontend context and a backend context do not import each other. They are two projects: they talk only through the HTTP API.
contexts-without-routes- A context does not import the routes or the context map. The routes and the context map use the contexts, not the opposite.
imports-by-shortcut- In src, an import does not go up a folder (../). A context imports its own files with #context/ and the context shared with #shared/; a route imports the contexts with #backend/ and #frontend/. Thus an import does not change when its file moves. A stylesheet loads only the files of its folder: Sass reads # as the start of a fragment, and a View reads the values of the theme as CSS variables.
The direction of the layers 4
layer-direction- In a context, and from a context to shared, each layer imports only the layers that it may use. The domain imports the domain; the application adds the domain; the presentation adds the application; the infra imports the domain (and the application in the frontend); the di.ts imports the domain and the infra.
core-without-node- The domain and the application do not import Node APIs (node:*). Node APIs are technology: they stay in the infra.
domain-uses-kernel-primitives- The domain imports only the files at the root of the kernel (@ingenioz-it/caits/Result, /Token, /DomainEvent). It does not import the buses, the store or the DI of the kernel.
entity-without-port- An entity or a value object does not import a port. It holds the business rules; the application calls the ports.
The domain 4
domain-shape- The domain of a context holds only entities, value objects, errors, domain events and ports. Each kind of file has its folder and its name pattern.
domain-file-exports-one-name- A domain file exports one name only. A port file exports its interface and its token with the same name.
entity-is-class- An entity or a value object is an exported class. The class holds the business rules of its values.
error-extends-error- A domain error is an exported class …Error that extends Error.
Ports, adapters and contracts 8
port-folder-named-after-port- The folder of a port has the name of the port in kebab-case: the port NoteRepository is in port/note-repository/. Its adapters use the same folder name in the infra.
port-has-contract- Each port has a contract test, {Port}.contract.ts, next to it. The contract says what every adapter of the port must do.
contract-next-to-port- A contract test is in domain/port/{port}/, next to the port that it describes.
contract-runs-in-adapter-test- The test of at least one adapter runs each contract. A contract that no test runs checks nothing.
backend-infra-shape- The infra of a backend context holds only adapters: infra/{port}/{implementation}-{port}.ts and their tests.
infra-folder-implements-port- Each folder of infra/ is for a port that the domain declares: infra/{port}/ goes with domain/port/{port}/.
adapter-implements-port- An adapter in infra/{port}/ implements the port of its folder (
implements Port). adapter-test-runs-contract- The test of an adapter runs the contract of its port. Thus all the adapters of a port do the same things.
The application 5
backend-application-shape- The application of a backend context holds only use cases. Each use case has its folder, command/{usecase}/ or query/{usecase}/, with its handler, its DTOs and its test.
handler-answers-output- A backend handler gives its result as an Output DTO: it makes a
new …Output(…). frontend-application-shape- The application of a frontend context holds its use cases, as in the backend, and its events, one file per event in event/.
event-file-exports-its-event- A frontend event file exports one name only: the event, with the name of the file.
event-type-names-context- A frontend event class has
readonly type = "{context}.{event}" as const. The store reads this type: the context, then the name of the event without the context, in kebab-case.
The presentation 8
backend-presentation-shape- The presentation of a backend context holds only controllers, each in its folder {name}/ with its test.
controller-extends-controller- A controller is an exported class …Controller that extends the Controller of the kernel.
frontend-presentation-shape- The presentation of a frontend context holds only its views (one folder per view), its store and the type of its frontend. The pages are in the routes.
zod-in-presentation- Only the presentation imports zod. zod checks the data that comes from HTTP; the other layers get typed values.
marko-in-frontend-presentation- Only the frontend presentation and the routes import Marko. The other layers stay free of the view technology.
dtos-sent-by-store- In the frontend, only the store and the application import the input DTOs. A view publishes events; the store turns them into DTOs and sends them to the message bus.
events-built-with-new- In the frontend, the code makes an event with
new SomeEvent(…), not as an object literal{ type: "…" }. The class gives the correct type and the correct data. views-take-own-frontend- A view takes only the frontend of its own context, with frontendOf. The contexts of the frontend do not share their frontends.
Dependency injection 4
container-built-by-kernel- Only the kernel makes a Container. A context does not build its dependencies: its di.ts gives the bindings, and the kernel builds them.
controllers-built-by-kernel- Only the kernel makes a controller, with its dependencies. The other code asks the kernel for a controller, or sends a request through the router.
di-holds-bindings- A di.ts or a test.di.ts has one export, a list of bindings:
export default [bind(Port).to(Adapter), …]. It builds nothing: the kernel reads the list and builds the dependencies. di-read-by-kernel- No layer imports the di.ts of a context. Only the kernel reads it.
The context map 2
context-map-reads-events-and-dtos- The context map imports from the contexts only their domain events and their input DTOs. It turns an event of a context into the input of another context.
context-map-without-routes- The context map does not import the routes.
The routes 7
routes-folders- src/routes holds only _frontend (the pages) and _backend (the HTTP handlers).
frontend-routes-content- src/routes/_frontend holds the pages (+page.marko), the layouts (+layout.marko), in component/ the components that only these pages use, in style/ the stylesheets (SCSS) of the app, and next to a page or a layout its stylesheet.
backend-routes-content- src/routes/_backend holds only +handler.ts files. The code of a handler lives in a context: the route only gives the request to a controller.
routes-on-their-side- A frontend route imports only the frontend; a backend route imports only the backend.
routes-through-presentation- A route imports only the presentation of the contexts of its side: the views for a page, the controllers for a handler.
routes-without-context-map- A route does not import the context map. The kernel reads it.
routes-take-no-frontend- A route does not call frontendOf. It only places the views; each view takes the frontend of its context.
Production and tests 3
no-test-environment- The production code does not import a test environment (test.di.ts). A test environment replaces dependencies in the tests only.
no-test-support- The production code does not import the test support of the kernel (@ingenioz-it/caits/testing).
nothing-outside-src- The production code in src imports no file outside src, test/ and quality/ included. It imports the kernel by its name, as a package, never by a path.
Make the rules yours
The rules are code, in quality/architecture.mjs, a file of your project. It starts from CAITS's rules: remove one, replace one, or add your own. Your file has the last word, and npx caits update never changes it.
quality/architecture.mjs: remove a rule
import { rules } from "./caits/architecture.mjs";
export default rules.filter(rule => rule.name !== "routes-folders");quality/architecture.mjs: add a rule
import { rules } from "./caits/architecture.mjs";
const noLodashInDomain = {
name: "no-lodash-in-domain",
description: "The domain does not import lodash.",
on: "import",
check: ({ from, specifier }) => from.layer === "domain"
&& specifier.startsWith("lodash") && "the domain does not import lodash"
};
export default [...rules, noLodashInDomain];Write a test for your rule in quality/*.test.mjs: npm test runs it with the others. All the tools that you can tune
See it hold, in your terminal.
The example app follows every rule. Create it, break a rule, and run the check.
npx @ingenioz-it/caits init my-app --example