CAITSGet started

Add a context

A context is a folder that holds one part of the business, such as messages, with its four layers. This page shows how to add a context to the backend or to the frontend, how to share code between the backend contexts, and how to connect two contexts.

When to add a context

Add a context when a part of the business has its own words and its own rules: messages, billing, accounts. A context never imports another context, except the backend context shared: two contexts talk only through events. So you can change one without breaking the other.

The folders of the contexts

src/
├── backend/
│   ├── messages/          a context of the backend
│   ├── billing/           another one
│   ├── shared/            the code that all the backend contexts can use (optional)
│   └── context-map.ts     how the events of one context start the use cases of another (optional)
└── frontend/
    └── messages/          a context of the frontend

A context of the backend and a context of the frontend can have the same name: they are two contexts, which talk only through the HTTP API.

A context of the backend

  1. Make the folder src/backend/<context>/.
  2. Copy package.json from another context. It gives the shortcut #context/ to the files of the context:

src/backend/<context>/package.json

{
  "type": "module",
  "imports": {
    "#context/*": "./*"
  }
}
  1. Add di.ts, with export default [ … ]. The list can be empty. Add test.di.ts when a port needs a fake adapter in the tests.
  2. Add the folders of the layers when you need them: domain/, application/, infra/, presentation/. Then add a use case: see Add a use case.

A context of the frontend

Do steps 1 to 3 above in src/frontend/<context>/, then add:

  1. The events of the context, one class for each in application/event/, and their union in application/event/<Context>Event.ts: see A use case of the frontend.
  2. The store, presentation/store/<context>Store.ts. It exports the type of its state, and its default export is a StoreDefinition<State, Event>: { initialState, reduce, send }.

src/frontend/messages/presentation/store/messagesStore.ts (shortened)

import type { StoreDefinition } from "@ingenioz-it/caits/state/createStore";
import type { Loadable } from "@ingenioz-it/caits/state/Loadable";
import type { MessagesEvent } from "#context/application/event/MessagesEvent.js";
import { ListMessagesQuery } from "#context/application/query/list-messages/list-messages.dto.js";
import type { Message } from "#context/domain/entity/Message.js";

export type MessagesState = { messages: Loadable<Message[]> };

const messagesStore: StoreDefinition<MessagesState, MessagesEvent> = {
  initialState: { messages: { status: "idle" } },
  reduce(state, event) {
    switch (event.type) {
      case "messages.requested": return { ...state, messages: { status: "loading" } };
      case "messages.loaded": return { ...state, messages: { status: "loaded", data: event.messages } };
      // … a case for each other event of MessagesEvent
    }
  },
  send(state, event) {
    if (event.type === "messages.requested") return new ListMessagesQuery();
    return undefined;
  }
};

export default messagesStore;
  1. The type of the frontend, presentation/<Context>Frontend.ts. It adds the context to Frontends. The name must be the name of the folder:

src/frontend/messages/presentation/MessagesFrontend.ts

import type { Frontend } from "@ingenioz-it/caits/state/request";
import type { MessagesEvent } from "#context/application/event/MessagesEvent.js";
import type { MessagesState } from "./store/messagesStore.js";

/** What a View of the context gets: the events it publishes, and the store it reads. */
export type MessagesFrontend = Frontend<MessagesState, MessagesEvent>;

declare module "@ingenioz-it/caits/di/frontend" {
  interface Frontends { messages: MessagesFrontend }
}
  1. The views, in presentation/view/<view>/. Each view takes the frontend of its own context with frontendOf($global, "<context>"): it publishes events on it, and follows its store. A loader asks for the data when the view loads, so that the page holds it in its first HTML, on the server:

src/frontend/messages/presentation/view/message-board/loadMessageBoard.ts

import { settled } from "@ingenioz-it/caits/state/Loadable";
import { request } from "@ingenioz-it/caits/state/request";
import { MessagesRequested } from "#context/application/event/MessagesRequested.js";
import type { MessagesFrontend } from "#context/presentation/MessagesFrontend.js";
import { listOf, type MessageList } from "./presentMessageBoard.js";

/** When the board loads, it asks for the messages. On the server, the page then holds them in its HTML. */
export async function loadMessageBoard(frontend: MessagesFrontend): Promise<MessageList> {
  return listOf(await request(frontend, new MessagesRequested(), state => settled(state.messages)));
}

src/frontend/messages/presentation/view/message-board/MessageBoard.marko (shortened)

import { frontendOf } from "@ingenioz-it/caits/di/frontend";
import { follow } from "@ingenioz-it/caits/state/follow";
import { loadMessageBoard } from "./loadMessageBoard.js";
import { idleMessageBoardViewModel, presentMessageBoard, type MessageBoardViewModel } from "./presentMessageBoard.js";

<let/viewModel=idleMessageBoardViewModel as MessageBoardViewModel>
<lifecycle
  onMount() { return { stop: follow(frontendOf($global, "messages").store, presentMessageBoard, next => { viewModel = next; }) }; }
  onDestroy() { this.stop(); }
/>

<await|initial|=loadMessageBoard(frontendOf($global, "messages"))>
  <const/list=viewModel.list ?? initial>
  // … the HTML of the list
</await>

follow gives the view model again each time the store changes: presentMessageBoard turns the state into what the view shows. The example app has the whole view.

  1. A page places the views: src/routes/_frontend/<page>/+page.marko. A page can place the views of several contexts side by side. It has no test of its own.

Share code between the backend contexts

Code that all the backend contexts use goes in src/backend/shared/: a value object, or a binding that every context needs. A context imports it with #shared/. shared/di.ts gives its bindings to every backend context.

For example, the example binds DataDirectory (the folder of the files) in the di.ts of messages. When a second context keeps files, this binding moves to src/backend/shared/di.ts.

The frontend has no shared context: the contexts of the frontend do not talk to each other. A page places their views side by side.

Connect two contexts

When a context must react to what another one does, the first one publishes an event, and the context map turns it into the DTOs of the other context. The publisher never waits for the reaction.

  1. In the context that publishes, write the event in domain/event/<Name>.ts. It extends DomainEvent:

src/backend/messages/domain/event/MessageAdded.ts

import { DomainEvent } from "@ingenioz-it/caits/DomainEvent";

export class MessageAdded extends DomainEvent {
  constructor(readonly id: string, readonly text: string) {
    super();
  }
}
  1. The handler takes an EventPublisher of its events, and publishes them:

src/backend/messages/application/command/add-message/add-message.handler.ts

import type { EventPublisher } from "@ingenioz-it/caits/messaging/EventPublisher";
import { MessageAdded } from "#context/domain/event/MessageAdded.js";
// … the other imports, as before

export class AddMessageHandler {
  constructor(private readonly messages: MessageRepository, private readonly events: EventPublisher<MessageAdded>) {}

  async execute(command: AddMessageCommand): Promise<AddMessageOutput> {
    const message = Message.create(await this.messages.nextId(), command.text);
    await this.messages.save(message);
    this.events.publish(new MessageAdded(message.id, message.text));
    return new AddMessageOutput(message.id, message.text);
  }
}
  1. In src/backend/context-map.ts, add a line: when this event happens, send these DTOs. Each DTO goes to the context whose handler takes it.

src/backend/context-map.ts

import { when } from "@ingenioz-it/caits/messaging/ContextMap";
import { MessageAdded } from "./messages/domain/event/MessageAdded.js";
import { CountWordsCommand } from "./statistics/application/command/count-words/count-words.dto.js";

export default [
  when(MessageAdded, event => [new CountWordsCommand(event.text)])
];
  1. Test the map next to it, in context-map.test.ts: an event gives the right DTOs, and a whole flow leaves the other context in the right state. See Test each layer.
Next pageTest each layer What each kind of test checks, with the helper of each layer and the mutation tests.