Domain

Description

The domain encapsulates the entire business logic of your application. It handles validation and authorization; it decides when and how entities change.

In the world of Event Sourcing, changes are made by appending new events to a database. If we want to know the current state of an entity, we load all events for the given ID and run them through a Reducer. The reducer interprets the events and returns to us the current state.

Events are generated by things called Actions, they are responsible for deciding if a change should be allowed. If allowed, the action will return one or more events which describe the change. If not allowed, the action will throw an error.

Actions and reducers are pure functions. They cannot mutate data or read from external data sources. All external information must be passed in as one of the parameters. The returned value should always be a new object.

In other words, the domain does not know anything about the outer layers of the application. Given some input, they return some output. That's all they can do.

Actions and reducers are pure functions. They cannot mutate data or read from external data sources.

Actions are given two parameters: the current state of the entity, and a payload. The current state is calculated by running existing events through the reducer. The payload is an arbitrary object provided by the caller.

Based only on these two parameters, the action decides to accept or reject the command. Let me say that again: Any information required for the action to make its decision must be contained in the current state of the entity or in the given payload.

Actions are given the current state of the entity and a payload. Based on these two parameters, and only on these parameters, it decides if it should reject and if not, what kind of event to return.

For example, let's say that one of our business requirements is that you can only update an entity if you are the owner. We need to make sure that the payload contains the userId of the caller and the state contains the userId of the owner. Inside the action we compare them and throw an error if they don't match, like so:

if (state.userId !== payload.userId) throw new Error('unauthorized')

Actions

An object where keys are action names and values are functions which accept two parameters, state and payload, and return an array of one or more events. If the action is not allowed, it should throw an error object

module.exports = {
  someActionName: (state, payload) => {
    return [
    // ...events
    ]
  }
}

Reducer

A function which accepts two parameters; an array of events and the state of the entity prior to the first event in the array (if the event array starts at the very first event then there is no previous state and the parameter should be omitted).

const initialState = {
  // ...some initial state
}
const reducer = (state, event) => {
  // compute and return new state
}
module.exports = (events, state=initialState) => events.reduce(reducer, state)

As a performance optimization, we can snapshot entities at a certain version number. Given a snapshot, you need only to apply newer events to get the current state.

Examples

Basic

In this example there is only one action, addTodo, and the only requirements is that the title is not null. The action returns a new event with the type TodoAdded. The reducer knows that whenever it comes across a TodoAdded event, it should append a new todo with a matching title.

actions.js
const actions = {
  addTodo: (state, payload) => {
    if (!payload.title) throw new Error('titleMissing')
    
    return [{
      type: 'TodoAdded',
      title: payload.title,
      at: Date.now(),
    }]
  }
}

module.exports = actions
reducer.js
const initialState = {
  todos: []
}

const reducer = (state, event) => {
  switch (event.type) {
    case 'TodoAdded':
      return {
        todos: [
          ...state.todos,
          { title: event.title },
        ]
      }
      
    default:
      return state
  }
}

module.exports = (events, state=initialState) => events.reduce(reducer, state)

Extended

In this example, we'll do something a little different.

Firstly, we have a createWidget action. Normally on 'create' actions, we check to make sure that an object with the same ID doesn't already exist. We can check this by making sure the current state is null.

Secondly, we make sure we have the userId of the user who is calling createWidget and we store is as part of the event. We do so, so that in future calls to setTitle we can make sure the calling user is authorized to perform the action.

Lastly, when the user calls createWidget, we are separating the change into two distinct events: WidgetCreated and TitleSet. Doing so allows us to reduce the scope of change for each event and simplify event handling in the reducer.

actions.js
const actions = {
  createWidget: (state, payload) => {
    if (!!state) throw new Error('alreadyExists')
    if (!payload.userId) throw new Error('userIdMissing')
    if (!payload.title) throw new Error('titleMissing')
        
    return [{
      type: 'WidgetCreated',
      userId: payload.userId,
      at: Date.now(),
    }, {
      type: 'TitleSet',
      title: payload.title,
      at: Date.now(),
    }]
  },
  
  setTitle: (state, payload) => {
    if (state.userId !== payload.userId) throw new Error('unauthorized')
    if (!payload.title) throw new Error('titleMissing')

    return [{
      type: 'TitleSet',
      title: payload.title,
      at: Date.now(),    
    }]
  }
}

module.exports = actions
reducer.js
const initialState = {}

const reducer = (state, event) => {
  switch (event.type) {
    case 'WidgetCreated':
      return {
        userId: event.userId,
        createdAt: event.at,
        updatedAt: event.at,
      }

    case 'TitleSet':
      return {
        title: event.title,
        updatedAt: event.at,
      }
                  
    default:
      return state
  }
}

module.exports = (events, state=initialState) => events.reduce(reducer, state)

Last updated