DEV Community

Cover image for Jump In: Sandboxes for Decoupled Applications
Sean Travis Taylor
Sean Travis Taylor

Posted on

Jump In: Sandboxes for Decoupled Applications

A common theme of the last few posts has been containment: as a solution to invalid state changes, for managing exceptions and for unifying control flow.

This approach has another name: encapsulation. It is a fundamental aspect of program design. Why? Encapsulated programs are easier to manage; they keep things organized and they limit the scope of change required as new needs emerge.

The oft-misquoted mantra of the Single Responsibility Principle is worth repeating:

A class should have one and only one reason to change.

Think of a codebase you know. How easy is it to change a class or a larger component without changing something else? If there is an update to the PaymentService, does the OrderService have to change too?

Why are these second-order changes necessary and what is the order of difficulty in making them? The reply demonstrates how well or how poorly our systems absorb change.

Adapt Or Die

In business, our ability to absorb change impacts our ability to stay in business.

How many times has your product manager insisted a change is not only urgent but required? How many times have you bristled at such a request owing to a keen awareness of the time such a change will take?

“How long?” is invariably the next question from your product manager. They may ask about “level of effort”, which is another polite way of asking “How long?”

Frustrating as it is, how long it takes to implement a change is the difference between maintaining competitiveness or making a needed improvement and watching as our business fumbles.

What makes change difficult? Why do consequential changes take so long? A prime suspect is…dependencies.

As the number of dependencies among modules grows, the greater the requirement of cascading modifications across our application and the greater the risk of instability across modules that directly depend on each other.

Image description

It begins innocently enough...

Image description

...but as more modules are added, potential interactions and dependencies grow exponentially, not just linearly. Even a modest number of modules can be difficult to mentally model.

When this network of modules grows too gnarly to reason about, the only recourse is to break our change into pieces over a span of weeks or months.

This lead time gives competitors time to out-innovate us or for a bug in our program to persist longer than necessary; it also hampers the ability to experiment, which is the key to innovation.

How can we limit the scope of change when updates are necessary? Further, how can we de-risk that change?

Let's talk about the Sandbox pattern. For those that like details first, you can explore a detailed demo here. For a gentle introduction, read on.

Come Out and Play

This pattern is an oldie but a goodie. The exploration below is inspired by Nicholas Zakas's aging but excellent talk on creating an application framework.

The implementation is less important, as Zakas makes clear. The benefits of the Sandbox pattern include enhanced modularity, reduced coupling and streamlined communication among application components. With this pattern, we set a foundation that ensures our applications and services can move at the speed of change.

First we see create a new Sandbox, which is an isolated context for our application modules to “play” in. The callback is where all the dependencies specified in the dependencies array are made available.

// classes imported from wherever they are in the filesystem
Sandbox.modules.of('OrderService', OrderService);
Sandbox.modules.of('PaymentService', PaymentService);

const mySandbox = new Sandbox(
  ['PaymentService', 'OrderService'], function (box) {
   // Where we use the services defined on the 
   // sandbox aka where the action happens...
  });
Enter fullscreen mode Exit fullscreen mode

The box argument of our callback is an EventTarget, which means we can register an event listener via addEventListener. Communication via events is key to decoupling our architecture; since our services have no knowledge of their peers they can only dispatch events to the sandbox.

import { SystemEvent, Events } from './event.js';

export class OrderService {
  constructor(sandbox) {
    this.sandbox = sandbox;
  }

  /**
   * @param {Object} event
   * @param {IEvent<Object>} event.detail
   */
  onOrderReceived({ detail: event }) {
    console.log(event);
    // do some order processing here...
    this.sandbox.dispatchEvent(new SystemEvent(Events.ORDER_FULFILLED));
  }
}
Enter fullscreen mode Exit fullscreen mode

When the Sandbox is initialized it provides access to registered services. Above we send an ORDER_FULFILLED event to the Sandbox after an order is processed

Only the sandbox has knowledge of registered services. When an event of interest is dispatched, the sandbox calls an appropriate handler, which receives data about the event.


const mySandbox = new Sandbox(
  ['PaymentService', 'OrderService'], function (box) {
    try {
      box.addEventListener(Events.ORDER_RECEIVED, (event) => {
        box.my.OrderService.onOrderReceived(event);
        box.my.PaymentService.createInvoice(event);
      });

      // assuming our OrderService dispatches event
      // when the order is fulfilled  
      box.addEventListener(Events.ORDER_FULFILLED, (event) => {
        box.my.PaymentService.onOrderFulfilled(event);
      });
    } catch (ex) {
      console.error(ex.message);
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

This architecture allows us to write isolated classes with no dependencies and clearly defined responsibilities. We have freedom to move services anywhere at any time in our app with no changing costs. For best results we limit ourselves to importing only types, interfaces, NPM packages and files located within the same folder; this gives us maximum maneuverability.

Breaking Boundaries with Cross-Sandbox Events

The Sandbox pattern also offers another organizational unit of our code. For example, if we want to create multiple sandboxes to group services into specific domains. Since the Sandbox constructor returns an object with a dispatchEvent method, we can publish events across sandboxes without exposing the sandboxed services themselves: this means maximum modularity, maximum range of motion and a clear separation of concern for each of our services.

// here we have a sandbox instance solely focused on 
// shipping-related concerns...
const yourSandbox = new Sandbox([], function (box) {
    try {
      box.addEventListener(Events.SHIPMENT_READY, (event) => {
        // do stuff related to preparing to ship the order...
    } catch (ex) {
      console.error(ex.message);
    }
  }
);

const mySandbox = new Sandbox(
  ['PaymentService', 'OrderService'], function (box) {
    try {
      box.addEventListener(Events.ORDER_RECEIVED, (event) => {
        box.my.OrderService.onOrderReceived(event);
        box.my.PaymentService.createInvoice(event);
      });

      box.addEventListener(Events.ORDER_FULFILLED, (event) => {
        box.my.PaymentService.onOrderFulfilled(event);
        // communication across sandboxes is facilitated through events too...
        yourSandbox.dispatchEvent(new SystemEvent(Events.SHIPMENT_READY, event.detail);
      });
    } catch (ex) {
      console.error(ex.message);
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Our dispatched events are instances of CustomEvent interface

If you are familiar with the classic design patterns, you will notice this implementation is a combination of the Mediator and Publish/Subscribe patterns.

As always, the exact implementation is less important than the inspiration. Whether you roll your own variety of this type of architecture or use an established framework, the principles of event-driven design and dependency injection set a solid foundation for any large application.

Through their judicious use, these principles will serve your programs well no matter what they aim to achieve–enabling them to move at the speed of change.

Stay tuned for an even deeper dive into this pattern! In the next post we get into the weeds of just how dependency injection and lazy loading enhance the flexibility of our Sandboxes.

Top comments (0)