blog.tnez.dev

Introducing Actions: A Wrapper for Your Business Logic

TL;DR

@tnezdev/actions is a wrapper for your business logic that produces consistently shaped responses and encourages easy unit testing.

Motivation

Applications are made up of many layers. You probably have things like route handlers that respond to HTTP requests, clients that interact with third party services, utilities, ... all kinds of things. If you are not careful the interactions between these different parts of the system can get all tangled up and mixed together, leading to things like code duplication and more importantly, making your application more difficult to test.

In code today it is very common to see business logic expressed directly in request handlers. Let's say we were developing an API and we had an endpoint to create a new user.

import { DBClient } from 'some-db-client'
import { EmailClient } from 'some-email-client'

const db = new DBClient()
const email = new EmailClient()

export async function POST(request) {
  try {
    const user = await request.json()
    const result = await db.insert(user)
		await email.send({ subject: `Welcome ${user.displayName}!` })
    return new Response('User Created')
  } catch () {
    return new Response('Unexpected error occurred', { status: 500 })
  }
}

But this is difficult to test. The reason is that the bit that actually makes something happen or side-effect (the db insert and email sending) are entangled with the part of your application that should be concerned only with parsing the incoming request and producing an appropriate response.

This seems like small potatoes, and I get that we all want to move fast and get stuff done. But I believe we can make a small effort to clean this up without incurring a short-term loss of velocity and increase our velocity in the long-term by writing more testable code.

The Solution

What we would like is for our request handler to only worry about handling the request and responding appropriately. One way to do this is to express the complicated stuff in an action, ideally one that is named in alignment with a meaningful business action that declares what is happening within the application in a way that a stakeholder would clearly understand.

import { DBClient } from "some-db-client";
import { EmailClient } from "some-email-client";
import { CreateNewUser } from "@/actions";

const db = new DBClient();
const email = new EmailClient();

const action = CreateNewUser.initialize({
  effects: {
    db,
    email,
  },
});

export async function POST(request) {
  const user = await request.json();
  const result = await action.run(user);

  return result.ok
    ? new Response("User Created")
    : new Response(result.error.message, { status: 500 });
}

You can see that:

  1. The request handler is now concerned only about handling the request and giving back the appropriate response.
  2. The complicated stuff is deferred to an CreateNewUser action.
const action = CreateNewUser.initialize({
  effects: {
    db,
    email,
  },
});

The important bit here is that the side-effects are passed in to the action using dependency injection. And what this allows is for us to pass in mock objects when unit testing our action so that we can thoroughly exercise every possible outcome for these side-effects from inside our unit test.

This might look something this (/packages/actions/src/users/create-new-user.test.ts):

import { CreateNewUser } from ".";
import type { CreateNewUserContext } from ".";
import { describe, expect, it } from "vitest";
import { MockDeep } from "vitest-mock-extended";

const VALID_INPUT = {};

describe("happy path", () => {
  it("should invoke effects.db.insert with expected arguments", async () => {
    const context = MockDeep<CreateNewUserContext>();
    const action = CreateNewUser.initialize(context);

    await action.run(VALID_INPUT);

    expect(context.effects.db).toHaveBeenCalledWith(VALID_INPUT);
  });

  it("should invoke effects.email.send with expected arguments", async () => {
    const context = MockDeep<CreateNewUserContext>();
    const action = CreateNewUser.initialize(context);

    await action.run(VALID_INPUT);

    expect(context.effects.email.send).toHaveBeenCalledWith({
      subject: `Welcome ${VALID_INPUT.displayName}!`,
    });
  });

  it("should return expected result", async () => {
    const context = MockDeep<CreateNewUserContext>();
    const action = CreateNewUser.initialize(context);

    const result = await action.run(VALID_INPUT);

    expect(result.ok).toBe(true);
  });
});

describe("when effects.db.insert fails", () => {
  it("should not invoke effects.email.send", async () => {
    const context = MockDeep<CreateNewUserContext>();
    context.effects.db.insert.mockImplementation(async () => {
      throw new Error("Unexpected Error");
    });
    const action = CreateNewUser.initialize(context);

    await action.run(VALID_INPUT);

    expect(context.effects.email.send).not.toHaveBeenCalled();
  });

  it("should return expected result", async () => {
    const context = MockDeep<CreateNewUserContext>();
    context.effects.db.insert.mockImplementation(async () => {
      throw new Error("Unexpected Error");
    });
    const action = CreateNewUser.initialize(context);

    const result = await action.run(VALID_INPUT);

    expect(result.ok).toBe(true);
    expect(result.error.message).toBe("Unexpected Error");
  });
});

In addition to making unit testing more convenient, this also allows you to define stubbed interfaces that represent fully fledged clients that will eventually be implemented. In doing so, you can use these to start building user interfaces in parallel with client logic.

So, where does @tnezdev/actions come in?

@tnezdev/actions is a formalization of this action pattern. It takes a handler that you define for your business logic and ensures that you will return one of the following shapes:

  1. For the happy path: { ok: true, data: Data }
  2. For the sad path: { ok: false, error: Error }

It takes care of try / catch logic so that you can simply throw from your handler and get back the sad path shape. In addition it adds helpful logging, enhanced with metadata when your logic is run, and additionally exposes logging methods to your handler so that you can log with the same metadata included.

[GetTemparature:{correlation-id}] Action Started (input: {"zipcode":"12345"})
[GetTemperature:{correlation-id}] You can emit logs from inside the action
[GetTempearture:{correlation-id}] Action Completed (data: {"temperature":"75˚F"})

In particular we add a correlationId because often times an action may invoke other actions and we want to be able to view all log entries related to a single initiating event by searching for this correlationId.

@tnezdev/actions exports a createAction method that can be used as follows:

import { createAction } from "@tnezdev/actions";
import type { ActionHandler } from "@tnezdev/actions";

export type Context = {};
export type Input = {};
export type Output = {};

const handler: ActionHandler<Context, Input, Output> = async (ctx, input) => {
  ctx.logger.info("You can emit logs from inside the action");
  const result = await doSomethingCool(input);

  return result;
};

export const SomeAction = createAction("SomeAction", handler);

Check the README.md for a more detailed example and additional documentation.

← Back to Home