With the technology short list settled, we finally write a complete service. We start with catalog-service, the first one to leave the monolith according to the extraction order from 02-02: it does not take part in the saga, it only reads, and its database is MongoDB, so we can focus on the anatomy of a microservice without being distracted by transactions or events. By the end you will have a runnable Node.js 20 + Express project on port 3001 that honors the GET /v1/products contract set in 03-01 and 03-06, returns RFC 7807 errors, exposes /health/live and /health/ready, and shuts down gracefully. That structure is the same one we will use in 04-04 for orders-service and the one the tests in 04-05 will exploit.

Contents

  1. Creating the project and choosing dependencies
  2. Layered structure and why app is separated from server
  3. Infrastructure: the MongoDB connection
  4. Products repository and service
  5. The routes: GET /v1/products in its three forms
  6. Input validation and centralized error handling
  7. Composing the application and starting up with graceful shutdown
  8. Running locally: MongoDB, seed data and curl checks

  1. Creating the project and choosing dependencies

In the techcorp/catalog-service repository (multirepo, 04-01), creation is the same as for any Node project:

mkdir catalog-service && cd catalog-service
echo "20" > .nvmrc && nvm use
npm init -y
npm install express mongodb pino pino-http zod @techcorp/common-http
npm install --save-dev nodemon dotenv
Dependency What for Why this one and not another
express HTTP server and routing Decision from 04-01: the monolith's framework
mongodb Official MongoDB driver Enough for reads by id and by index; no ODM (Mongoose) so as not to add a layer we do not need
pino + pino-http Minimal JSON logger and one log line per request The fastest in the ecosystem; the full format comes in 06-01
zod Validate and type the input (query, params) Declarative and readable; the alternative is the manual validation from 03-01
@techcorp/common-http sendProblem, createHealthRoutes, requestIdMiddleware, errorMiddleware, BusinessError, createLogger The library from 04-01
dotenv, nodemon (dev) Load .env locally; automatic restart on save Development only; in production the environment sets the variables (04-03)

The resulting package.json, with the scripts agreed in the template:

{
  "name": "@techcorp/catalog-service",
  "version": "0.1.0",
  "private": true,
  "engines": { "node": ">=20" },
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon -r dotenv/config src/server.js",
    "test": "jest",
    "seed": "node -r dotenv/config scripts/seed.js"
  }
}

start is what the container will run (05-01): plain Node, no dotenv or nodemon. dev preloads dotenv (-r dotenv/config) to read a local .env. test gets filled in in 04-05.

  1. Layered structure and why app is separated from server

catalog-service/
├── src/
│   ├── server.js                    # startup: config, connections, listen, SIGTERM
│   ├── app.js                       # composes Express: middlewares, routes, errors (NO listen)
│   ├── config.js                    # validated environment variables (minimal version; extended in 04-03)
│   ├── health.js                    # checks for createHealthRoutes (when there are several dependencies)
│   ├── routes/products.js           # HTTP ⇄ service (parse, validate, respond)
│   ├── services/productsService.js           # use cases: batch by ids, detail, paginated listing
│   ├── repositories/productsRepository.js    # MongoDB access
│   └── infra/mongo.js               # connection, ping, close
├── scripts/seed.js                  # sample data (p-501, p-777)
├── contracts/openapi.yaml           # the contract from 03-01/03-06 (design-first)
├── .env.example
└── package.json

The layers talk inward: the route knows the service, the service knows the repository, the repository knows MongoDB; never the other way around. Each layer receives its dependencies as parameters (dependency injection without any framework: functions that receive objects). There is a practical reason for this that we will see in 04-05: to test the route you do not need MongoDB; you pass it an in-memory repository.

Why app.js and server.js are separate. app.js exports a createApp(dependencies) function that returns the configured Express application but without listening on any port. server.js is the only one that connects to the real world: it reads the configuration, opens MongoDB, calls createApp and calls listen. The consequence is that Supertest (04-05) can run requests against createApp({ repository: inMemory }) without opening ports or databases, and that the same app can be mounted in Pact contract tests. If listen were inside app.js, every require would start a server.

  1. Infrastructure: the MongoDB connection

// src/infra/mongo.js
const { MongoClient } = require('mongodb');

// One connection (actually a pool managed by the driver) for the whole process.
async function connectMongo({ url, dbName, logger }) {
  const client = new MongoClient(url, {
    serverSelectionTimeoutMS: 5000,   // if Mongo does not show up within 5 s at startup, fail fast
    maxPoolSize: 20                   // maximum concurrent connections for this replica
  });
  await client.connect();
  logger.info({ dbName }, 'connected to MongoDB');
  const db = client.db(dbName);
  return {
    db,
    ping: () => db.command({ ping: 1 }),   // cheap check for /health/ready
    close: () => client.close()            // graceful close: waits for in-flight operations
  };
}

module.exports = { connectMongo };

We return a small object (db, ping, close) instead of the full MongoClient: the rest of the code only needs that, and in 04-05 it gets replaced by a double without touching anything else.

  1. Products repository and service

The repository translates between MongoDB documents (the schema from 02-04: _id = productId, name, price, published, category, attributes...) and the service's model. It is the only file that knows MongoDB exists.

// src/repositories/productsRepository.js
function createProductsRepository(db) {
  const collection = db.collection('products');
  // Projection: only the fields the public API exposes (03-01). Nothing else leaves this file.
  const PROJECTION = { _id: 1, name: 1, price: 1, published: 1, category: 1 };

  return {
    // Batch by ids: a single query with $in (max 100, enforced by the route)
    async findByIds(ids) {
      return collection.find({ _id: { $in: ids } }, { projection: PROJECTION }).toArray();
    },
    async getById(id) {
      return collection.findOne({ _id: id }, { projection: PROJECTION });
    },
    // Cursor pagination: "the next `limit` after `afterId` within `category`".
    // We sort by _id (unique and stable): the cursor is simply the last _id returned.
    async list({ category, afterId, limit }) {
      const filter = {};
      if (category) filter.category = category;
      if (afterId) filter._id = { $gt: afterId };
      // limit + 1 to know whether there is a next page without a second query
      return collection.find(filter, { projection: PROJECTION }).sort({ _id: 1 }).limit(limit + 1).toArray();
    },
    // Indexes this service needs; createIndex is idempotent
    async ensureIndexes() {
      await collection.createIndex({ category: 1, _id: 1 });
    }
  };
}

module.exports = { createProductsRepository };

The service holds the use cases and the translation to the public contract ({ id, name, price, currency, available }). There is no HTTP or MongoDB here: it receives and returns JavaScript objects, and throws business errors with a code.

// src/services/productsService.js
const { BusinessError } = require('@techcorp/common-http');

// Mongo document → public representation of the 03-01 contract
function toDto(doc) {
  return { id: doc._id, name: doc.name, price: doc.price, currency: 'EUR', available: doc.published === true };
}

// Opaque cursor: base64url of a JSON with the last id. Opaque to the client, readable for us.
const encodeCursor = (id) => Buffer.from(JSON.stringify({ id })).toString('base64url');
function decodeCursor(cursor) {
  try { return JSON.parse(Buffer.from(cursor, 'base64url').toString()).id; }
  catch { throw new BusinessError('INVALID_DATA', 'invalid cursor', 422); }
}

function createProductsService({ repository }) {
  return {
    // GET /v1/products?ids=  → { data, notFound }
    async getBatch(ids) {
      const docs = await repository.findByIds(ids);
      const found = new Set(docs.map((d) => d._id));
      return { data: docs.map(toDto), notFound: ids.filter((id) => !found.has(id)) };
    },
    // GET /v1/products/{id} → dto or PRODUCT_NOT_FOUND (404)
    async get(id) {
      const doc = await repository.getById(id);
      if (!doc) throw new BusinessError('PRODUCT_NOT_FOUND', `Product ${id} does not exist`, 404);
      return toDto(doc);
    },
    // GET /v1/products?category=&cursor=&limit= → { data, pagination }
    async list({ category, cursor, limit }) {
      const afterId = cursor ? decodeCursor(cursor) : undefined;
      const docs = await repository.list({ category, afterId, limit });
      const hasNext = docs.length > limit;
      const page = hasNext ? docs.slice(0, limit) : docs;
      return {
        data: page.map(toDto),
        pagination: { limit, hasNext, nextCursor: hasNext ? encodeCursor(page.at(-1)._id) : null }
      };
    }
  };
}

module.exports = { createProductsService };

BusinessError is a minimal class from @techcorp/common-http (class BusinessError extends Error { constructor(code, message, status = 422) {...} }) whose only job is to carry code and status all the way to the error middleware. With it, the service says "does not exist" without knowing that will become a 404.

  1. The routes: GET /v1/products in its three forms

The route does three things and three things only: parse and validate the request, call the service, and translate the result to HTTP (status codes, headers). The same path /v1/products serves the batch (if ids is present) or the paginated listing (if not).

// src/routes/products.js
const express = require('express');
const { z } = require('zod');

// Input schemas (zod). Validated BEFORE touching the service.
const batchSchema = z.object({
  ids: z.string().min(1)
        .transform((s) => [...new Set(s.split(',').map((x) => x.trim()).filter(Boolean))])   // "p-501, p-777" → ['p-501','p-777'] without duplicates
        .refine((arr) => arr.length <= 100, { message: 'at most 100 ids per request' })
});
const listSchema = z.object({
  category: z.string().min(1).max(50).optional(),
  cursor: z.string().max(200).optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20)     // coerce: "20" (query string text) → 20
});
const idSchema = z.object({ id: z.string().regex(/^p-\d+$/, 'product id in the format p-<n>') });

function createProductsRoutes({ productsService }) {
  const router = express.Router();

  router.get('/v1/products', async (req, res, next) => {
    try {
      if (req.query.ids !== undefined) {
        // ---- Form 1: batch by ids (03-01 §5.3) ----
        const { ids } = batchSchema.parse(req.query);      // throws ZodError → 400 in the error middleware
        const result = await productsService.getBatch(ids);
        return res.set('Cache-Control', 'public, max-age=30').json(result);
      }
      // ---- Form 2: cursor-paginated listing ----
      const filters = listSchema.parse(req.query);
      const result = await productsService.list(filters);
      const links = { self: { href: req.originalUrl } };
      if (result.pagination.hasNext) {   // minimal HATEOAS (03-01): the link to the next page, ready-built
        links.next = { href: `/v1/products?${new URLSearchParams({ ...req.query, limit: String(filters.limit), cursor: result.pagination.nextCursor })}` };
      }
      res.set('Cache-Control', 'public, max-age=30').json({ ...result, _links: links });
    } catch (err) { next(err); }
  });

  router.get('/v1/products/:id', async (req, res, next) => {
    try {
      // ---- Form 3: detail ----
      const { id } = idSchema.parse(req.params);
      const product = await productsService.get(id);   // throws PRODUCT_NOT_FOUND → 404
      res.set('Cache-Control', 'public, max-age=30').json({ ...product, _links: { self: { href: `/v1/products/${id}` } } });
    } catch (err) { next(err); }
  });

  return router;
}

module.exports = { createProductsRoutes };

Details worth underlining: /v1/ is in the route from day one (03-06; the gateway forwards /api/v1/products/* to catalog-service:3001/v1/products/*); every error goes to next(err); the route does not format errors (section 6), so the format is identical across all endpoints and services; Cache-Control: public, max-age=30 is the header promised in the contract; and the 100-id limit lives in the validation schema, not in the repository, because it is a rule of the HTTP contract.

  1. Input validation and centralized error handling

There are three families of errors, and the final Express middleware converts them all to the same RFC 7807 format from 03-01, without leaking internals and with the X-Request-Id so it can be looked up in the logs:

Origin How it arrives Response
Invalid input ZodError thrown by parse() 400 INVALID_REQUEST with an errors: [{field, message}] list
Business rule BusinessError with code and status The error's status and code (404 PRODUCT_NOT_FOUND, 422 INVALID_DATA)
Any other exception Generic Error (Mongo down, bug) 500 INTERNAL_ERROR with a generic detail; the full error goes to the log

This is how it is implemented in @techcorp/common-http (services only import it, but it is worth understanding):

// @techcorp/common-http — errorMiddleware.js
const { sendProblem } = require('./sendProblem');   // the helper from 03-01

function errorMiddleware({ logger }) {
  return (err, req, res, _next) => {                             // 4 parameters: that is how Express recognizes an error middleware
    if (err.name === 'ZodError') {
      const errors = err.issues.map((i) => ({ field: i.path.join('.') || '(query)', message: i.message }));
      return sendProblem(res, req, { status: 400, code: 'INVALID_REQUEST', detail: `${errors.length} invalid field(s)`, errors });
    }
    if (err.code && err.status) return sendProblem(res, req, { status: err.status, code: err.code, detail: err.message }); // BusinessError
    if (err.type === 'entity.parse.failed') return sendProblem(res, req, { status: 400, code: 'INVALID_REQUEST', detail: 'Malformed JSON' });
    (req.log ?? logger).error({ err, requestId: req.id }, 'unhandled error');   // full log; nothing internal to the client
    sendProblem(res, req, { status: 500, code: 'INTERNAL_ERROR', detail: `Internal error. Reference: ${req.id}` });
  };
}
module.exports = { errorMiddleware };

Since sendProblem (03-01) fills in type, title, status, detail, code and instance, and requestIdMiddleware has already put X-Request-Id in the response, a GET /v1/products/p-999 returns exactly what the contract promises: 404, Content-Type: application/problem+json and the body {"type": "https://techcorp.example/errors/product-not-found", "title": "Request error", "status": 404, "detail": "Product p-999 does not exist", "code": "PRODUCT_NOT_FOUND", "instance": "/v1/products/p-999"}.

  1. Composing the application and starting up with graceful shutdown

app.js mounts the pieces in the right order (middleware order in Express matters: request-id and logging before the routes; the error one, last):

// src/app.js
const express = require('express');
const pinoHttp = require('pino-http');
const { requestIdMiddleware, errorMiddleware, createHealthRoutes, sendProblem } = require('@techcorp/common-http');
const { createProductsRoutes } = require('./routes/products');
const { createProductsService } = require('./services/productsService');

// Receives the dependencies already built: this is what lets tests pass an in-memory repository
function createApp({ repository, logger, healthChecks = {} }) {
  const app = express();
  app.disable('x-powered-by');                                   // we do not advertise the technology
  app.use(requestIdMiddleware());                                // 1. X-Request-Id in req.id and in the response
  app.use(pinoHttp({ logger, genReqId: (req) => req.id }));      // 2. one log line per request, with the same id
  app.use(express.json({ limit: '100kb' }));                     // 3. JSON body (Catalog does not use it yet; the template ships it)
  app.use(createHealthRoutes({ checks: healthChecks }));         // 4. /health/live and /health/ready (03-05)

  const productsService = createProductsService({ repository });
  app.use(createProductsRoutes({ productsService }));            // 5. the API

  app.use((req, res) => sendProblem(res, req, { status: 404, code: 'ROUTE_NOT_FOUND', detail: `${req.method} ${req.originalUrl} does not exist` }));
  app.use(errorMiddleware({ logger }));                          // 6. ALWAYS last
  return app;
}

module.exports = { createApp };

And server.js, the only file with side effects on the world (port, database, signals):

// src/server.js
const { createLogger } = require('@techcorp/common-http');
const { config } = require('./config');                 // { PORT, MONGO_URL, MONGO_DB, LOG_LEVEL } validated; 04-03 extends it
const { connectMongo } = require('./infra/mongo');
const { createProductsRepository } = require('./repositories/productsRepository');
const { createApp } = require('./app');

async function start() {
  const logger = createLogger({ service: 'catalog-service', level: config.LOG_LEVEL });

  // 1. External dependencies first: if Mongo is not there, the process dies and Kubernetes will retry it
  const mongo = await connectMongo({ url: config.MONGO_URL, dbName: config.MONGO_DB, logger });
  const repository = createProductsRepository(mongo.db);
  await repository.ensureIndexes();

  // 2. Compose the app with its real dependencies
  const app = createApp({ repository, logger, healthChecks: { mongodb: mongo.ping } });

  // 3. Listen
  const server = app.listen(config.PORT, () => logger.info({ port: config.PORT }, 'catalog-service listening'));
  server.keepAliveTimeout = 65000;                      // > load balancer timeout (typically 60 s): avoids keep-alive cut-offs

  // 4. Graceful shutdown. Kubernetes sends SIGTERM and waits (30 s by default) before SIGKILL.
  //    createHealthRoutes has already switched /health/ready to 503 on SIGTERM (03-05): new requests stop arriving.
  const shutdown = (signal) => {
    logger.info({ signal }, 'shutdown started');
    server.close(async () => {                          // stops accepting connections; waits for in-flight requests
      await mongo.close();
      logger.info('shutdown completed');
      process.exit(0);
    });
    setTimeout(() => { logger.error('shutdown forced by timeout'); process.exit(1); }, 10000).unref();
  };
  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));       // Ctrl+C locally
}

start().catch((err) => { console.error('could not start', err); process.exit(1); });

The shutdown sequence (SIGTERM → /health/ready returns 503 → the load balancer stops sending requests → server.close() finishes in-flight requests → Mongo close → exit(0), with a 10 s safety limit) is what avoids losing requests on every deployment; in 05-04 we will see how it fits with Kubernetes rolling updates.

src/config.js is, for now, minimal: it reads PORT (default 3001), MONGO_URL (default mongodb://localhost:27017), MONGO_DB (catalog) and LOG_LEVEL (info) from process.env and fails if MONGO_URL is not a valid URL. The full version, with zod and every kind of configuration, is the subject of 04-03.

  1. Running locally: MongoDB, seed data and curl checks

MongoDB in a container (one line; Docker in depth is 05-01) and the local .env, copied from .env.example (which is in git; .env is not):

docker run -d --name mongo-catalog -p 27017:27017 mongo:7
cp .env.example .env     # PORT=3001  MONGO_URL=mongodb://localhost:27017  MONGO_DB=catalog  LOG_LEVEL=debug

Seed data with the products used throughout the course. It is idempotent (upsert), so it can be run as many times as you like:

// scripts/seed.js
const { MongoClient } = require('mongodb');

const PRODUCTS = [
  { _id: 'p-501', productId: 'p-501', name: 'BT X200 Headphones', category: 'audio', price: 59.90, published: true,
    description: 'Wireless headphones with noise cancelling', attributes: { color: 'black', batteryHours: 30, bluetooth: '5.3' } },
  { _id: 'p-777', productId: 'p-777', name: 'USB-C Cable 2 m', category: 'accessories', price: 9.90, published: true,
    description: 'USB-C to USB-C cable, 100 W', attributes: { lengthMeters: 2, powerW: 100 } },
  { _id: 'p-802', productId: 'p-802', name: 'S10 Portable Speaker', category: 'audio', price: 34.50, published: false, attributes: { color: 'blue' } }
];

async function main() {
  const client = new MongoClient(process.env.MONGO_URL ?? 'mongodb://localhost:27017');
  await client.connect();
  const collection = client.db(process.env.MONGO_DB ?? 'catalog').collection('products');
  for (const p of PRODUCTS) {
    await collection.updateOne({ _id: p._id }, { $set: { ...p, updatedAt: new Date().toISOString() } }, { upsert: true });
  }
  console.log(`seed applied: ${PRODUCTS.length} products`);
  await client.close();
}
main().catch((e) => { console.error(e); process.exit(1); });
npm run seed        # seed applied: 3 products
npm run dev         # {"level":30,"service":"catalog-service","port":3001,"msg":"catalog-service listening"}

Manual checks and what should come out:

# 1. Batch (the contract Orders will consume in 04-04). p-999 does not exist → goes to notFound, not a 404
curl -s -i "http://localhost:3001/v1/products?ids=p-501,p-777,p-999"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=30
X-Request-Id: req-01J5A2X7Q0K3

{"data":[{"id":"p-501","name":"BT X200 Headphones","price":59.9,"currency":"EUR","available":true},
         {"id":"p-777","name":"USB-C Cable 2 m","price":9.9,"currency":"EUR","available":true}],
 "notFound":["p-999"]}
# 2. Nonexistent detail → 404 application/problem+json, code PRODUCT_NOT_FOUND (the JSON from section 6)
curl -s -i http://localhost:3001/v1/products/p-999 | head -2
# 3. Paginated listing: 1 per page, category audio (p-501 and p-802; p-802 comes out with available=false)
curl -s "http://localhost:3001/v1/products?category=audio&limit=1"
# {"data":[{"id":"p-501",...}],"pagination":{"limit":1,"hasNext":true,"nextCursor":"eyJpZCI6InAtNTAxIn0"},"_links":{...,"next":{"href":"/v1/products?category=audio&limit=1&cursor=eyJpZCI6InAtNTAxIn0"}}}
# 4. Validation: limit=500 (or 101 ids) → 400 INVALID_REQUEST
curl -s "http://localhost:3001/v1/products?limit=500" | jq .code     # "INVALID_REQUEST"
# 5. Health: ready with Mongo; 503 without Mongo
curl -s http://localhost:3001/health/ready      # {"status":"ready","dependencies":{"mongodb":"ok"}}
docker stop mongo-catalog && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/health/ready   # 503
docker start mongo-catalog
# 6. Graceful shutdown: Ctrl+C in the npm run dev terminal → "shutdown started" ... "shutdown completed"

If all six steps respond like this, catalog-service honors its contract and is ready for Orders to consume it. A requests.http file with these same calls, for the VS Code REST Client extension, ships with the repository.

Common Mistakes and Tips

  • app.listen inside app.js. It breaks testability and makes every require open a port. createApp() returns, server.js listens.
  • Routes that talk to MongoDB directly. It works until you have to test without a database or change the driver. Route → service → repository, always.
  • Formatting errors in every route. You end up with five different formats. next(err) and a single errorMiddleware.
  • Forgetting the try/catch in an async handler. Express 4 does not catch rejected promises: the request hangs. Always wrap (or use express-async-errors, or Express 5, which does catch them).
  • Returning the MongoDB document as is. It exposes internal fields and turns your DB schema into a public contract. Always through toDto.
  • Ignoring SIGTERM. Node dies abruptly by default: in-flight requests get a reset. server.close() + closing connections + safety timeout.
  • No server selection timeout in Mongo (starting with Mongo down: 30 s of silence) and non-idempotent seeds (insertMany fails the second time). serverSelectionTimeoutMS and upsert.

Exercises

Exercise 1. The Shopping Experience team asks that the batch GET /v1/products?ids= not return products with published: false in data, but include them in notFound instead (for Orders, "unpublished" and "does not exist" mean the same thing: it cannot be sold). Modify the service (not the route or the repository) to achieve this and explain why that is the right layer.

Exercise 2. Write an in-memory version of the repository (createInMemoryProductsRepository(products)) that implements findByIds, getById, list and ensureIndexes on top of an array. Then show how createApp would be called with it and with a silent logger (pino({ level: 'silent' })). It is the foundation 04-05 will use.

Exercise 3. A colleague proposes that Catalog's /health/ready also check that the gateway (port 8080) responds, "to make sure the system works." Explain, using the table from 03-05, why it is a bad idea and what would happen to Catalog's replicas if the gateway went down.

Solutions

Solution 1.

async getBatch(ids) {
  const docs = await repository.findByIds(ids);
  const sellable = docs.filter((d) => d.published === true);
  const found = new Set(sellable.map((d) => d._id));
  return { data: sellable.map(toDto), notFound: ids.filter((id) => !found.has(id)) };
}

It is a business rule of the contract ("the batch only returns sellable products"), not a matter of HTTP (route) or storage (repository): the repository must still be able to return unpublished products for the detail view or the admin panel. It is also advisable to update contracts/openapi.yaml in the same PR (03-06) and let Orders know, since it will interpret those ids as PRODUCT_UNAVAILABLE.

Solution 2.

// tests/doubles/inMemoryProductsRepository.js
function createInMemoryProductsRepository(products = []) {
  const data = [...products];
  return {
    async findByIds(ids) { return data.filter((p) => ids.includes(p._id)); },
    async getById(id) { return data.find((p) => p._id === id) ?? null; },
    async list({ category, afterId, limit }) {
      return data.filter((p) => !category || p.category === category).filter((p) => !afterId || p._id > afterId)
                 .sort((a, b) => a._id.localeCompare(b._id)).slice(0, limit + 1);
    },
    async ensureIndexes() {}
  };
}
// usage: no changes in app.js, routes or service; that is the payoff of dependency injection
const app = createApp({ repository: createInMemoryProductsRepository([{ _id: 'p-501', name: 'BT X200 Headphones', price: 59.90, published: true, category: 'audio' }]),
                        logger: require('pino')({ level: 'silent' }) });

Solution 3. /health/ready must check the service's own dependencies, the ones without which this replica cannot serve a request: MongoDB. The gateway is not a dependency of Catalog (it is the other way around). If it were included and the gateway went down, every Catalog replica would become "not ready," Kubernetes would pull them out of load balancing, and when the gateway came back it would have no Catalog to send traffic to until the replicas became ready again: a partial outage turned into a total, prolonged one. In addition, the mobile BFF and other internal consumers that do not go through the gateway would lose Catalog for no reason.

Conclusion

We have TechCorp's first microservice running: a Node.js 20 + Express project with minimal dependencies (express, mongodb, pino, zod, @techcorp/common-http), a layered structure (routes → service → repository → infrastructure) in which each layer receives its dependencies as parameters, the separation between createApp() and server.js that makes it testable without ports or a database, the three GET /v1/products endpoints (batch with {data, notFound} and Cache-Control, detail with 404 PRODUCT_NOT_FOUND, cursor-paginated listing) faithful to the contracts from 03-01 and 03-06, validation with zod, a single middleware that turns any error into RFC 7807 with X-Request-Id, health with createHealthRoutes, graceful shutdown on SIGTERM, and a seed with p-501 and p-777 to try it with curl.

There is one piece we have deliberately kept small: src/config.js. A service that runs on the laptop, in staging and in production, replicated and containerized, needs a disciplined way of knowing which MongoDB to connect to, which port to listen on and which features to enable, without touching the code and without leaking passwords. That discipline, and the complete config.js module with fail-fast validation that every service will reuse, is the next lesson.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved