DCI tutorial for TypeScript: Part 1

DCI tutorial for TypeScript: Part 1

DCI (Data, Context and Interaction) is a programming paradigm that breathes life into the hope that we can someday write maintainable software. This tutorial series will provide both an in-depth explanation of the DCI concepts, as a programmer would like to understand, but also a high-level perspective explaining why we should consider writing code in a different way than what we do today.

What is DCI?

DCI was invented by Trygve Reenskaug, also the inventor of MVC, and has been furthered together with renowned OOP writer James O. Coplien since 2009.

From a high level, DCI strives to achieve the following:

  • Separating what the system is (data) from what it does (function). Data and function have different rates of change so they should be separated, not as it currently is, put in classes together.

  • Create a direct mapping from the user's mental model to code. The computer should think as the user, not the other way around.

  • Make system behavior a first class entity.

  • Great code readability with no surprises at runtime.

To begin our journey, let's start with the Context, since from a programmer's perspective, most of the DCI-related things happen in there.

The DCI Context

A DCI Context is a structure that encapsulates system behavior, in contrast to objects and classes which encapsulate state well, but only capture fragments of the interactions between objects in the system, which makes a system more difficult to reason about as it grows.

One of the goals of DCI is to prevent the spreading of functionality across arbitrary boundaries, be it a class hierarchy, helpers, services, patterns, etc. Many programming constructs abstract away relevant information (like interfaces), or provides irrelevant information (classes), so we are left with either a limited or overwhelming view when trying to understand how the system works at runtime.

The current way of gaining an understanding of runtime system behavior is to use a debugger, or become the debugger by tracing the program in your head, branching and jumping across class hierarchies, events, exceptions, etc. Something is obviously problematic with this hope that the parts of a system will self-organize, yet every torturous visit to the debugger proves otherwise.

Fortunately, a DCI Context not only encapsulates but also describes the runtime behavior of a network of interaction objects in a relevant locus, without hiding information behind classes, interfaces and polymorphism. So how does it work?

A basic Context

A true DCI execution model is difficult to achieve today due to several technical reasons, but I've created a TypeScript library that tries to get close by making the code adhere to certain conventions. It can be installed by following the instructions at https://github.com/ciscoheat/eslint-plugin-dci-lint

If you want to follow this tutorial in code, you can do that with Stackblitz by clicking here.

The library tries to provide the simplest possible structure for a working DCI Context, within the limits of TypeScript syntax. Here's an example of a "Hello World" Context:

/**
 * @DCI-context
 * A speaker proclaims something to the world, that dutifully notes it
 */
function HelloWorld(
  Speaker: { phrase: string },
  World: { log: (msg: unknown) => void }
) {
  function Speaker_proclaim() {
    World_note(Speaker.phrase);
  }

  function World_note(phrase: string) {
    World.log(phrase);
  }

  Speaker_proclaim();
}

First, the @DCI-context tag on top is required for the library to work. It can be put in a simpler // @DCI-context comment as well, right above the function.

The Context may look a bit unusual, due to the capitalized variables and underscored methods. The underscore is a syntax limitation that will be explained later, but capitalization is only a convention. UPPERCASE has been used in other examples, similar to a movie script that uses uppercase to highlight importance, but it may be too sore on the eyes, so capitalization is a middle ground.

Looking at the Context, two often mentioned parts seem to be Speaker and World. What are they? They're not classes, since they are typed as literal objects. You don't have to look up the public interface of a class or type in a different file to understand what they can do. As said, a goal with DCI is to be able to understand the code from a different perspective - in a Context we're standing between objects, describing and observing how they interact, only being interested in what's relevant in the context, in this case, proclaiming something to the world.

This is in contrast to traditional OOP, where we are placed inside the objects, only seeing a limited view of the system (the class interface and its outgoing messages).

A brief introduction to Roles

Again, what is the Speaker and the World? The answer is that they are Roles. A Role is an identifier within a Context, with a literal type, called a RoleObjectContract, or just contract.

Like in a movie, a Role can be played by an actor - in our case an object, but just as real actors casting for a certain role, the object must adhere to certain characteristics. Here are the Roles for HelloWorld again:

/**
 * @DCI-context
 * A speaker proclaims something to the world, that dutifully notes it
 */
function HelloWorld(
  Speaker: { phrase: string },
  World: { log: (msg: unknown) => void }
)

Looking at Speaker, from its contract we discern that any object with a string property called phrase can play the Role of Speaker. Let's create a super-simple object just for that purpose:

const you = { phrase: "Hello, World!" };

The contract for the World Role is a log function that accepts anything and returns nothing. With a bit of knowledge about Javascript runtimes, we know that the global console object matches that contract.

About naming: Why are the Roles in this Context named "Speaker" and "World"? That has to do with the mental model the Context is based upon, which will be the topic of the next part. Until then, just note that the comment above the Context mentions these two names.

The Data part of DCI

The objects you and console are the Data part of the DCI acronym; simple objects with little awareness of the rest of the system, which is very well, because in DCI we don't want to mix Data (what the system is) with Functionality (what the system does). Not only do they have different rates of change, so a domain separation is already obvious, but mixing them leads to the classic issue of objects taking on more and more responsibilities, eventually resulting in an entangled and fragile jumble.

RoleMethods

Looking at the HelloWorld Context again, we see an underscored method:

function Speaker_proclaim() {
  World_note(Speaker.phrase);
}

The underscore, being a rather poor substitute for a period due to syntax limitations, demarcates a RoleMethod. Its name always starts with one of the Roles in the Context, working as an extension of the object playing the Role while it's inside the Context. See it as the object playing the Role of Speaker gets a proclaim() function attached to it when, and only when, the HelloWorld Context is executing.

The Interaction part of DCI

In the function body of Speaker_proclaim, we see that the Speaker interacts with the World Role, by calling one of its RoleMethods:

function Speaker_proclaim() {
  World_note(Speaker.phrase);
}

function World_note(phrase: string) {
  World.log(phrase);
}

In World_note, the World calls its log method, and this is a very important part of DCI - only the Role can access its contract. It solely decides when to call its underlying object, inside its own RoleMethods. This is one of several rules that the linter library is upholding. For example, this would lead to an error:

// Trying to take a shortcut:
function World_note() {
  World.log(Speaker.phrase); // Error: Call to a Role contract outside its own RoleMethods.
}

By preventing access to the underlying objects from other Roles, we gain additional security. Just as computer memory is protected by an operating system, we shouldn't be able to dive into the internals of any object in our system without consideration, and being explicit about it.

Executing the Context - System interaction

At the end of the Context, there is an entry point for its execution:

Speaker_proclaim();

This is called a System Interaction which starts a flow of interactions through the Context by calling a RoleMethod. We can do this in two different ways:

A. When the Context describes functionality that should execute "on the spot", like in this example, putting a RoleMethod at the end is a simple way of starting it. Using our objects you and console, it will look just like this:

const you = { phrase: "Hello, World!" };

HelloWorld(you, console);

B. If you instead want to reuse the context, or pass parameters to it, you can achieve a more OO approach by letting it return an object, basically the public interface of the Context:

function HelloWorld(
  Speaker: { phrase: string },
  World: { log: (msg: unknown) => void }
) {
  function Speaker_proclaim() {
    World_note(Speaker.phrase);
  }

  function World_note(phrase: string) {
    World.log(phrase);
  }

  return {
    note: () => Speaker_proclaim()
  }
}

Which will make the Context usable as an object:

const you = { phrase: "Hello, World!" };
const world = HelloWorld(you, console);

world.note();

This concludes part 1 of this DCI tutorial! The HelloWorld Context is a bit contrived, but the next part will explain why methods like Speaker.exclaim and World.note are important, instead of a simple console.log(you.phrase). Please continue in part 2.

Official website

For more information about DCI, its official website is https://fulloo.info.