Skip to main content
Version: 0.4.2 (Previous)

🔧 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 and neverthrow 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:

  1. Schemas - Data validation and TypeScript types
  2. Handlers - Business logic functions
  3. Routes - HTTP endpoint definitions
  4. Features - Composed schemas + routes
  5. 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.

src/schemas/review.ts
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.

src/handlers/review.ts
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.

src/routes/review.ts
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

src/features/review.ts
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.

src/services/review.ts
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.

src/index.ts
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