Skip to main content
Version: 0.5.0 (Previous)

๐Ÿ”ง Handler

A handler is an async function that contains the core business logic of your API endpoint. It receives a payload containing request data and context, and returns same payload object that contains the response data.


๐Ÿ“‹ Handler Typesโ€‹

Nodeblocks provides two main handler types based on their role in the handler chain:

Handlersโ€‹

Handlers that perform business logic operations (database calls, data validation, transformations, etc.) and return Result<RouteHandlerPayload, NodeblocksError>:

import { ok, err, Result } from 'neverthrow';
import { primitives, handlers, utils } from '@nodeblocks/backend-sdk';

const { NodeblocksError, AsyncRouteHandler, RouteHandlerPayload } = primitives;
const { mergeData } = handlers;
const { createBaseEntity } = utils;

// Async handler for database operations
export const createUser: AsyncRouteHandler<
Result<RouteHandlerPayload, NodeblocksError>
> = async (payload) => {
const { params, context } = payload;

if (!params.requestBody || Object.keys(params.requestBody).length === 0) {
return err(
new NodeblocksError(400, 'Request body is required', 'createUser')
);
}

const baseEntity = createBaseEntity(params.requestBody);
try {
const createdUser = await context.db.users.insertOne(baseEntity);

if (!createdUser.insertedId) {
return err(
new NodeblocksError(400, 'Failed to create user', 'createUser')
);
}

return ok(mergeData(payload, { userId: baseEntity.id }));
} catch (_error) {
return err(new NodeblocksError(500, 'Failed to create user', 'createUser'));
}
};

// Sync handler for data transformation
export const formatUserData: RouteHandler<
Result<RouteHandlerPayload, NodeblocksError>
> = (payload) => {
const { requestBody } = payload.params;

const user = {
name: `${requestBody.firstName} ${requestBody.lastName}`
};

return ok(mergeData(payload, { user }));
};

Note: Handlers can be either synchronous (RouteHandler) or asynchronous (AsyncRouteHandler) depending on whether they perform async operations like database calls or API requests.

Terminator Handlersโ€‹

Terminator handlers are the final handlers in every handler chain. They receive a Result from previous handlers, check for errors, and format the final HTTP response:

import { primitives } from '@nodeblocks/backend-sdk';
import { Result } from 'neverthrow';

const { NodeblocksError, RouteHandlerPayload } = primitives;

export const normalizeUserTerminator = (
result: Result<RouteHandlerPayload, NodeblocksError>
) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;

if (!context.data?.user) {
throw new NodeblocksError(
500,
'Unknown error normalizing user',
'normalizeUserTerminator'
);
}
const { _id, ...user } = context.data.user;
return user;
};

Key Pattern: Terminator handlers always:

  1. Check result.isErr() and throw result.error for failed operations
  2. Extract payload with result.value and get data from payload.context.data
  3. Return the final formatted response object (not a Result type)

๐Ÿ”„ Payload Structureโ€‹

Every handler receives a payload object with the following structure:

interface HandlerPayload {
params: {
requestBody?: Record<string, any>; // POST/PATCH request body
requestParams?: Record<string, any>; // URL parameters (:userId)
requestQuery?: Record<string, any>; // Query string parameters
};
context: {
db: any;
mailService?: MailService;
// request: express.Request; disabled
[entityName: string]: any;
};
logger?: Logger;
[prop: string]: any;
}

๐Ÿง‘โ€๐Ÿ’ป Complete Handler Chain Exampleโ€‹

Here's how handlers and terminator handlers work together in a complete chain:

import { primitives, handlers } from '@nodeblocks/backend-sdk';
import { ok, err, Result } from 'neverthrow';

const { AsyncRouteHandler, RouteHandlerPayload, NodeblocksError, lift } = primitives;
const { mergeData } = handlers;

// Handler: Database operation (returns Result)
export const getUserById: AsyncRouteHandler<
Result<RouteHandlerPayload, NodeblocksError>
> = async (payload) => {
const { context, params } = payload;
const userId = context.data?.userId || params.requestParams?.userId;

if (!userId) {
return err(new NodeblocksError(400, 'User ID is required', 'getUserById'));
}

try {
const user = await context.db.users.findOne({ id: String(userId) });
if (!user) {
return err(new NodeblocksError(404, 'User not found', 'getUserById'));
}
return ok(mergeData(payload, { user }));
} catch (_error) {
return err(new NodeblocksError(500, 'Failed to get user', 'getUserById'));
}
};

// Terminator handler: Format final response (handles Result, returns formatted data)
export const sanitizeUserTerminator = (result: Result<RouteHandlerPayload, NodeblocksError>) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;

if (!context.data?.user) {
throw new NodeblocksError(500, 'User data not available', 'sanitizeUserTerminator');
}

return {
id: context.data.user.id,
email: context.data.user.email
};
};

// How they're used together in a route
export const getUserRoute = withRoute({
method: 'GET',
path: '/users/:userId',
handler: compose(
getUserById, // Handler: gets user from DB
lift(sanitizeUserTerminator) // Terminator: formats final response
),
});

The handler component works closely with several utility functions:


โžก๏ธ Nextโ€‹

Learn about Schema ยป to see how data is shaped.