Skip to content
@tde.io/plexis  ·  npm install @tde.io/plexis  ·  npm  ·  GitHub

API Route

In this guide a single POST /api/records endpoint is backed by a Plexis pipeline whose nodes form the handler chain — validate → service → persist → respond — with forks routing failures to an error-response path. A small request domain tracks the lifecycle state of each request independently.

Each stage of request processing is a pipeline node. Forks decide which path the request follows based on the outcome of the preceding node.

import { definePipeline, node, action, fork, target, terminal } from '@tde.io/plexis';
type RequestCtx = {
body: Record<string, unknown>;
userId?: string;
validated: boolean;
recordId?: string;
response?: { status: number; body: unknown };
error?: string;
};
const handleRequest = definePipeline<RequestCtx>('handle-request', () => {
node('validate', () => {
action(async (ctx) => {
if (!ctx.body.data) return { error: 'Missing required field: data' };
return { userId: ctx.body.userId as string, validated: true };
});
fork('error', target('error-response'), (ctx) => !ctx.validated);
fork('next', target('service'));
});
node('service', () => {
action(async (ctx) => ({ recordId: `rec_${ctx.userId}` }));
fork('error', target('error-response'), (ctx) => !!ctx.error);
fork('next', target('persist'));
});
node('persist', () => {
action(async (ctx) => ({}));
fork('next', target('respond'));
});
node('respond', terminal(
async (ctx) => ({
response: { status: 201, body: { id: ctx.recordId, ok: true } },
})
));
node('error-response', terminal(
async (ctx) => ({
response: { status: 400, body: { error: ctx.error ?? 'Bad request' } },
})
));
return { initial: 'validate' };
});

A new domain instance is created per request so concurrent requests do not share state. The domain tracks where each request is in its lifecycle independently from the handler logic.

import { defineDomain, when, on, terminal } from '@tde.io/plexis';
type RequestLifecycle = { requestId: string };
function createRequestDomain(requestId: string) {
return defineDomain<RequestLifecycle>('request', () => {
when('pending', () => {
on('start', target('processing'));
});
when('processing', () => {
on('complete', target('complete'));
on('fail', target('failed'));
});
when('complete', terminal());
when('failed', terminal());
return { context: { requestId }, initial: 'pending' };
});
}

The pipeline and domain compose inside a standard Node.js http request handler. Because handleRequest is just a function that accepts a context object and returns a result, the same pipeline plugs into any framework handler — Express, Hono, Fastify — without modification.

import http from 'node:http';
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/api/records') {
res.writeHead(404).end();
return;
}
let raw = '';
for await (const chunk of req) raw += chunk;
const body = JSON.parse(raw) as Record<string, unknown>;
const requestDomain = createRequestDomain(crypto.randomUUID());
await requestDomain.follow('start');
const result = await handleRequest.run({ body, validated: false });
const { status, body: resBody } = result.context.response!;
if (status === 201) {
await requestDomain.follow('complete');
} else {
await requestDomain.follow('fail');
}
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(resBody));
});
server.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});

Start the server:

Terminal window
node dist/server.js

Post a valid request:

Terminal window
curl -X POST http://localhost:3000/api/records \
-H 'Content-Type: application/json' \
-d '{"userId":"alice","data":{"value":42}}'

Post an invalid request (missing data):

Terminal window
curl -X POST http://localhost:3000/api/records \
-H 'Content-Type: application/json' \
-d '{"userId":"alice"}'

The missing field routes the pipeline to the error-response terminal node, which writes a 400 response. The request domain follows fail and lands on the failed terminal state.