Skip to main content
Version: ๐Ÿšง Canary

๐Ÿ›ฃ๏ธ Route

A route bundles an HTTP method, path, validators and a business logic together. Nodeblocks provides the withRoute helper which stores metadata and plugs into Express automatically when the service router is generated. Routes support both HTTP and WebSocket protocols for real-time communication.


1๏ธโƒฃ Anatomyโ€‹

A route is defined using the withRoute helper, which bundles HTTP method, path, validators, and handler logic:

src/routes/user.ts
import { primitives, blocks, validators } from '@nodeblocks/backend-sdk';

const { compose, withRoute } = primitives;
const { isAuthenticated } = validators;
const { getUserById } = blocks;

export const getUserRoute = withRoute({
method: 'GET',
path: '/users/:userId',
validators: [isAuthenticated()],
handler: compose(/* handler chain */),
});

Below is the minimal shape returned by withRoute.

interface Route {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // Required for HTTP routes
protocol?: 'http' | 'ws'; // Specify protocol (defaults to 'http')
path: string; // Express-style path, supports :params
validators?: Validator[]; // optional list executed **before** handler chain
handler: RouteHandler; // composable function chain
}

Protocolโ€‹

Routes support two protocols:

ProtocolPurposeHandler Return Type
httpTraditional REST API endpointsData object or Result
wsReal-time WebSocket connectionsRxJS Subject

Methodโ€‹

The HTTP verb that follows REST conventions (only for HTTP routes):

VerbPurpose
GETRead/retrieve resources
POSTCreate new resources
PATCHPartially update resources
DELETERemove resources

Pathโ€‹

An Express-style template. Use plural nouns for collections (/users) and sub-paths for actions (/users/:id/lock). Dynamic parts become requestParams.

Validatorsโ€‹

Synchronous or async hooks that run before any database work. They receive the same payload object.
The idiomatic way to reject a request is to throw new NodeblocksError(status, message):

import {NodeblocksError} from '../primitives/error';

export const validateEmail: Validator = ({ params }) => {
if (!params.requestBody.email?.includes('@')) {
throw new NodeblocksError(400, 'Invalid email');
}
};

Handlerโ€‹

Routes delegate their business logic to handler chainsโ€”composed functions that process requests and format responses. Every handler chain follows this pattern:

  1. Blocks or Handlers - Perform business logic (database operations, validations, transformations)
  2. Terminator Handler - Final handler that formats the HTTP response

The recommended approach is to compose routes using blocks directly with applyPayloadArgs. Blocks are pure functions that contain business logic and can be composed directly in routes:

src/routes/user.ts
import { primitives, blocks, validators } from '@nodeblocks/backend-sdk';

const { compose, flatMapAsync, withRoute, lift, applyPayloadArgs, orThrow } = primitives;
const { isAuthenticated } = validators;
const { getUserById, normalizeUser, UserNotFoundBlockError, UserBlockError } = blocks;

export const getUserRoute = withRoute({
method: 'GET',
path: '/users/:userId',
validators: [isAuthenticated()],
handler: compose(
// Use block directly with applyPayloadArgs
applyPayloadArgs(
getUserById,
[
['context', 'db', 'users'],
['params', 'requestParams', 'userId']
],
'user'
),
// Chain additional blocks
flatMapAsync(
applyPayloadArgs(
normalizeUser,
[['context', 'data', 'user']],
'normalizedUser'
)
),
// Terminator formats final response with error mapping
lift(
orThrow(
[
[UserNotFoundBlockError, 404],
[UserBlockError, 500],
],
[['context', 'data', 'normalizedUser'], 200]
)
)
),
});

Key benefits of using blocks:

  • Pure functions - easier to test without payload mocking
  • Reusable - same blocks can be used across different routes
  • Composable - chain multiple blocks together seamlessly
  • Type-safe - better TypeScript inference

Legacy: Composing with Handlersโ€‹

The legacy approach uses custom handlers that wrap business logic. This is still supported but not recommended for new code:

src/routes/user.ts (legacy)
import { primitives, handlers } from '@nodeblocks/backend-sdk';
import {createUser, getUserById, normalizeUserTerminator} from '../handlers';

const { compose, flatMapAsync, withRoute, lift } = primitives;

export const createUserRoute = withRoute({
method: 'POST',
path: '/users',
validators: [isAuthenticated()],
handler: compose(
createUser, // Handler: write to DB
flatMapAsync(getUserById), // Handler: read back the created entity
lift(normalizeUserTerminator) // Terminator handler: format final HTTP response
),
});

Note: For detailed information on blocks, see Blocks ยป. For handler patterns, see Handler explanation ยป.


2๏ธโƒฃ Good Practicesโ€‹

  1. Path semantics โ€“ nouns for resources (/users), verbs for actions (/users/:id/lock).
  2. Complete handler chains โ€“ always end with a terminator handler that formats the final response.
  3. Use blocks โ€“ prefer composing blocks directly over custom handlers for better reusability and testability.
  4. Keep logic small โ€“ prefer multiple composables to a single monolith.
  5. Validators first โ€“ do lightweight checks before DB calls.
  6. Stateless โ€“ routes should not mutate global state; everything arrives via payload.
const { block1, block2, BlockError1, BlockError2 } = blocks;

handler: compose(
applyPayloadArgs(block1, [/* paths */], 'result1'), // Block: business logic
flatMapAsync(applyPayloadArgs(block2, [/* paths */], 'result2')), // Block: more logic
lift(orThrow([[BlockError1, 404], [BlockError2, 500]], [['context', 'data', 'result2']])) // Terminator: maps errors, formats response
)

Legacy: Handler Chain Patternโ€‹

handler: compose(
handler1, // Business logic: returns Result
flatMapAsync(handler2), // More business logic: returns Result
lift(terminatorHandler) // Response formatting: handles Result, returns data
)

3๏ธโƒฃ WebSocket Routesโ€‹

WebSocket routes enable real-time communication by returning RxJS Subjects instead of static data:

import { Subject } from 'rxjs';
import { primitives } from '@nodeblocks/backend-sdk';

const { withRoute } = primitives;

export const realtimeDataRoute = withRoute({
protocol: 'ws',
path: '/api/realtime',
handler: (payload) => {
const subject = new Subject();

// Setup real-time data source (e.g., database change stream)
const changeStream = payload.context.db.data.watch();

changeStream.on('change', (changeDoc) => {
subject.next({
type: 'data_change',
data: changeDoc.fullDocument,
timestamp: Date.now()
});
});

// Cleanup when connection closes
subject.subscribe({
complete: () => changeStream.close(),
error: () => changeStream.close()
});

return subject;
},
validators: [
// WebSocket routes can also use validators
(payload) => {
// Validate WebSocket connection headers, etc.
return Promise.resolve();
}
]
});

WebSocket vs HTTP Routesโ€‹

AspectHTTP RoutesWebSocket Routes
Protocol'http' (default)'ws'
MethodRequired (GET, POST, etc.)Not applicable
Handler ReturnData object or ResultRxJS Subject
CommunicationRequest-responseBidirectional streaming
Use CasesREST APIs, CRUD operationsReal-time updates, live data

โžก๏ธ Nextโ€‹

Learn about Feature ยป to see how to compose routes and schemas into reusable units, or explore WebSocket Services ยป for real-time functionality