| name | DCI |
|---|---|
| description | Instructions for writing code with the DCI paradigm (Data, Context and Interaction) |
| applyTo | **/*.ts |
DCI: Data, Context and Interaction: DCI is a programming paradigm that separates what the system is (domain knowledge/data models) from what the system does (behavior/functionality), bridging human mental models and code.
- DCI code is organized around three projections:
- Domain objects, with simple properties and methods that only regards its own data.
- Pure data structures that represent the state of the system.
- Classes or types that do NOT contain interaction logic relevant to the current use case.
- Encapsulates a use case based on a mental model.
- Orchestrates interactions between Data objects by assigning them Roles at runtime.
- The (public) properties of these Data objects form the Role Contracts, a partial interface for accessing the role-playing object by its Role.
- A Context encapsulates one complete use case or user story, with all variations expressed in the Context.
- Specifies how objects collaborate inside a Context - via RoleMethods.
- RoleMethods define the behavior of objects playing specific Roles.
- IMPORTANT: ONLY the Role's own RoleMethods can access its Role Contract (the underlying object properties) directly. There CAN NOT be any access to the Role Contract from outside the RoleMethods of that Role, not even from other RoleMethods of other Roles. The only way to access the Role Contract from other Roles is through RoleMethod calls.
- Internal (private) RoleMethods are callable only by RoleMethods in the same Role.
- Interactions should favor "ask, don't tell" (objects request services, not micromanage).
- The starting point for a Role interaction (a flow of messages through the Context) is called a System Operation.
- In a true DCI runtime, RoleMethods are attached dynamically to the objects playing the Roles, and only exist during Context execution, but this is language- and implementation-specific.
- DCI code should map closely to how users think about the domain.
- The RoleMethods should express what the user wants the role-playing objects to do, based on their properties.
- Role = An identifier for an object in a Context; not a reusable type.
- Objects can play a Role if they fulfill the Role's contract (literal type).
- Roles are not wrappers; object identity MUST be preserved.
- Real objects, not proxies or wrappers, play Roles to maintain their identity.
- Domain knowledge (Data) evolves slowly; use case logic (Context/Interaction) changes rapidly.
- Keep these separate for maintainability.
- Gather use case logic in one place (the Context).
- Use comments and types to clarify contracts and intent.
- DCI describes system behavior at runtime, not just compile-time structure.
- DCI supports practices like iterative development, clear mental models, and adaptation to change.
- DCI is best suited for use cases where there are two or more interacting actors.
- For simple operations like CRUD, or purely functional data transformation, do not use DCI.
- If a use case tends to contain only one Role or be specific enough not to express a genericity of its Role interfaces, do not use DCI.
- The Context is the script; objects are actors; Roles are character parts.
- Objects (actors) can play different Roles in different Contexts (scenes).
- Instead of modeling trains or stations individually, DCI models their patterns of interaction (e.g., station visits).
- When producing different products, the factory (Context) assigns machines (objects) to different Roles based on the product being made.
- The product is passed between the machines (through Role Method arguments), each using its Role Contract to modify or use it, until the goal of the Context has been achieved.
- Extending the Context (adding variations to the use case) should be like rewiring cables (RoleMethod calls), not changing domain objects. The simple data playing the Context Roles should be able to play a part in many different scenarios through their interfaces.
- Start with the Use Case
- What does the user want to achieve?
- Define this as a Context.
- If a mental model or use case is supplied, use it as a foundation for the Roles and RoleMethods.
- Do NOT name the context "SubmitContext" or similar, but rather after the use case (e.g.,
SubmitForm,LibraryMachine).
- Identify Roles
- What objects collaborate for this use case?
- Roles should be played by objects, not primitive types.
- Primitive types passed to the Context, like configuration options, can be expressed as a settings object, played by a
Contextrole. - Additional Context state, usually transient, can also be added as properties to the
Contextrole if simple, otherwise a separate Role can be created for it, usually when a Context needs to "construct" an object throughout its Interaction, like aResponseto a HTTP request. - Name the Roles meaningfully (e.g.,
SourceAccount,Messages). Ensure they are relevant to the use case. - DON'T add Roles that are not relevant to the use case/mental model, or just for technical reasons (e.g., a
DatabaseRole for database access,ResponseComposerfor constructing a HTTP response, or roles that act like software design patterns). Instead, consider whether the technical dependency can be abstracted behind a Role Contract of an existing Role, or if it is truly needed as a separate Role.
- Define Role Contracts
- What properties/methods must an object have for its Role, for the Context goal to be fulfilled?
- Define clear, minimal contracts that specify the interface needed for each Role.
- Use the language's type system to express these contracts explicitly.
- Implement RoleMethods
- Write interaction logic inside the Context.
- Group RoleMethods by Role for clarity.
- RoleMethods should be kept together. No mixing of RoleMethods or other instructions between RoleMethods belonging to the same role.
- DON'T add RoleMethods without Roles, they are just helper functions in disguise. RoleMethods MUST have a corresponding Role identifier with a Contract, so they further the Context goal ("doing their part" in the use case) and are not just utility functions, which can exist on a Role but usually as private RoleMethods.
- Most of the time, RoleMethods should "chain" together the Interaction in progress, meaning that at the end of a RoleMethod, another RoleMethod of a Role is called, passing on relevant data as arguments, further fulfilling the purpose of the Context according to the mental model.
- The "chaining" avoids the dependency on return values and makes it easier to "rewire" the context later if requirements change, or new functionality is added.
- If a RoleMethod is called only once in the Context, it is usually better to inline its logic into the caller RoleMethod, to avoid unnecessary indirection. But if it is called multiple times, or if it is a distinct step in the use case that can be clearly named, it can be a separate RoleMethod (if it can be connected to a relevant Role).
- Focus on Interaction
- RoleMethods should coordinate with ("ask") other Roles, not dictate ("tell").
- When data is acquired or created within a RoleMethod, for example through a Role Contract method call, if needed by other Roles it should be passed to other RoleMethods, expressing the interaction and collaboration of the Roles - true object-orientation.
- Return values should be avoided if possible (think message-passing that ultimately modifies state), but is not prohibited, for example an occasional boolean check. Readability is the goal, not enforcing rules that complicates the code.
- Keep Data Pure
- Domain objects (classes/types) must NOT contain Context-specific logic.
- Preserve Object Identity
- Role wrappers can lead to subtle bugs with strict equality checks, so NEVER wrap objects for Role assignment - always use direct references.
- Role-binding
- All Roles must be bound (assigned) either during the Context initialization, or in a single
rebindfunction that reassigns all Roles. - If one or more Roles must change during the Context execution, prefer reinstantiating the Context again, or use the
rebindfunction to avoid recursion for example. - Roles can be bound to null, but is unusual and a good reason must exist for that.
- Nested Contexts
- If a RoleMethod's logic represents a reusable, distinct use case, consider implementing it as a separate Context.
- This keeps Contexts focused and manageable.
- Calling such a Context within another is called "nesting" Contexts.
- Follow the rules about when not to use DCI to determine whether to use nested Contexts.
- Documentation
- Document Contexts clearly with their purpose and use case.
- Clarify Role Contracts with appropriate type annotations or comments.
- A Context is a function annotated with
@DCI-context. If that doesn't exist, do not apply DCI. - Contexts and their RoleMethods can be async functions when needed.
- Use literal TypeScript types as Role Contracts, so the code can be understood without deeper type knowledge. Example:
const Form: { action: string } = event.target; - EXCEPTION: If the types are well-known, like the JavaScript Web APIs, you can reference them directly (e.g.,
Page,HTMLElement). Example:const Page: Page = await Browser.newPage(); - If an object is passed to the Context function, a Role can be defined from it directly in the function parameter, which is the ONLY case RoleMethods should exist in the Context without their Role defined immediately before them.
- In TypeScript, RoleMethods are functions within the Context scope, named
Role_method(). - Example:
Speaker_proclaim(),World_note() - Internal (private) RoleMethods, callable only by RoleMethods in the same Role, use a double underscore:
Role__method().
- Use
//#region RoleName Role /////and//#endregioncomments to group RoleMethods by Role. - This enables easy folding/unfolding of Roles in the editor.
- Use
@DCI-contextJSDoc tag to mark Context functions. - Clarify Role Contracts with explicit TypeScript types inline.
- Prefer inline literal types over separate type declarations for Role Contracts.
- Notable as a "one off" operation, nothing is returned from the Context function.
- Also demonstrates the basics of Context error handling by using a single try/catch around the System Operation part, to avoid errors leaking outside the RoleMethods.
/**
* Submit a form and show error messages from the response.
* @DCI-context
*/
async function SubmitForm(e: SubmitEvent) {
if (!(e.target instanceof HTMLFormElement)) throw new Error("No form found.");
//#region Form Role ////////////////////
const Form: { action: string } = e.target;
async function Form_submit() {
// Role contract: Form.action
const response = await fetch(Form.action, {
method: "POST",
body: new FormData(Form as HTMLFormElement),
});
const data = await response.json();
for (const error of data.errors ?? []) Messages_show(error); // Role interaction
}
//#endregion
//#region Messages Role ////////////////////
const Messages: Iterable<{
dataset: DOMStringMap;
style: CSSStyleDeclaration;
}> = e.target.querySelectorAll<HTMLElement>("[data-form-message]");
async function Messages_hide() {
Messages__set("none");
await Form_submit(); // Role interaction
}
function Messages_show(name: string) {
Messages__set("unset", name);
}
function Messages__set(display: string, name = "") {
for (const msg of Messages) {
if (name && msg.dataset.formMessage != name) continue;
msg.style.display = display;
}
}
//#endregion
try {
console.log("Submit");
e.preventDefault();
await Messages_hide(); // System operation
console.log("Done");
} catch (e) {
console.error(e);
}
}- Another "one off" operation, where ultimately the Request is modified to have valid or invalid session data.
- The Roles are defined in the Context arguments, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context.
- The System Operation (initial RoleMethod call) is started right away, as all Roles are defined in the Context arguments.
/**
* Sets locals.user and locals.session on success, otherwise null.
* @DCI-context
*/
export async function ValidateSession(
Request: RequestEvent,
Session = db,
INVALIDATE = false
): Promise<void> {
await Request_getTokenFromCookie();
//#region Request //////////////////////////////
async function Request_getTokenFromCookie() {
const token = Request.cookies.get(COOKIE_NAME);
if (!token) Request_clearSession();
else await Session_findByToken(token);
}
function Request_setSession(token: string, session: Session, user: User) {
Object.freeze(user);
Object.freeze(session);
Request.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
expires: session.expiresAt,
path: "/",
});
Request.locals.user = user;
Request.locals.session = session;
}
function Request_clearSession(): void {
Request.cookies.set(COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
maxAge: 0,
path: "/",
});
Request.locals.user = undefined;
Request.locals.session = undefined;
}
//#region Session ////////////////////////////////////////
async function Session_findByToken(token: string) {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token))
);
const [result] = await Session.select({
user: userTable,
session: sessionTable,
})
.from(sessionTable)
.innerJoin(userTable, eq(sessionTable.userId, userTable.id))
.where(eq(sessionTable.id, sessionId))
.limit(1);
if (!result) Request_clearSession();
else await Session_checkExpiryDate(token, result.session, result.user);
}
async function Session_checkExpiryDate(
token: string,
session: Session,
user: User
) {
if (INVALIDATE || Date.now() >= session.expiresAt.getTime()) {
await Session.delete(sessionTable).where(eq(sessionTable.id, session.id));
Request_clearSession();
} else {
await Session_refreshExpiryDate(token, session, user);
}
}
async function Session_refreshExpiryDate(
token: string,
session: Session,
user: User
) {
if (
Date.now() >=
session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * (EXPIRY_DAYS / 2)
) {
session.expiresAt = new Date(
Date.now() + 1000 * 60 * 60 * 24 * EXPIRY_DAYS
);
await Session
.update(sessionTable)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessionTable.id, session.id));
}
Request_setSession(token, session, user);
}
}- Notable as it returns an object from the Context, similar to a class with public methods.
- The
ScreenandPrinterRoles are defined in the Context arguments, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context, as the other Roles are.
import { Display, type ScreenState } from "$lib/assets/screen/screenStates";
import { title } from "$lib/data/libraryItem";
import { cards, library, loans } from "$lib/library";
import { hash } from "$lib/utils";
import { BorrowItem } from "./borrowItem";
/**
* A book borrowing machine at a public library.
* @DCI-context
*/
export function LibraryMachine(
Screen: {
display: (state: ScreenState) => void;
currentState: () => ScreenState;
},
Printer: {
print: (line: string) => void;
}
) {
//#region Borrower /////
let Borrower: {
"@id": string;
"@type": "Person";
items: { id: string; title: string; expires: Date }[];
};
function Borrower_isLoggedIn() {
// A getter is ok if it is descriptive beyond "get" and returns a boolean
return !!Borrower["@id"];
}
function Borrower_login(user: Pick<typeof Borrower, "@id" | "@type">) {
rebind(user["@id"]);
Screen_displayItems(Borrower.items);
}
/**
* @param forced Whether the logout was forced by the user (e.g. card removed)
*/
function Borrower_logout(forced: boolean, printItems: boolean) {
// Need to print before rebinding, as it will clear the items
if (printItems) Printer_printReceipt(Borrower.items);
if (Borrower_isLoggedIn()) rebind(undefined);
Screen_displayThankYou(forced);
}
function Borrower_borrowItem(itemId: string | undefined) {
// TODO: Built-in security (assertions) for required login
if (!Borrower_isLoggedIn() || !itemId) return;
if (Borrower.items.find((item) => item.id === itemId)) return;
// Call nested context
const loan = BorrowItem(library, Borrower, { "@id": itemId }, loans);
// TODO: Error handling (logging) for expected errors
if (loan instanceof Error) return Screen_displayError(loan);
Borrower.items.push({
id: loan.object["@id"],
title: title(loan.object),
expires: loan.endTime,
});
Screen_displayItems(Borrower.items);
}
//#endregion
//#region CardReader /////
const CardReader: { currentId: string; attempts: number } = {
currentId: "",
attempts: 0,
};
function CardReader_cardScanned(id: string | undefined) {
if (CardReader.currentId == id) return;
if (!id) {
// Card removed or missing
if (CardReader.currentId) Borrower_logout(true, false);
} else {
// Card scanned
if (!Borrower_isLoggedIn()) {
// New card
Screen_displayEnterPIN(0);
}
}
CardReader.currentId = id ?? "";
}
function CardReader_resetAttempts() {
CardReader.attempts = 0;
}
function CardReader_validatePIN(pin: string[]) {
Library_validateCard(CardReader.currentId, pin);
}
function CardReader_PINfailed() {
// TODO: Force remove card after 3 failed attempts
Screen_displayEnterPIN(++CardReader.attempts);
}
//#endregion
//#region Library /////
const Library = {
cards,
};
function Library_validateCard(cardId: string, pin: string[]) {
const card = Library.cards.find((card) => card["@id"] === cardId);
if (card && card.identifier === hash(pin.join(""))) {
Borrower_login(card._owner);
} else {
CardReader_PINfailed();
}
}
//#endregion
//#region Screen /////
function Screen_displayWelcome() {
Screen.display({ display: Display.Welcome });
}
function Screen_displayEnterPIN(attempts: number) {
Screen.display({ display: Display.EnterPIN, attempts });
}
function Screen_displayItems(items: { title: string; expires: Date }[]) {
Screen.display({ display: Display.Items, items });
}
function Screen_displayThankYou(forced: boolean) {
if (forced && Screen.currentState().display === Display.ThankYou) {
Screen_displayWelcome();
} else {
Screen.display({ display: Display.ThankYou });
if (forced) Screen__displayNext({ display: Display.Welcome });
}
}
function Screen_displayError(error: Error) {
// Log out user
rebind(undefined);
Screen.display({ display: Display.Error, error });
Screen__displayNext({ display: Display.Welcome }, 10000);
}
function Screen__displayNext(nextState: ScreenState, delay = 5000) {
const currentState = Screen.currentState();
setTimeout(() => {
if (currentState === Screen.currentState()) Screen.display(nextState);
}, delay);
}
//#endregion
//#region Printer /////
async function Printer_printReceipt(
items: { title: string; expires: Date }[]
) {
if (items.length) {
await Printer__printLine(new Date().toISOString().slice(0, 10));
await Printer__printLine("");
for (const item of items) {
await Printer__printLine(item.title);
await Printer__printLine(
"Return on " + item.expires.toISOString().slice(0, 10)
);
await Printer__printLine("");
}
}
}
async function Printer__printLine(line: string) {
Printer.print(line);
await new Promise((resolve) => setTimeout(resolve, 100));
}
//#endregion
/**
* Reset the Context state, rebind to a new user or undefined (not logged in).
*/
function rebind(userId: string | undefined) {
Borrower = { "@id": userId ?? "", "@type": "Person", items: [] };
CardReader_resetAttempts();
}
{
// Context start
rebind(undefined);
Screen_displayWelcome();
return {
cardScanned(id: string | undefined) {
CardReader_cardScanned(id);
},
itemScanned(id: string | undefined) {
Borrower_borrowItem(id);
},
pinEntered(pin: string[]) {
CardReader_validatePIN(pin);
},
finish(printReceipt: boolean) {
Borrower_logout(false, printReceipt);
},
};
}
}