Installation

Import SubwayJS in your HTML file using unpkg:

<script src="https://unpkg.com/@subway-js/subway@latest/dist/subway.js"></script>

The library will create a global variable Subway in the window global object.

API

1. Aggregates

You can create an aggregate by providing a unique aggregate name:

const counterAggregate = Subway.createAggregate("counter");

You can also specify an initial state for the aggregate:

const counterAggregate = Subway.createAggregate("counter", { value: 0 });

Additionally, you can select an existing aggregate:

const counterAggregate = Subway.selectAggregate("counter");

2. Handling commands

It helps to think about aggregates as domain-relevant entities who are able to receive messages and do something with those messages. Messages can be commands or events.

You can send a command to an existing aggregate:

Subway
  .selectAggregate("counter")
  .command("INIT_COUNTER", {
    incrementValue: 1
  });

but in order for it to have any effect, a command handler must be in place:

Subway
  .selectAggregate("counter")
  .reactToCommand(
    "INIT_COUNTER",
    ({ state, payload }) => {
      const { incrementValue } = payload;
      // ...
    }
  );

A command handler is a function that is triggered upon receiving a specific command. Inside the function we receive:

A command handler mainly perform a check on the commands being received (e.g. using API calls), and the result of its execution is the trigger of one or more events:

Subway
  .selectAggregate("counter")
  .reactToCommand(
    "INIT_COUNTER",
    ({ state, payload }, { triggerEvents }) => {
      const { incrementValue } = payload;
      triggerEvents([{
        id: 'COUNTER_READY',
        payload: {
          currentValue: 0,
          incrementValue
        }
      }]);
    }
  );

Commands cannot change the aggregate state: they represent an intent to act on it, and their name usually includes a verb in the imperative mood.

3. Handling events

We can’t directly send an event to aggregate (they are the result of a command), but we can define a handler to make use of them:

Subway
  .selectAggregate("counter")
  .reactToEvent(
    "COUNTER_READY",
    ({ state, payload }, { updateState, triggerEvents }) => {
      const { currentValue, incrementValue } = payload;
      updateState({
        ...aggregateState,
        status: 'ready',
        currentValue,
        incrementValue
      });
    }
  );

Events indicate that something happened in our system, and they are named in the past tense: this is a very important difference in the context of event sourcing pattern.

The main difference between a command and an event handler is that the event handler can change the aggregate state: it can also trigger additional events to implement more complex business logic.

4. Observing state

So far we saw how to create an aggregate, send commands to it, and configure the chain of events and business logic triggered by those commands: we are still missing the ability to receive the new state of an aggregate (e.g. to implement our views).

We can observe an aggregate state in the following way:

Subway
  .selectAggregate("counter")
  .observeState(nextState => {
      // ...
  });

Every time an aggregate state is updated, the next function will be invoked.

5. Exposing commands

Subway
  .selectAggregate("counter")
  .publicChannel()
  .reactToCommand(
    "RESET_COUNTER",
    ({ state, payload }, { triggerEvents }) => {
      triggerEvents([{ id: "COUNTER_RESET_REQUEST_RECEIVED" }])
    }
  );
anotherAggregate
  .publicChannel()
  .command("RESET_COUNTER");

6. Exposing Events

Subway
  .selectAggregate("counter")
  .reactToEvent(
    "COUNTER_READY",
    ({ state, payload }, { broadcastEvent }) => {
      const { currentValue, incrementValue } = payload;

      broadcastEvent('SOMETHING_INTERESTING_HAPPENED', payload);

      // ..
    }
  );
anotherAggregate
  .publicChannel()
  .reactToEvent("SOMETHING_INTERESTING_HAPPENED", payload => {
    // ...
  });

7. Exposing Components

Subway
  .selectAggregate("counter")
  .publicChannel()
  .publishComponent(
    "ExportedComponent",
    // Mount function:
    (params, { selector, element }) => {
      /*
        Mount the component on the DOM
        element specified by:
        - the selector, to be used with document.querySelector
        - or the element itself

        Parameters are used to customize the component
      */
    },
    // Unmount function:
    ({ selector, element }) => {
      /*
        Cleanup
      */
    }
  );
anotherAggregate
  .publicChannel()
  .importComponent(
    "ExportedComponent",
    ({ mount }) => {
      mount({
        label: 'Custom Button'
      }, {
        selector: '#buttonContainer'
      })
    });

Managing errors

This is a first level of errors management we can perform in SubwayJS is command rejection: when triggering a command, it may be rejected by using the rejectCommand function injected in any command handler:

Subway
  .selectAggregate("counter")
  .reactToCommand(
    "INIT_COUNTER",
    ({ payload }, { triggerEvents, rejectCommand }) => {

      if(!payload.incrementValue) {
        rejectCommand( 'Missing required field for INIT_COUNTER command', {
          fields: ['incrementValue']
        });
        return;
      }

      triggerEvents([{
        id: 'COUNTER_READY',
        payload: {
          currentValue: 0,
          incrementValue: payload.incrementValue
        }
      }]);
    }
  );

This mechanism can be used for simple scenarios like:

We can handle a rejection by providing a callback when triggering a command:

Subway
  .selectAggregate("counter")
  .command("INIT_COUNTER", {
    incrementValue: 1
  }, ({ reasonString, meta }) => {
    // ...
  });

What happens when something happens in some other point of a SubwayJS application lifecycle?

When we talk about errors in JavaScript, we usually think about try/catch clauses, callbacks, or rejecting promises. SubwayJS approach to errors is to consider them as any other event in the system: they just describe a different path or flow.

As an example, when logging in, there are a variety of things that could go wrong:

And if we are already logged in, and we are processing a payment:

Commands rejection is a useful tool, but when an exception/error occurs inside an event handler (and we can have ‘sagas’ that involve multiple event handlers), we don’t have such tool as we have already lost any link to the original command that triggered the chain of events.

Event handlers can trigger meaningful events, that have a real meaning in the current aggregate, e.g.:

With such events we can send the relevant payload for our next aggregate store in order to provide all the details we need in the UI to properly deal with any error (or do something in background e.g. refresh an authentication token without even bothering the user).

Micro-frontends

Check the micro-frontends example for the full code or subway-react-ecommerce-microfrontends for microfrontends with SubwayJS and React.

Subway comes with two utility functions to setup your micro-frontends.

The first one is Subway.helpers.composeMicroFrontends(), which compose the application:

<script type="text/javascript">
  Subway
    .microFrontends()
    .compose({
      mfs: [
        {
          id: "MF_1",
          src: "http://127.0.0.1:8080/examples/microservices/mf1.js",
          domSelector: "#mf1"
        },
        {
          id: "MF_2",
          src: "http://127.0.0.1:8080/examples/microservices/mf2.js",
          domSelector: "#mf2"
        },
        { id: "MF_3", domSelector: "#mf3" }
      ]
    });
</script>

It accepts a list of micro-frontends elements, that specify:

It is also possible to statically load a micro-frontend by omitting the src attribute and attaching the script tag to the html file as follows (it is possible to mix dynamic and static micro-frontends):

<script type="text/javascript">
  Subway
    .microFrontends()
    .compose({
      mfs: [{
        id: "MF_1",
        src: "http://127.0.0.1:8080/examples/microservices/mf1.js",
        domSelector: "#mf1"
      }, {
        id: "MF_3",
        domSelector: "#mf3"
      }]
    });
</script>
<script id="MF_3" src="/examples/microservices/mf3.js"></script>

The second utility function is the one used by each micro-frontend to install itself into the application container:

Subway
  .microFrontends()
  .install('MF_1', ({ domSelector }) => {
  // bootstrap your app in the HTML element identified by 'domSelector'
});