Skip to content

Instantly share code, notes, and snippets.

@ciscoheat
Last active March 1, 2026 17:14
Show Gist options
  • Select an option

  • Save ciscoheat/f76cb9541c9c71022fa8b69aece8afb6 to your computer and use it in GitHub Desktop.

Select an option

Save ciscoheat/f76cb9541c9c71022fa8b69aece8afb6 to your computer and use it in GitHub Desktop.
Agent instructions for DCI
name DCI
description Instructions for writing code with the DCI paradigm (Data, Context and Interaction)
applyTo **/*.ts

Instructions for Writing Code with the DCI Paradigm

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.

1. Core DCI Architecture

  • DCI code is organized around three projections:

Data ("What the system is")

  • 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.

Context ("What the system does")

  • 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.

Interaction ("How the system does it")

  • 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.

2. DCI Principles & Key Concepts

Mental Model Alignment

  • 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.

Roles

  • 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.

Object Identity

  • Real objects, not proxies or wrappers, play Roles to maintain their identity.

Separation of Concerns

  • Domain knowledge (Data) evolves slowly; use case logic (Context/Interaction) changes rapidly.
  • Keep these separate for maintainability.

Readability

  • Gather use case logic in one place (the Context).
  • Use comments and types to clarify contracts and intent.

Runtime Focus

  • DCI describes system behavior at runtime, not just compile-time structure.

Agile Support

  • DCI supports practices like iterative development, clear mental models, and adaptation to change.

When not to use DCI

  • 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.

3. DCI Analogies

Movie Script

  • The Context is the script; objects are actors; Roles are character parts.
  • Objects (actors) can play different Roles in different Contexts (scenes).

Train System

  • Instead of modeling trains or stations individually, DCI models their patterns of interaction (e.g., station visits).

Automated factory

  • 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

  • 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.

4. DCI Code Generation Workflow

  1. 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).
  1. 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 Context role.
  • Additional Context state, usually transient, can also be added as properties to the Context role if simple, otherwise a separate Role can be created for it, usually when a Context needs to "construct" an object throughout its Interaction, like a Response to 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 Database Role for database access, ResponseComposer for 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.
  1. 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.
  1. 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).
  1. 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.
  1. Keep Data Pure
  • Domain objects (classes/types) must NOT contain Context-specific logic.
  1. 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.
  1. Role-binding
  • All Roles must be bound (assigned) either during the Context initialization, or in a single rebind function that reassigns all Roles.
  • If one or more Roles must change during the Context execution, prefer reinstantiating the Context again, or use the rebind function to avoid recursion for example.
  • Roles can be bound to null, but is unusual and a good reason must exist for that.
  1. 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.
  1. Documentation
  • Document Contexts clearly with their purpose and use case.
  • Clarify Role Contracts with appropriate type annotations or comments.

5. TypeScript-Specific Implementation

Context Implementation

  • 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.

Role Contracts

  • 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.

RoleMethod Naming

  • 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().

Role Organization

  • Use //#region RoleName Role ///// and //#endregion comments to group RoleMethods by Role.
  • This enables easy folding/unfolding of Roles in the editor.

Type Annotations

  • Use @DCI-context JSDoc tag to mark Context functions.
  • Clarify Role Contracts with explicit TypeScript types inline.
  • Prefer inline literal types over separate type declarations for Role Contracts.

6. TypeScript DCI Examples

AJAX form submit with dynamic error display

  • 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);
  }
}

Session validation for SvelteKit and Drizzle

  • 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);
  }
}

A book borrowing machine at a public library

  • Notable as it returns an object from the Context, similar to a class with public methods.
  • The Screen and Printer 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, 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);
      },
    };
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment