CAITSGet started

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.

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

The layers of a context: the domain at the center, the application around it, then the presentation and the infra. Every dependency points to the center.Domainthe businessApplicationuse casesPresentationInfra

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.

LayerHoldsMay import
DomainEntities, value objects, errors, domain events, portsThe domain, and Result, Token, DomainEvent of the kernel
ApplicationOne folder per use case: its DTOs, its handler, its testThe domain
PresentationControllers in the backend; views and a store in the frontendThe application and the domain
InfraThe adapters of the portsThe domain (and the application, in the frontend)
di.tsThe bindings of the ports to their adaptersThe 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.

  1. MessageBoard.markoFrontend presentation

    The view publishes MessageAdditionRequested.

  2. messagesStore.tsFrontend presentation

    The store turns the event into an AddMessageCommand.

  3. add-message.handler.tsFrontend application

    The handler calls the MessagesGateway port.

  4. http-messages-gateway.tsFrontend infra

    The adapter posts the text to /api/messages.

  5. routes/_backend/api/messagesRoute

    The route gives the request to the controller.

  6. add-message-http.controller.tsBackend presentation

    The controller sends an AddMessageCommand.

  7. add-message.handler.tsBackend application

    The handler asks the domain, then the MessageRepository port.

  8. Message.tsBackend domain

    The entity refuses an empty text, or one that is too long.

  9. file-message-repository.tsBackend infra

    The adapter keeps the message in a JSON file.

Follow the whole path, drawn step by step, on each side 

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.

TestWhat it checks
ApplicationThe output or the error for a DTO, and what the repository keeps. In the frontend: the events that each handler publishes.
PresentationThe 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.
InfraEach adapter runs the contract of its port.
IntegrationThe backend on the bindings of production.
End-to-endThe pages in a real browser, on the production build.
MutationThat 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