Andreas Söderlund
Uniting art and engineering in code

Uniting art and engineering in code

DCI tutorial for TypeScript: Part 1

DCI tutorial for TypeScript: Part 1

Andreas Söderlund's photo
Andreas Söderlund
·Oct 22, 2022·

8 min read

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?

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 behaviour, in contrast to objects and classes which encapsulate state well, but only captures 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 abstracts away relevant information (like interfaces), or provides too much information (classes), so we are left with either a limited or overwhelming view when trying to understand how the system works at runtime - which is markedly different from what it looks like at compile-time.

The current way of gaining 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. So if you want to follow along in code, you can do that immediately with Stackblitz by clicking here.

The library can also be installed locally by following the instructions at github.com/ciscoheat/eslint-plugin-dci-lint

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 // comment as well above the function.

It 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 the capitalization is only a convention. UPPERCASE has been used in other examples, similar to a movie script which uses uppercase to highlight importance, but it may be too sore on the eyes, so capitalization could work as a middle ground.

Looking at the Context, two often mentioned parts seems 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 a 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 of, 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 basically just an identifier within a Context, with a literal type, called a RoleObjectContract, or just contract.

Just as 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. 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 looks familiar. 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 tutorial. Until then, just note that the comment above the Context mentions these two names.

The Data part of DCI

The you and console objects 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 rate 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. Look at it as though 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 own 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 (RolePlayers) 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

At the end of the Context we see how it will start to execute:

Speaker_proclaim();

This is called a System Interaction which starts a flow of interactions through the Context by calling a RoleMethod. There are basically two ways to go:

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 {
    say: () => Speaker_proclaim()
  }
}

Which will make the Context usable like this:

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

world.say();

Further on in the series we'll take a look at those different approaches in greater depth.

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.

 
Share this