DCI tutorial for TypeScript: Part 4

DCI tutorial for TypeScript: Part 4

Ready for more DCI? If you're new, check out the other parts first.

We talked a lot about Roles in the previous part, concluding that Roles are a quite natural way of expressing ourselves in code. This has led to many attempts at adding Roles to languages without introducing additional syntax. This has also been a huge source of misunderstanding regarding DCI, so let's clear this one once and for all.

DCI Roles cannot be wrappers

Who needs the underscore RoleMethod convention? Let's use objects instead for the example in Part 3:

const Messages = (function(
  Messages: {
    [Symbol.iterator]: () => IterableIterator<HTMLElement>;
  }
) {
  function set(display: string, name = '') {
    for (const msg of Messages) {
      if (name && msg.dataset.formMessage != name) continue;
      msg.style.display = display;
    }
  }
  return {
    show(name: string) {
      set('unset', name);
    },
    hide() {
      set('none');
    },
  };
})(e.target.querySelectorAll('[data-form-message]'));

// Now we're free to use the Role with normal method syntax:
Messages.hide()

This seems to be working but unfortunately isn't. This has been the number one obstacle when trying to understand DCI: Failing to understand that you cannot wrap a Role in, or substitute it for another object. By doing that you are violating object identity, which could work in many cases, but not all, and that kind of uncertainty is not what we want in software.

The problem is that any other part of the program, and that can be a huge part considering the number of external dependencies of a project, doing an identity check (== or ===) on the Messages Role will now fail this check, even though it was supposed not to.

This FAQ entry explains the whole thing in more detail, and I've made an example on Stackblitz that shows how these bugs can appear, even in a strongly typed language like TypeScript.

Now that we have gotten this out of the way, let's move on to more interesting things.

Adding actual interaction

A question was asked at the end of Part 3: If there is only one Role, can there be interaction?

Interaction presupposes more than one part, so the answer seems to be no, and this is also the case with DCI. Programs with minimal object interaction are probably just as readable without Roles, data transforming with functional programming comes to mind here as well. But it's worth considering that something simple can grow more complicated, and the more variables introduced, the greater the chance that some interaction happens between them.

Looking at our SubmitForm Context again, is it too simple for Roles?

// @DCI-context
function SubmitForm(e: SubmitEvent) {
  e.preventDefault();
  if (!(e.target instanceof HTMLFormElement)) 
    throw new Error('No form found.');

  // Role
  const Messages : Iterable<HTMLElement> = 
    e.target.querySelectorAll('[data-form-message]');

  // RoleMethods
  const Messages__set = (display: string, name = '') => {
    for (const msg of Messages) {
      if (name && msg.dataset.formMessage != name) continue;
      msg.style.display = display;
    }
  };

  const Messages_show = (name: string) => Messages__set('unset', name);

  const Messages_hide = () => Messages__set('none');

  {
    // System operation
    const form: HTMLFormElement = e.target as HTMLFormElement;

    Messages_hide();
    fetch(form.action, { method: 'POST' })
      .then((response) => response.json())
      .then((data) => data.errors?.forEach(Messages_show));
  }
}

Well, something seems to interacting in the system operation:

{
  // System operation

  // We're creating a new variable here:
  const form: HTMLFormElement = e.target as HTMLFormElement;

  // This is the actual system operation - a RoleMethod call
  Messages_hide();

  // Form submit behavior
  fetch(form.action, { method: 'POST' })
    .then((response) => response.json())
    // Interaction with Messages.show
    .then((data) => data.errors?.forEach(Messages_show));
}

I'd say we have created a Role without really noticing:

  • An identifier (form) is created and assigned

  • It has behavior (submit the form)

  • It interacts with the Messages role.

This means that we in principle have two Roles, and therefore interaction! Let's modify the Context to use a Form Role instead:

// @DCI-context
function SubmitForm(e: SubmitEvent) {
  e.preventDefault();
  if (!(e.target instanceof HTMLFormElement)) 
    throw new Error('No form found.');

  // Role
  const Form: {
    action: string;
  } = e.target;

  // RoleMethods
  const Form_submit = () => {
    Messages_hide();
    fetch(Form.action, { method: 'POST' })
      .then((response) => response.json())
      .then((data) => data.errors?.forEach(Messages_show));
  };

  // Messages role omitted, it's unchanged.
  // ...

  {
    // System operation
    Form_submit();
  }
}

It's now more apparent what's going on. The Role contract is literal, so we can immediately see that the object playing the Form Role only needs an action property to do that. And remember that the action property is protected from access by the rest of the Context. If we want to access it, we have to be explicit and use the Form's public RoleMethods.

Distributing the interaction

What we have done now, however, is to create an imperative, all-knowing Role that limits the DCI idea of describing a network of interacting objects. Putting the interactions in one place like this is a step back to procedural programming (like the previous system operation, though perhaps it was simple enough to get away with it).

Older languages like Fortran and Pascal worked in sequence and told in detail to the computer what to execute. This is not what we want, since objects in the network are non-static entities. By strictly organizing them, telling them what to do and handling their return values, we prevent interaction and communication. This FAQ entry explains in more depth.

Instead of limiting communication, let's think of how our objects could interact and communicate more dynamically. Here is a breakdown of the code in its current, procedure-like fashion:

  1. Tell the messages to hide

  2. Fetch the form action and return the errors

  3. Tell the messages to show the errors

Let's instead distribute this algorithm in an object-oriented fashion:

DCI distributed algorithm@2x.png

Now the RolePlayers are communicating in a message-passing manner, which is not only more connected to mental modeling and DCI concepts, it is also more genuine:

  1. User: Messages, can you hide?

  2. Messages: (Hiding) Form, can you submit?

  3. Form: (Submitting) Messages, can you show these errors?

  4. Messages: Hey User, here are some error messages.

Now we're asking, not telling. By asking, we get rid of the assumption that the object can always do what we want (a more reasonable error handling, in other words). We will talk more about error handling in the next part.

With this, I think we're ready to update the Context (code on Stackblitz).

// @DCI-context
function SubmitForm(e: SubmitEvent) {
  e.preventDefault();
  if (!(e.target instanceof HTMLFormElement)) 
    throw new Error('No form found.');

  // Role
  const Form: {
    action: string;
  } = e.target;

  // RoleMethods
  const Form_submit = async () => {
    const response = await fetch(Form.action, {
      method: 'POST',
      body: new FormData(e.target as HTMLFormElement),
    });
    const data = await response.json();

    // Role communication/interaction
    data.errors?.forEach(Messages_show);
  };

  // Role
  const Messages: Iterable<HTMLElement> = 
    e.target.querySelectorAll('[data-form-message]');

  // RoleMethods
  const Messages_hide = () => {
    Messages__set('none');
    // Role communication/interaction
    Form_submit();
  };

  const Messages_show = (name: string) => {
    Messages__set('unset', name);
  };

  const Messages__set = (display: string, name = '') => {
    for (const msg of Messages) {
      if (name && msg.dataset.formMessage != name) continue;
      msg.style.display = display;
    }
  };

  {
    // System operation
    Messages_hide();
  }
}

The interactions now follow our mental model very closely. For further analysis, tracing a code path through the Context for this specific Interaction can be done with a sequence diagram:

DCI sequence diagram@2x.png

"Rewiring" the Context

The flexibility of a distributed algorithm like this will be more evident as the system grows. By avoiding return values (though they are not forbidden), the parts of the system become more modular, and as functionality changes, we can more easily "rewire" the interactions similar to patching an audio effect rack, where a cable can send messages to another part of the rack.

Rewiring is much harder when we rely on return values to tell the system what to do next. It creates a coupling to the return value type, takes us away from the idea of mental modeling, and invokes the imperative programming style that isn't very object-oriented.

Context visualization

Looking at physical cables we can see how messages could travel, so it can help visualize the system until it gets too messy. Being able to pass messages without physical cables avoids the mess, but on the other hand, makes debugging less practical! Previously we've had trouble determining what part of the system to diagram since the behavior was spread out through multiple abstraction boundaries. The boundary of a Context though, as a distinct unit of system behavior, makes it appropriate for visualization tools to display its interactions, as I've experimented with earlier:

DCI Context visualization.png

The Roles have different colors, the inner circle contains RoleMethods, and behind them, in the outermost layer, are the Role contract fields. I can put an interactive diagram online if there is enough interest.

In the next part

In the next part of this series, we will look at Context error handling with the least amount of surprise. See you there!