Skip to main content
Version: ๐Ÿšง Canary

๐Ÿ”ง 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' },
identityId: { 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', 'identityId', '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 Blocksโ€‹

Next, create a review.ts file inside the src/blocks directory.
We'll add blocks that contain the business logic for our review service. These functions handle creating reviews, retrieving them by ID, and normalizing the data format.

src/blocks/review.ts
import { Result, ok, err } from 'neverthrow';
import { Collection, WithId, Document } from 'mongodb';
import { utils, primitives } from '@nodeblocks/backend-sdk';

const { createBaseEntity } = utils;
const { BlockError, hasValue } = primitives;

export class ReviewBlockError extends BlockError {}
export class ReviewNotFoundBlockError extends ReviewBlockError {}

/**
* Creates a new review in the database.
*/
export async function createReview(
reviewsCollection: Collection,
reviewData: Record<string, unknown>
): Promise<Result<string, ReviewBlockError>> {
try {
const entity = createBaseEntity(reviewData);
const result = await reviewsCollection.insertOne(entity);

if (!result.insertedId) {
return err(new ReviewBlockError('Failed to create review'));
}

return ok(entity.id);
} catch (error) {
return err(new ReviewBlockError('Failed to create review'));
}
}

/**
* Retrieves a review by ID from the database.
*/
export async function getReviewById(
reviewsCollection: Collection,
reviewId: string
): Promise<Result<WithId<Document> | null, ReviewBlockError>> {
try {
const result = await reviewsCollection.findOne({ id: String(reviewId) });
if (!hasValue(result)) {
return err(new ReviewNotFoundBlockError('Review not found'));
}
return ok(result);
} catch (error) {
return err(new ReviewBlockError('Failed to get review'));
}
}

/**
* Finds multiple reviews with optional filtering.
*/
export async function findReviews(
reviewsCollection: Collection,
filter: Record<string, unknown> = {}
): Promise<Result<WithId<Document>[], ReviewBlockError>> {
try {
const reviews = await reviewsCollection.find(filter).toArray();
return ok(reviews);
} catch (error) {
return err(new ReviewBlockError('Failed to find reviews'));
}
}

/**
* Normalizes a single review by removing MongoDB-specific fields.
*/
export function normalizeReview<T extends Record<string, unknown>>({
_id,
...object
}: T): Result<Omit<T, '_id'>, Error> {
return ok(object);
}

/**
* Normalizes an array of reviews by removing MongoDB-specific fields.
*/
export function normalizeReviews<T extends Record<string, unknown>>(
objects: T[]
): Result<Omit<T, '_id'>[], Error> {
return ok(objects.map(({ _id, ...object }) => object));
}


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

Create a review.ts file inside the src/routes directory.
Routes define HTTP endpoints and connect them to our blocks. We'll create three routes: one for creating reviews and two to retrieve them.

src/routes/review.ts
import { primitives, validators } from '@nodeblocks/backend-sdk';
const {
compose,
flatMapAsync,
lift,
withLogging,
withRoute,
applyPayloadArgs,
orThrow,
} = primitives;
import {
createReview,
getReviewById,
findReviews,
normalizeReview,
normalizeReviews,
ReviewNotFoundBlockError,
ReviewBlockError,
} from '../blocks/review';

const { isAuthenticated, isSelf } = validators;

export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
validators: [
isAuthenticated(),
isSelf(['params', 'requestParams', 'identityId']),
],
handler: compose(
withLogging(
applyPayloadArgs(
createReview,
[
['context', 'db', 'reviews'],
['params', 'requestBody'],
],
'createdReview'
)
),
flatMapAsync(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['context', 'data', 'createdReview', 'id'],
],
'review'
)
)
),
flatMapAsync(
applyPayloadArgs(
normalizeReview,
[['context', 'data', 'review']],
'normalizedReview'
)
),
lift(
withLogging(
orThrow(
[[ReviewBlockError, 500]],
[['context', 'data', 'normalizedReview'], 200]
)
)
)
),
});

export const getReviewRoute = withRoute({
method: 'GET',
path: '/reviews/:reviewId',
validators: [],
handler: compose(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['params', 'requestParams', 'reviewId'],
],
'review'
)
),
flatMapAsync(
applyPayloadArgs(
normalizeReview,
[['context', 'data', 'review']],
'normalizedReview'
)
),
lift(
withLogging(
orThrow(
[
[ReviewBlockError, 500],
[ReviewNotFoundBlockError, 404],
],
[['context', 'data', 'normalizedReview'], 200]
)
)
)
),
});

export const listReviewsRoute = withRoute({
method: 'GET',
path: '/reviews',
validators: [],
handler: compose(
withLogging(
applyPayloadArgs(
findReviews,
[
['context', 'db', 'reviews'],
[], // empty filter for now
],
'reviews'
)
),
flatMapAsync(
applyPayloadArgs(
normalizeReviews,
[['context', 'data', 'reviews']],
'normalizedReviews'
)
),
lift(
withLogging(
orThrow(
[[ReviewBlockError, 500]],
[['context', 'data', 'normalizedReviews'], 200]
)
)
)
),
});

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 { withMongo } = drivers;
const { nodeBlocksErrorMiddleware } = middlewares;

const connectToDatabase = withMongo('mongodb://localhost:27017', 'dev', 'user', 'password');

express()
.use(reviewService(await connectToDatabase('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","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5}'

# Create a review with optional comment
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","rating":5,"comment":"Great product!"}'

# Get a review
curl http://localhost:8089/reviews/[reviewId]

Expected Response:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"identityId": "6dcdd50a-e0e6-445d-82e1-3da35bc2d149",
"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:

const connectToDatabase = withMongo('mongodb://localhost:27017', 'dev', 'user', 'password');

// โŒ Missing error middleware
express()
.use(reviewService(await connectToDatabase('reviews'), {}))
.listen(8089, () => console.log('Server running'));

// โœ… Correct with error middleware
express()
.use(reviewService(await connectToDatabase('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:

const connectToDatabase = withMongo('mongodb://localhost:27017', 'dev', 'user', 'password');

// โœ… Correct order
express()
.use(reviewService(await connectToDatabase('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