🔧 Creating a Custom Service
This guide walks you through creating a complete custom service using the Nodeblocks SDK. We'll build a Review Service that demonstrates all the key patterns and components. This simple example will allow users to create and retrieve product reviews.
📦 Required Packages: This example uses
ramda
andneverthrow
packages. Make sure to install them:npm install ramda neverthrow
🏗️ Service Architecture
In order to build the service, we will implement the following core components:
- Schemas - Data validation and TypeScript types
- Handlers - Business logic functions
- Routes - HTTP endpoint definitions
- Features - Composed schemas + routes
- Service - Factory function that wires everything together
1️⃣ Define Schema
First, create a review.ts
file inside the src/schemas
directory.
Here we define a schema that contains the required fields productId
(string) and rating
(number from 1-5), along with an optional comment
field.
import { primitives } from '@nodeblocks/backend-sdk';
const { withSchema } = primitives;
const reviewIdPathParameter: primitives.OpenAPIParameter = {
in: 'path',
name: 'reviewId',
required: true,
schema: {
type: 'string',
},
};
export const reviewSchema: primitives.SchemaDefinition = {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: {
productId: { type: 'string' },
rating: { type: 'number', minimum: 1, maximum: 5 },
comment: { type: 'string' },
},
type: 'object',
};
export const createReviewSchema = withSchema({
requestBody: {
content: {
'application/json': {
schema: { ...reviewSchema, required: ['productId', 'rating'] },
},
},
required: true,
},
});
export const updateReviewSchema = withSchema({
parameters: [{ ...reviewIdPathParameter }],
requestBody: {
content: {
'application/json': {
schema: {
additionalProperties: false,
properties: {
comment: { type: 'string' },
},
required: [],
type: 'object',
},
},
},
required: true,
},
});
2️⃣ Create Handlers
Next, create a review.ts
file inside the src/handlers
directory.
We'll add handlers that contain the business logic for our review service. These functions handle creating reviews, retrieving them by ID, and normalizing the data format.
import { Result, ok, err } from 'neverthrow';
import { primitives, handlers, utils } from '@nodeblocks/backend-sdk';
const { NodeblocksError } = primitives;
const { mergeData } = handlers;
const { createBaseEntity } = utils;
export const startTransaction = async (
payload: primitives.RouteHandlerPayload
) => {
const { context } = payload;
const session = await context.db.startSession();
session.startTransaction();
return ok(mergeData(payload, { session }));
};
export const commitTransaction = async (session: any) => {
await session.commitTransaction();
await session.endSession();
};
export const createReview: primitives.AsyncRouteHandler<
Result<primitives.RouteHandlerPayload, Error>
> = async payload => {
const { params, context } = payload;
const entity = createBaseEntity(params.requestBody || {});
const res = await context.db.reviews.insertOne(entity);
if (!res.insertedId)
return err(new NodeblocksError(400, 'Failed to create review'));
return ok(mergeData(payload, { reviewId: entity.id }));
};
export const getReviewById: primitives.AsyncRouteHandler<
Result<primitives.RouteHandlerPayload, Error>
> = async payload => {
const { context, params } = payload;
const id = context.data?.reviewId || params.requestParams?.reviewId;
const review = await context.db.reviews.findOne({ id });
if (!review) return err(new NodeblocksError(404, 'Review not found'));
return ok(mergeData(payload, { review }));
};
export const normalizeReviewTerminator = (
result: Result<primitives.RouteHandlerPayload, Error>
) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
if (!context.data?.review) {
throw new NodeblocksError(
500,
'Unknown error normalizing review',
'normalizeReview'
);
}
const { _id, ...review } = context.data.review;
return review;
};
export const normalizeReviewListTerminator = (
result: Result<primitives.RouteHandlerPayload, Error>
) => {
if (result.isErr()) {
throw result.error;
}
const payload = result.value;
const { context } = payload;
const reviews = context.data.reviews.map(
(rawReview: { _id: string; [key: string]: unknown }) => {
const { _id, ...review } = rawReview;
return review;
}
);
return reviews;
};
export const listReviews: primitives.AsyncRouteHandler<
Result<primitives.RouteHandlerPayload, Error>
> = async payload => {
const { context } = payload;
const reviews = await context.db.reviews.find({}).toArray();
return ok(mergeData(payload, { reviews }));
};
3️⃣ Define Routes
Create a review.ts
file inside the src/routes
directory.
Routes define HTTP endpoints and connect them to our handlers. We'll create three routes: one for creating reviews and two to retrieve them.
import { primitives } from '@nodeblocks/backend-sdk';
const { compose, flatMapAsync, lift, withLogging, withRoute } = primitives;
import {
createReview,
getReviewById,
normalizeReviewTerminator,
normalizeReviewListTerminator,
listReviews,
} from '../handlers/review';
export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
validators: [],
handler: compose(
createReview,
flatMapAsync(getReviewById),
lift(normalizeReviewTerminator)
),
});
export const getReviewRoute = withRoute({
method: 'GET',
path: '/reviews/:reviewId',
validators: [],
handler: compose(getReviewById, lift(normalizeReviewTerminator)),
});
export const listReviewsRoute = withRoute({
method: 'GET',
path: '/reviews',
validators: [],
handler: compose(
withLogging(listReviews, 'debug'),
lift(normalizeReviewListTerminator)
),
});
4️⃣ Compose Features
Create a review.ts
file inside the src/features
directory.
Features combine schemas with routes to create complete API endpoints. This step links schema validation logic with routes
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReviewRoute,
getReviewRoute,
listReviewsRoute,
} from '../routes/review';
import { createReviewSchema } from '../schemas/review';
const { compose } = primitives;
export const createReviewFeature = compose(
createReviewSchema,
createReviewRoute
);
export const getReviewFeature = getReviewRoute;
export const listReviewsFeature = listReviewsRoute;
5️⃣ Create Service
Create a review.ts
file inside the src/services
directory.
The service is a factory function that brings everything together. It takes database connections and returns a complete service ready to be used by Express.
import { partial } from 'lodash';
import { primitives } from '@nodeblocks/backend-sdk';
import {
createReviewFeature,
getReviewFeature,
listReviewsFeature,
} from '../features/review';
const { compose, defService } = primitives;
export const reviewService: primitives.Service = db =>
defService(
partial(
compose(getReviewFeature, createReviewFeature, listReviewsFeature),
{ dataStores: db }
)
);
6️⃣ Use Service
Finally, create or update your index.ts
file in the src
directory.
This is where we wire up our review service with Express and MongoDB, creating the actual running application.
import express from 'express';
import { middlewares, drivers } from '@nodeblocks/backend-sdk';
import { reviewService } from './services/review';
const { getMongoClient } = drivers;
const { nodeBlocksErrorMiddleware } = middlewares;
const client = getMongoClient('mongodb://localhost:27017', 'dev');
express()
.use(reviewService({ reviews: client.collection('reviews') }, {})) // {} - empty config
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));
🧪 Testing the Service
# Create a review (required fields only)
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-d '{"productId":"123","rating":5}'
# Create a review with optional comment
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-d '{"productId":"123","rating":5,"comment":"Great product!"}'
# Get a review
curl http://localhost:8089/reviews/[reviewId]
Expected Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"productId": "123",
"rating": 5,
"comment": "Great product!",
"createdAt": "2024-01-01T12:00:00.000Z",
"updatedAt": "2024-01-01T12:00:00.000Z"
}
🔧 Troubleshooting
Getting HTML Instead of JSON Errors?
If your API returns HTML error pages instead of JSON, you likely forgot the error middleware:
// ❌ Missing error middleware
express()
.use(reviewService({ reviews: client.collection('reviews') }, {}))
.listen(8089, () => console.log('Server running'));
// ✅ Correct with error middleware
express()
.use(reviewService({ reviews: client.collection('reviews') }, {}))
.use(nodeBlocksErrorMiddleware()) // Add this line!
.listen(8089, () => console.log('Server running'));
Error Middleware Must Come Last
The error middleware must be added after all your services and routes:
// ✅ Correct order
express()
.use(reviewService({ reviews: client.collection('reviews') }, {}))
.use(nodeBlocksErrorMiddleware()) // Last!
.listen(8089, () => console.log('Server running'));
➡️ Next Steps
Now you can practice by adding more functionality to your review service. Try implementing the following endpoints that might be required for a review service:
- Update and Delete operations - Add PATCH and DELETE endpoints to modify and remove reviews