Skip to main content
Version: 0.14.0 (Latest)

🔗 Creating a Composite Service

This guide demonstrates how to combine multiple Nodeblocks services into a single composite service. We'll build a Composite Auth + Profile Service that combines authentication and profile management functionality in one application. This pattern is useful when you want to share context between services or create a unified API.

📦 Required Packages: This example imports Express, the SDK, Ramda, and cookie-parser. Make sure to install them:

npm install express @nodeblocks/backend-sdk ramda cookie-parser
npm install --save-dev @types/node @types/express @types/ramda @types/cookie-parser

⚙️ Module setup: This 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().

🖼️ Avatar support: The profile routes in this sample normalize avatar URLs, so the example also uses createFileStorageDriver from the SDK.

🔐 Google Cloud credentials: createFileStorageDriver uses Google Cloud Storage under the hood, so your environment still needs valid Google Cloud credentials for signed URL generation.


🏗️ Service Architecture

The composite service pattern allows you to:

  1. Combine multiple services - Merge authentication and profile management
  2. Share context - Use the same datastore configuration and request context
  3. Unified middleware - Create a single Express middleware from multiple services
  4. Simplified deployment - Deploy multiple related services as one application

1️⃣ Understand the Components

Before building the composite service, let's understand what we're combining:

Authentication Service Features

  • Register credentials - Identity registration with email/password
  • Login with credentials - Authentication and token generation
  • Logout - Invalidate the current session
    • This guide intentionally excludes invitation, MFA, one-time-token, email verification, password reset, and OAuth flows.

Profile Service Features

  • Create profile - Create a new profile for an identity
  • Get profile - Retrieve a profile by ID
  • Edit profile - Update profile fields
  • Delete profile - Remove a profile
  • Find profiles - List and filter profiles
    • This guide intentionally excludes avatar upload URLs, identity lookup by identity ID, and follow/like flows.

2️⃣ Create Service Middleware

Create a compositeService.ts file that combines multiple service features into unified middleware:

src/compositeService.ts
import cookieParser from 'cookie-parser';
import express from 'express';
import {partial} from 'ramda';

import {middlewares, features, primitives, drivers, utils} from '@nodeblocks/backend-sdk';

const {withMongo, createFileStorageDriver} = drivers;
const {getCookieTokenInfo} = utils;

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

const fileStorageDriver = createFileStorageDriver(process.env.GCP_PROJECT_ID!, process.env.GCP_BUCKET_NAME!);

const {nodeBlocksErrorMiddleware} = middlewares;
const {defService, compose} = primitives;
const {
registerCredentialsFeature,
loginWithCredentialsFeature,
logoutFeature,
createProfileFeature,
getProfileFeature,
editProfileFeature,
deleteProfileFeature,
findProfilesFeature,
} = features;

const authServiceMiddleware = compose(registerCredentialsFeature, loginWithCredentialsFeature, logoutFeature);

const profileServiceMiddleware = compose(
createProfileFeature,
getProfileFeature,
editProfileFeature,
deleteProfileFeature,
findProfilesFeature,
);

const dataStores = {
...(await connectToDatabase('identities')),
...(await connectToDatabase('refreshtokens')),
...(await connectToDatabase('profiles')),
};

const configuration = {
// Use 'cookie' only when the host app also registers cookie-parser.
authMode: (process.env.AUTH_MODE === 'cookie' ? 'cookie' : 'bearer') as 'bearer' | 'cookie',
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
maxFailedLoginAttempts: 5,
accessTokenSignOptions: {expiresIn: '2h'},
refreshTokenSignOptions: {expiresIn: '2d'},
identity: {
typeIds: {
admin: '100',
guest: '000',
regular: '001',
},
},
};

const context = {
dataStores,
configuration,
fileStorageDriver,
// Feature composition does not configure this automatically as profileService does.
...(configuration.authMode === 'cookie' && {
authenticate: getCookieTokenInfo,
}),
};

const appMiddleware = compose(authServiceMiddleware, profileServiceMiddleware);

express()
// you can define services under a single namespace, e.g. /api/auth, /api/profiles
.use(cookieParser())
.use('/api', defService(partial(appMiddleware, [context])))
.use(nodeBlocksErrorMiddleware())
.listen(8089, () => console.log('Server running'));

3️⃣ Understanding the Pattern

Service Composition

The key to composite services is the compose function, which combines multiple features:

// Individual service middleware
const authServiceMiddleware = compose(registerCredentialsFeature, loginWithCredentialsFeature, logoutFeature);

const profileServiceMiddleware = compose(
createProfileFeature,
getProfileFeature,
editProfileFeature,
deleteProfileFeature,
findProfilesFeature,
);

// Combined middleware
const appMiddleware = compose(authServiceMiddleware, profileServiceMiddleware);

Shared Context

All services share the same context object, which includes:

  • Database collections for the auth and profile routes shown here (identities and profiles)
  • Nested configuration for authentication secrets and token options
  • A fileStorageDriver for avatar URL normalization in profile responses
  • An authenticate function when you opt into cookie authentication

If you later enable invitation-token registration or MFA / one-time-token login, add invitations and/or onetimetokens to the auth datastores as well.

const context = {
dataStores: {
...(await connectToDatabase('identities')),
...(await connectToDatabase('refreshtokens')),
...(await connectToDatabase('profiles')),
},
configuration: {
authSecrets: {
authEncSecret: 'your-encryption-secret',
authSignSecret: 'your-signing-secret',
},
maxFailedLoginAttempts: 5,
accessTokenSignOptions: {expiresIn: '2h'},
refreshTokenSignOptions: {expiresIn: '2d'},
identity: {
typeIds: {
admin: '100',
guest: '000',
regular: '001',
},
},
},
fileStorageDriver,
};

Partial Application

The partial function from Ramda pre-applies the context to the middleware:

defService(partial(appMiddleware, [context]));

This creates a service factory that's ready to be used by Express. Always pass the context as a one-element array: [{ dataStores, configuration, ... }].


4️⃣ API Endpoints

Your composite service will expose the following endpoints:

The examples use bearer tokens. To use cookie authentication, set configuration.authMode to 'cookie', register cookie-parser in the host app, and set context.authenticate to getCookieTokenInfo as shown above.

Authentication Endpoints

  • POST /api/auth/register - Register a new identity
  • POST /api/auth/login - Login with credentials
  • POST /api/auth/logout - Logout and invalidate the session
    • Requires authentication

Profile Management Endpoints

  • POST /api/profiles - Create a new profile
    • Requires authentication
    • Admin or the identity owner may create the profile
  • GET /api/profiles/:profileId - Get profile by ID
    • Requires authentication
    • Admin or the profile owner may read it
  • PATCH /api/profiles/:profileId - Update profile
    • Requires authentication
    • Admin or the profile owner may update it
  • DELETE /api/profiles/:profileId - Delete profile
    • Requires authentication
    • Admin or the profile owner may delete it
  • GET /api/profiles - List/filter profiles
    • Requires authentication
    • Admin only

5️⃣ Testing the Composite Service

# Register a new identity
curl -X POST http://localhost:8089/api/auth/register \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "securepass123"
}'

# Login with credentials
curl -X POST http://localhost:8089/api/auth/login \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "securepass123"
}'

# Create a profile (requires identityId from registration/login)
curl -X POST http://localhost:8089/api/profiles \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{
"identityId": "6dcdd50a-e0e6-445d-82e1-3da35bc2d149",
"name": "John Doe"
}'

# Get profile by ID
curl -X GET http://localhost:8089/api/profiles/PROFILE_ID \
-H 'Authorization: Bearer <access-token>'

# Update profile
curl -X PATCH http://localhost:8089/api/profiles/PROFILE_ID \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access-token>' \
-d '{
"name": "John Smith"
}'

# Delete profile
curl -X DELETE http://localhost:8089/api/profiles/PROFILE_ID \
-H 'Authorization: Bearer <access-token>'

6️⃣ Environment Configuration

For production, you should externalize your configuration:

src/config.ts
export const config = {
database: {
url: process.env.MONGODB_URI || 'mongodb://localhost:27017/?authSource=admin',
name: process.env.MONGODB_DB_NAME || 'dev',
user: process.env.DB_USER || 'user',
password: process.env.DB_PASSWORD || 'password',
},
storage: {
gcpProjectId: process.env.GCP_PROJECT_ID || 'your-gcp-project-id',
bucketName: process.env.GCP_BUCKET_NAME || 'your-bucket-name',
},
auth: {
encSecret: process.env.AUTH_ENC_SECRET || 'your-encryption-secret',
signSecret: process.env.AUTH_SIGN_SECRET || 'your-signing-secret',
maxFailedAttempts: parseInt(process.env.MAX_FAILED_ATTEMPTS || '5', 10),
accessTokenExpiresIn: process.env.ACCESS_TOKEN_EXPIRE || '2h',
refreshTokenExpiresIn: process.env.REFRESH_TOKEN_EXPIRE || '2d',
},
server: {
port: parseInt(process.env.PORT || '8089', 10),
},
};

Then update your composite service:

src/compositeService.ts
import {drivers} from '@nodeblocks/backend-sdk';
import {config} from './config';

const {withMongo, createFileStorageDriver} = drivers;

const connectToDatabase = withMongo(
config.database.url,
config.database.name,
config.database.user,
config.database.password,
);

const fileStorageDriver = createFileStorageDriver(config.storage.gcpProjectId, config.storage.bucketName);

const context = {
dataStores: {
...(await connectToDatabase('identities')),
...(await connectToDatabase('refreshtokens')),
...(await connectToDatabase('profiles')),
},
configuration: {
authSecrets: {
authEncSecret: config.auth.encSecret,
authSignSecret: config.auth.signSecret,
},
maxFailedLoginAttempts: config.auth.maxFailedAttempts,
accessTokenSignOptions: {expiresIn: config.auth.accessTokenExpiresIn},
refreshTokenSignOptions: {expiresIn: config.auth.refreshTokenExpiresIn},
identity: {
typeIds: {
admin: '100',
guest: '000',
regular: '001',
},
},
},
fileStorageDriver,
};

// ... rest of the service

📐 Best Practices

1. Organize by Domain

Group related features together:

// ✅ Good: Logical grouping
const authServiceMiddleware = compose(registerCredentialsFeature, loginWithCredentialsFeature, logoutFeature);

const profileServiceMiddleware = compose(
createProfileFeature,
getProfileFeature,
editProfileFeature,
deleteProfileFeature,
findProfilesFeature,
);

// ❌ Avoid: Mixed concerns
const mixedMiddleware = compose(
registerCredentialsFeature,
createProfileFeature,
loginWithCredentialsFeature,
getProfileFeature,
);

➡️ Next Steps

Now you can extend your composite service by:

  • Adding more services - Include product, order, or notification features
  • Implementing middleware - Add logging, rate limiting, or CORS
  • Adding custom features - Create domain-specific business logic
  • Overriding schemas - Customize validation for built-in features
  • Implementing microservices - Split into separate services when needed

🔗 See Also