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 frontendA 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
- Make the folder
src/backend/<context>/. - Copy
package.jsonfrom another context. It gives the shortcut#context/to the files of the context:
src/backend/<context>/package.json
{
"type": "module",
"imports": {
"#context/*": "./*"
}
}- Add
di.ts, withexport default [ … ]. The list can be empty. Addtest.di.tswhen a port needs a fake adapter in the tests. - 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:
- The events of the context, one class for each in
application/event/, and their union inapplication/event/<Context>Event.ts: see A use case of the frontend. - The store,
presentation/store/<context>Store.ts. It exports the type of its state, and its default export is aStoreDefinition<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;- The type of the frontend,
presentation/<Context>Frontend.ts. It adds the context toFrontends. 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 }
}- The views, in
presentation/view/<view>/. Each view takes the frontend of its own context withfrontendOf($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.
- 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.
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.
- In the context that publishes, write the event in
domain/event/<Name>.ts. It extendsDomainEvent:
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();
}
}- The handler takes an
EventPublisherof 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);
}
}- 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)])
];- 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.