🔧 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.
1️⃣ Create Project Directory
First, create a new directory for your custom-service project and navigate into it:
mkdir custom-service
cd custom-service
2️⃣ Install Dependencies
📦 Required Packages: This example directly imports Express, the SDK, ramda, neverthrow, and MongoDB types. Make sure to install them:
npm install @nodeblocks/backend-sdk express ramda neverthrow mongodb
npm install --save-dev typescript @types/express @types/node @types/ramda
3️⃣ Initialize TypeScript
Create a TypeScript configuration file to enable proper compilation and type checking:
npx tsc --init
⚙️ Module setup: The bootstrap example uses top-level await. Run it as an ESM module (for example with module: "NodeNext" in tsconfig.json and "type": "module" in package.json) or move the bootstrap code into an async function main().
Also to make your environment compatible with this tutorial, add these options to to your tsconfig.json:
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
4️⃣ Create Service Architecture
In order to build the service, we will implement the following core components:
- Schemas - Data validation and TypeScript types
- Blocks - Business logic functions
- Routes - HTTP endpoint definitions
- Features - Composed schemas + routes
- Service - Factory function that wires everything together
Create main source directory and folders for the components inside it:
mkdir src
cd src
mkdir schemas
mkdir routes
mkdir blocks
mkdir features
mkdir services
5️⃣ 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), identityId (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'},
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 getReviewSchema = withSchema({
parameters: [{...reviewIdPathParameter}],
});
6️⃣ 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.
import {Result, ok, err} from 'neverthrow';
import {Collection, type WithId, type 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.
* Returns the created review id as a string.
*/
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>, 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));
}
7️⃣ 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.
import {primitives, validators} from '@nodeblocks/backend-sdk';
import {
createReview,
getReviewById,
findReviews,
normalizeReview,
normalizeReviews,
ReviewNotFoundBlockError,
ReviewBlockError,
} from '../blocks/review.ts';
const {compose, flatMapAsync, lift, withLogging, withRoute, applyPayloadArgs, orThrow} = primitives;
const {isAuthenticated, isSelf} = validators;
export const createReviewRoute = withRoute({
method: 'POST',
path: '/reviews',
validators: [
isAuthenticated(),
// identityId comes from the request body for POST /reviews
isSelf(['params', 'requestBody', 'identityId']),
],
handler: compose(
withLogging(
applyPayloadArgs(
createReview,
[
['context', 'db', 'reviews'],
['params', 'requestBody'],
],
'createdReview',
),
),
// createReview stores a string id under context.data.createdReview
flatMapAsync(
withLogging(
applyPayloadArgs(
getReviewById,
[
['context', 'db', 'reviews'],
['context', 'data', 'createdReview'],
],
'review',
),
),
),
flatMapAsync(applyPayloadArgs(normalizeReview, [['context', 'data', 'review']], 'normalizedReview')),
lift(
withLogging(
orThrow(
[
[ReviewNotFoundBlockError, 404],
[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(
[
[ReviewNotFoundBlockError, 404],
[ReviewBlockError, 500],
],
[['context', 'data', 'normalizedReview'], 200],
),
),
),
),
});
export const listReviewsRoute = withRoute({
method: 'GET',
path: '/reviews',
validators: [],
handler: compose(
withLogging(applyPayloadArgs(findReviews, [['context', 'db', 'reviews']], 'reviews')),
flatMapAsync(applyPayloadArgs(normalizeReviews, [['context', 'data', 'reviews']], 'normalizedReviews')),
lift(withLogging(orThrow([[ReviewBlockError, 500]], [['context', 'data', 'normalizedReviews'], 200]))),
),
});
8️⃣ 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, getReviewSchema} from '../schemas/review.ts';
const {compose} = primitives;
export const createReviewFeature = compose(createReviewSchema, createReviewRoute);
export const getReviewFeature = compose(getReviewSchema, getReviewRoute);
export const listReviewsFeature = listReviewsRoute;
9️⃣ 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 configuration and returns a complete Express router.
import {partial} from 'ramda';
import {primitives} from '@nodeblocks/backend-sdk';
import {createReviewFeature, getReviewFeature, listReviewsFeature} from '../features/review.ts';
const {compose, defService} = primitives;
export const reviewService: primitives.Service = (dataStores, configuration) =>
defService(
partial(compose(createReviewFeature, getReviewFeature, listReviewsFeature), [{dataStores, configuration}]),
);
🔟 Use Service
Finally, create or update your index.ts file in the src directory.
This is where we wire up authentication and our review service with Express and MongoDB, creating the actual running application.
import express from 'express';
import {middlewares, services, drivers} from '@nodeblocks/backend-sdk';
import {reviewService} from './services/review.ts';
const {withMongo} = drivers;
const {nodeBlocksErrorMiddleware} = middlewares;
const connectToDatabase = withMongo('mongodb://localhost:27017/?authSource=admin', 'dev', 'user', 'password');
const {authService, profileService} = services;
async function main() {
// Authentication Service - handles registration, login, and token management
express()
.use(
authService({
...(await connectToDatabase('identities')),
...(await connectToDatabase('refreshtokens')),
}, {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
identity: {
typeIds: {
admin: '100',
guest: '000',
regular: '010',
},
},
}),
.use(
reviewService(await connectToDatabase('reviews'), {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
}),
)
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running on http://localhost:8089'));
}
main().catch(error => {
console.error(error);
process.exit(1);
});
Note:
POST /reviewsusesisAuthenticated()andisSelf(...), so create requests need a valid user access token whoseidentityIdmatches the body. This sample validates tokens with the suppliedauthSecrets, but does not issue them. That is why we mountservices.authService(with its requiredidentitiesand, when applicable,onetimetokensdatastores).
1️⃣1️⃣ Setup MongoDB with Docker Compose
Create docker-compose.yml in your project root directory with following content:
services:
mongodb:
image: mongo:7
container_name: mongodb
ports:
- '27017:27017'
restart: always
environment:
MONGO_INITDB_DATABASE: dev
MONGO_INITDB_ROOT_USERNAME: user
MONGO_INITDB_ROOT_PASSWORD: password
💡 Note:
MONGO_INITDB_ROOT_*creates a root user in theadmindatabase. ThewithMongoconnection URL below includesauthSource=adminso authentication succeeds when connecting to thedevdatabase. Username and password must match the Docker Compose values.
Start MongoDB in detached mode:
docker compose up -d
Build and Run the Server
Compile the TypeScript code and start the server:
npx tsc
node index.js
You should see the following output:
Server running on http://localhost:8089
🧪 Testing the Service
Step 1: Register
First, create an account through the authentication service:
curl -X POST http://localhost:8089/auth/register \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "securepassword123"
}'
You should receive a 201 Created response with an empty body.
Note: Registration does not return the identity id in the response body. Use the
idfield from the login response (Step 2) asidentityIdwhen creating a profile.
Step 2: Login to Get Access Token
Login with the registered identity to get an access token and identity id:
curl -X POST http://localhost:8089/auth/login \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "securepassword123"
}'
You should receive a response with an access token, refresh token, and identity id:
{
"accessToken": "77c268e06d87594067d91c5b2f08e532...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"id": "f792cde5-958b-49e9-bf83-13d59c1e35c0"
}
Step 3: Use the Access Token
# Create a review (required fields only)
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{"productId":"123","identityId": <identity-id>,"rating":5}'
# Create a review with optional comment
curl -X POST http://localhost:8089/reviews \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{"productId":"123","identityId":<identity-id>,"rating":5,"comment":"Great product!"}'
# Get a review
curl "http://localhost:8089/reviews/<reviewId>"
# List reviews
curl http://localhost:8089/reviews
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/?authSource=admin', 'dev', 'user', 'password');
const reviewServiceConfig = {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
};
// ❌ Missing error middleware
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.listen(8089, () => console.log('Server running'));
// ✅ Correct with error middleware
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.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/?authSource=admin', 'dev', 'user', 'password');
const reviewServiceConfig = {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
};
// ✅ Correct order
express()
.use(reviewService(await connectToDatabase('reviews'), reviewServiceConfig))
.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
- Composite setup - Combine this service with auth and profile features (Creating a Composite Service)
- Custom storage - Swap MongoDB for a custom adapter (Using a Custom DataStore)