メインコンテンツまでスキップ
バージョン: 🚧 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 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 @types/express @types/node @types/ramda

⚙️ 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().


🏗️ Service Architecture

In order to build the service, we will implement the following core components:

  1. Schemas - Data validation and TypeScript types
  2. Blocks - 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), identityId (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 getReviewSchema = withSchema({
parameters: [{...reviewIdPathParameter}],
});

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.
* 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));
}

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';
import {
createReview,
getReviewById,
findReviews,
normalizeReview,
normalizeReviews,
ReviewNotFoundBlockError,
ReviewBlockError,
} from '../blocks/review';

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]))),
),
});

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, getReviewSchema} from '../schemas/review';

const {compose} = primitives;

export const createReviewFeature = compose(createReviewSchema, createReviewRoute);
export const getReviewFeature = compose(getReviewSchema, 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 configuration and returns a complete Express router.

src/services/review.ts
import {partial} from 'ramda';
import {primitives} from '@nodeblocks/backend-sdk';
import {createReviewFeature, getReviewFeature, listReviewsFeature} from '../features/review';

const {compose, defService} = primitives;

export const reviewService: primitives.Service = (dataStores, configuration) =>
defService(
partial(compose(createReviewFeature, getReviewFeature, listReviewsFeature), [{dataStores, configuration}]),
);

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/?authSource=admin', 'dev', 'user', 'password');

express()
.use(
reviewService(await connectToDatabase('reviews'), {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
}),
)
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));

Note: POST /reviews uses isAuthenticated() and isSelf(...), so create requests need a valid user access token whose identityId matches the body. This sample validates tokens with the supplied authSecrets, but does not issue them. Mount services.authService (with its required identities and, when applicable, onetimetokens datastores) or use another trusted JWT issuer before testing the protected endpoint.


🧪 Testing the Service

# 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":"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' \
-H 'Authorization: Bearer <access-token>' \
-d '{"productId":"123","identityId":"6dcdd50a-e0e6-445d-82e1-3da35bc2d149","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)