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