DCI tutorial for TypeScript: Part 5

DCI tutorial for TypeScript: Part 5

Welcome back to the DCI series! Here are the previous parts if you're new.

Error handling in Contexts

As promised, the topic for today is error handling with the least amount of surprise. This could lead us to ask what is surprising when coding and debugging.

Code locality

DCI does an excellent job at locality - making the code understandable when looking at only a small portion of it. In the non-polymorphic, pattern-less locale of a DCI Context, you rarely have to seek outside it to understand how things are supposed to work.

Let's illustrate this with an example of the opposite. There is a problem with password handling in a web framework, and we have to figure it out. The entry point is found easily enough:

class PasswordController extends Controller
{
  public function update(Request $request, UpdatesUserPasswords $updater)
  {
    $updater->update($request->user(), $request->all());
    return app(PasswordUpdateResponse::class);
  }
}

A short controller class that calls an updater. The UpdatesUserPasswords is an interface, so next we have to find the implementations, briefly analyze them and start debugging to conclude which one is the problem. And then the process repeats, maybe the UpdatesUserPasswords implementation calls an UpdatesClient and an UpdatesDatabase interface, with respective implementations, and so on... you'll get the idea.

This abstraction is not only unexplanatory (an "update" method with an "updater" that will "update") but will lead us down a rabbit hole. Where are things happening? In the next layer of abstraction? Or parts of it here, and the rest in another place?

DCI does the opposite, by describing local and contextualized behavior. A Role is an interface but related to relevant behavior. You can see it as the DCI Role abstraction points toward the architecture of the system, instead of towards some general "update" notion that conceals the system behavior and mental model.

The 21st-century GOTO

Lack of locality can be confusing and lead to surprises, because the further we go away from a central location, the more we have to know about and mentally knit together all parts of the system - not a trivial task.

Now imagine that during all this, you're suddenly transported somewhere else and have to refocus and adjust to new surroundings.

This is what happens when a program jumps across reasonable boundaries, as in the following concepts:

  • Events

  • Exceptions

Arbitrary jumping across a program has since long been frowned upon, but it seems to have resurfaced in these concepts as a 21st-century GOTO. And we experience pretty much the same kind of surprise and confusion when getting lost in reality, as in program code: "Where am I? How did I get here?"

With DCI however, we have an effective way of protecting ourselves from these kinds of surprises, which is, as you may have guessed, to keep the program flow within the Context to the highest possible extent. Let's examine the "GOTOs" and see if and how they can be used in a DCI Context.

Events

Role contract events

If a Role contract contains some event subscribing method, be aware that any subscription to it finds the Context program flow at the mercy of a Roleplayer. This can be confusing and also break the mental model described by the RoleMethods, as it can suddenly interrupt the defined message flow.

Therefore, if you need to subscribe to an event, treat it as a one-off event if possible. For example, addEventListener has a once option. After the event is handled, you explicitly subscribe to it again, so it becomes a part of the message flow, keeping you in control of the program.

Context events

Exposing events in the public Context interface should at least warrant the question of why it is needed. Since a Context can be a RolePlayer in another Context, by exposing an event source, we potentially cause surprises in other Contexts.

Exceptions

Jumping from the well-defined message flow of a Context to some unknown place outside it violates the readability goal of DCI. Not even exceptions are exempt from this rule.

In the previous parts' example, we have started the system operation (an entry point) in a code block at the end of the Context:

{
  // System operation
  Messages_hide();
}

There was a reason for the code block: It provides a place for error handling. It can now be easily modified to

try {
  // System operation
  await Messages_hide();
} catch (e) {
  // TODO: Error handling
}

We can now assure that no errors will leak through the Context to another abstraction level. try/catch requires that we are using async/await for the RoleMethods, hence the added await keyword.

How the exceptions are handled varies, but in a web application, it's common to send errors to a logging service. It can be done at the top level, so it's not a strict requirement to catch and handle all errors, but as long as the errors are related to the Context it's a good practice, including being consistent about the approach that's taken.

The final rewrite of the SubmitForm Context

After making our SubmitForm Context use async/await, the final version looks like this:

import { log } from './log';

/** 
 * 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 call (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);
  }

  // Private RoleMethod (double underscore)
  function Messages__set(display: string, name = '') {
    for (const msg of Messages) {
      if (name && msg.dataset.formMessage != name) continue;
      msg.style.display = display;
    }
  }

  //#endregion

  try {
    log('Submit');
    e.preventDefault();
    await Messages_hide(); // System operation
    log('Done');
  } catch (e) {
    log(e);
  }
}

(Code with demo is available here on Stackblitz.)

Some regions have been added for the Roles, which can be folded in VS Code for a better overview in large Contexts.

Context initialization errors

The astute reader may have noticed that we're throwing an exception at the beginning of the Context - outside the try/catch block - which may look contradictory to what's been said, but this initial error checking is more like a contract or a constructor than part of the Context behavior, and if that fails, it's nothing we can handle and must relinquish control, since the initiator didn't fulfill the conditions for initializing the Context.

Reusable Roles?

Before finishing this part, we have to tie up a loose end from part 3, where I mentioned that the language Haxe has something called Abstract types, an interesting and useful feature that looks similar to Roles, but since it's a separate type, similar to mixins and extension methods, etc, it can stand on its own.

This can lead to the question if Roles can be reusable as separate types, but Roles have a name and responsibilities that make sense only in a certain Context, so they cannot. The DCI FAQ has a question dedicated to this topic, so we'll end this part with a quote from there:

A Shape can move and draw in a graphical context; a Cowboy can move, draw and shoot in a Western movie context. Having a Role — named however you like — that can draw and shoot does not make it a candidate for reuse in another Context.

As always, thank you for reading, just reach out if you have any questions!