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.
- Repo: https://www.github.com/tnez/actions
- NPM: https://www.npmjs.com/package/@tnezdev/actions
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:
- The request handler is now concerned only about handling the request and giving back the appropriate response.
- 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:
- For the happy path:
{ ok: true, data: Data }
- 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.