Module: MongoDB
MongoDB·147·5 MIN READ

147: Node.js + Express + MongoDB Production Architecture — Repositories, Services, Validation, Tenancy, Pagination, Transactions, and Shutdown

TOPICS COVERED: Node.js + Express + MongoDB Production Architecture — Repositories, Services, Validation, Tenancy, Pagination, Transactions, and Shutdown

Learning objectives

You will learn to:

  • combine Node.js, Express, the MongoDB native driver or Mongoose without blurring responsibilities;
  • design application startup and dependency composition;
  • keep HTTP, domain, persistence, and infrastructure boundaries explicit;
  • build tenant-safe repositories;
  • validate request data before Mongo query construction;
  • design cursor pagination with matching indexes;
  • map Mongo errors to stable HTTP contracts;
  • choose atomic writes versus transactions;
  • integrate idempotency and outbox patterns;
  • manage Mongo connection lifecycle and graceful shutdown;
  • test the architecture with real Mongo integration;
  • avoid duplicated data ownership and hidden ODM/framework coupling.

The goal

At this point you know the pieces individually:

text
Node runtime
Express
HTTP contracts
security
authentication
workers
testing
Mongo modeling
indexes
aggregation
transactions
replication/sharding
native driver
Mongoose

Production architecture is about assigning each concern to the correct owner.

A robust baseline:

text
HTTP request
↓
Express middleware
↓
request schema validation
↓
authentication context
↓
service/application operation
↓
authorization/business rules
↓
repository
↓
MongoDB
↓
repository result
↓
service result
↓
HTTP response mapping

Do not collapse this into one 300-line route.

Suggested project structure

text
src/
├─ app/
│  ├─ create-app.js
│  ├─ create-runtime.js
│  └─ shutdown.js
├─ config/
│  └─ config.js
├─ domain/
│  ├─ errors.js
│  ├─ task-policy.js
│  └─ task-service.js
├─ http/
│  ├─ middleware/
│  │  ├─ authenticate.js
│  │  ├─ request-id.js
│  │  ├─ validate.js
│  │  └─ error-handler.js
│  └─ routes/
│     └─ tasks.js
├─ persistence/
│  ├─ mongo-client.js
│  ├─ task-repository.js
│  └─ indexes.js
├─ jobs/
│  └─ outbox-worker.js
└─ server.js

Mongoose version may replace repository internals with models, but the upper architecture should remain similar.

Dependency direction

Preferred:

text
http
↓
service/domain
↓
repository interface/implementation
↓
Mongo driver/Mongoose

Avoid:

text
repository imports Express
domain imports req/res
Mongo model sends HTTP response
route reaches into global MongoClient singleton everywhere

Infrastructure details should not leak upward unnecessarily.

Startup composition

js
async function createRuntime(config) {
  const mongo = await createMongoDatabase(
    config.mongodb,
  );

  const taskRepository =
    createTaskRepository({
      db: mongo.db,
    });

  const taskService =
    createTaskService({
      taskRepository,
    });

  const app = createApp({
    taskService,
  });

  return {
    app,
    mongo,
  };
}

Server:

js
const config = loadConfig();

const runtime = await createRuntime(config);

const server = runtime.app.listen(
  config.port,
  config.host,
);

installShutdown({
  server,
  mongo: runtime.mongo,
});

No route opens its own database connection.

Mongo client factory

js
import {
  MongoClient,
} from 'mongodb';

export async function createMongoDatabase({
  uri,
  databaseName,
  clientOptions,
}) {
  const client = new MongoClient(
    uri,
    clientOptions,
  );

  await client.connect();

  const db = client.db(
    databaseName,
  );

  return {
    client,
    db,
  };
}

Do not log the URI.

Repository responsibility

Repository should own Mongo query details.

js
export function createTaskRepository({
  db,
}) {
  const tasks = db.collection(
    'tasks',
  );

  return {
    async findById({
      tenantId,
      taskId,
    }) {
      return tasks.findOne({
        _id: taskId,
        tenantId,
      });
    },
  };
}

Service should not know:

text
$match
ObjectId conversion details
Mongo duplicate-key error internals

unless that knowledge is deliberately part of domain/persistence boundary.

Request ID parsing

HTTP route receives string ID.

Validate/convert at boundary or a dedicated input mapper:

js
import {
  ObjectId,
} from 'mongodb';

function parseObjectId(value, field) {
  if (
    typeof value !== 'string' ||
    !ObjectId.isValid(value)
  ) {
    throw new ValidationError({
      [field]: 'Invalid identifier.',
    });
  }

  return ObjectId.createFromHexString(
    value,
  );
}

Do not let a raw BSON cast error become a 500.

Tenant ownership

Authentication middleware:

js
res.locals.auth = {
  userId,
  tenantId,
  permissions,
};

Route:

js
router.get(
  '/:taskId',
  async (req, res) => {
    const task = await taskService.getTask({
      actor: res.locals.auth,
      taskId: parseObjectId(
        req.params.taskId,
        'taskId',
      ),
    });

    res.json({
      data: {
        task,
      },
    });
  },
);

Service/repository:

js
const task =
  await taskRepository.findById({
    tenantId: actor.tenantId,
    taskId,
  });

Never:

js
await tasks.findOne({
  _id: taskId,
});

then return error only after examining another tenant's document.

Scope first.

Tenant field cannot come from body

Danger:

js
await Task.create(req.body);

with:

json
{
  "tenantId": "victimTenant"
}

Correct:

js
await taskService.createTask({
  actor,
  input: validatedBody,
});

Repository constructs:

js
{
  tenantId: actor.tenantId,
  title: input.title,
  ...
}

Server derives tenant.

Validation before query construction

Public filter request:

text
GET /tasks?status=open&priority=high

Validate:

js
const ListTaskQuery = z.object({
  status: z
    .enum([
      'open',
      'done',
      'all',
    ])
    .default('all'),

  priority: z
    .enum([
      'low',
      'normal',
      'high',
    ])
    .optional(),

  limit: z.coerce
    .number()
    .int()
    .min(1)
    .max(100)
    .default(25),

  after: z
    .string()
    .optional(),
}).strict();

Then build Mongo filter yourself.

Do not:

js
collection.find(req.query);

Query builder

js
function buildTaskFilter({
  actor,
  query,
}) {
  const filter = {
    tenantId: actor.tenantId,
  };

  if (query.status === 'open') {
    filter.completed = false;
  }

  if (query.status === 'done') {
    filter.completed = true;
  }

  if (query.priority) {
    filter.priority = query.priority;
  }

  return filter;
}

No arbitrary Mongo operator input.

Cursor pagination contract

Stable ordering:

text
createdAt DESC
_id DESC

Index:

javascript
{
  tenantId: 1,
  completed: 1,
  createdAt: -1,
  _id: -1
}

if completed is commonly filtered.

Cursor payload:

json
{
  "createdAt": "2026-08-27T10:00:00.000Z",
  "id": "..."
}

Encode opaque URL-safe token.

Validate after decoding.

Page query

js
function applyAfterCursor(
  filter,
  cursor,
) {
  if (!cursor) {
    return filter;
  }

  return {
    ...filter,

    $or: [
      {
        createdAt: {
          $lt: cursor.createdAt,
        },
      },
      {
        createdAt: cursor.createdAt,
        _id: {
          $lt: cursor.id,
        },
      },
    ],
  };
}

Because tenant/status predicates remain top-level, check explain/index behavior.

You may structure with $and if necessary for clarity/query construction.

Test with real query planner.

Limit + 1

Fetch:

js
.limit(limit + 1)

If extra exists:

text
hasNextPage = true
nextCursor = last returned item

Return only requested limit.

This avoids exact count on every page.

Total counts

If UI needs exact total, decide:

  • separate endpoint;
  • aggregation facet;
  • precomputed counter;
  • approximate count;
  • no count.

Do not make every list query pay count cost by default.

Index ownership

Define required indexes explicitly.

Example migration/admin script:

js
await tasks.createIndex(
  {
    tenantId: 1,
    completed: 1,
    createdAt: -1,
    _id: -1,
  },
  {
    name:
      'tasks_tenant_completed_created_id',
  },
);

Do not depend on production Mongoose autoIndex for critical large deployment.

Track indexes like schema migrations.

Unique error mapping

Index:

javascript
{
  tenantId: 1,
  externalId: 1
}

unique.

Mongo duplicate key commonly surfaces as error code:

text
11000

Repository maps:

js
if (isDuplicateKey(error)) {
  throw new ConflictError(
    'External task already exists.',
    {
      cause: error,
    },
  );
}

Do not expose index name/raw key values if sensitive.

Database errors versus domain errors

Repository failure classes can include:

text
DuplicateKey
DatabaseUnavailable
DatabaseTimeout

Service/domain:

text
NotFound
Conflict
Forbidden
Validation

HTTP maps:

text
404
409
403
422
503/500

Do not map every Mongo error to 500 blindly.

Retry semantics

Driver may retry eligible reads/writes.

Application may retry selected transient operations.

Do not add:

js
for (let i = 0; i < 10; i++) {
  try {
    return await operation();
  } catch {}
}

around arbitrary write.

Classify:

  • driver retryable operation;
  • idempotent service operation;
  • external side effects;
  • overall request deadline.

Create idempotency

POST /orders:

Client sends:

text
Idempotency-Key

Server stores record:

javascript
{
  tenantId,
  key,
  requestHash,
  status,
  result,
  expiresAt
}

Unique index:

javascript
{
  tenantId: 1,
  key: 1
}

Workflow must handle concurrent same-key requests.

Do not store only in process Map if app scales horizontally.

Optimistic concurrency

Task has:

javascript
{
  version: 7
}

PATCH:

json
{
  "version": 7,
  "title": "..."
}

Repository:

js
const result = await tasks.updateOne(
  {
    _id: taskId,
    tenantId,
    version: expectedVersion,
  },
  {
    $set: {
      title,
      updatedAt: new Date(),
    },
    $inc: {
      version: 1,
    },
  },
);

If no match, distinguish missing/conflict without leaking cross-tenant resource.

Atomic write before transaction

Suppose:

text
complete task only if open

Use one atomic update:

js
findOneAndUpdate(
  {
    _id: taskId,
    tenantId,
    completed: false,
  },
  {
    $set: {
      completed: true,
    },
  },
);

No transaction needed.

Transaction boundary

Use transaction when invariant truly spans multiple documents.

Example:

text
mark order paid
insert payment
write outbox event

All inside Mongo transaction.

Then worker publishes outbox later.

Do not include:

text
send WhatsApp
charge external gateway

inside Mongo retryable transaction callback.

Outbox

Collection:

javascript
{
  _id,
  tenantId,
  type: "order.paid",
  aggregateId: orderId,
  payload: {...},
  createdAt,
  publishedAt: null,
  attempts: 0
}

Transaction:

text
update order
insert payment
insert outbox
commit

Publisher claims unpublished events and sends.

On crash/retry, delivery may repeat.

Consumer should be idempotent.

Outbox claim

Use atomic findOneAndUpdate/lease approach:

text
publishedAt null
lease expired

claim one worker.

Do not have every replica publish same event simultaneously.

Mongoose integration option

If using Mongoose:

text
routes
→ services
→ repositories
→ models

not:

text
routes
→ model everywhere

Repository might use:

js
Task.findOne(...).lean()
Task.updateOne(...)

Architecture remains.

lean() in list paths

If Mongoose:

js
await Task
  .find(filter)
  .sort(sort)
  .limit(limit + 1)
  .lean();

Good when results are read-only plain API data.

If service needs document methods/save, use hydrated docs intentionally.

Aggregation repository

Reports:

js
return orders.aggregate([
  {
    $match: {
      tenantId,
      createdAt: {
        $gte: start,
        $lt: end,
      },
    },
  },
  ...
]).toArray();

Report parameters validated.

Do not expose arbitrary pipeline endpoint.

Search/vector repository

Search request:

text
query
filters
limit

Server builds $search/$vectorSearch.

Tenant/permission filter must be included.

Do not search globally then discard unauthorized hits after limit; that can leak counts/timing and produce poor results.

Connection pool architecture

Do not create:

text
Mongo client in each repository

Repositories share application-scoped DB handle.

Do not make pool huge because API concurrency is high.

DB can become overloaded.

Use:

  • bounded HTTP concurrency where needed;
  • pool metrics;
  • DB capacity.

Readiness

Before ready:

js
await mongo.client.db('admin').command({
  ping: 1,
});

or initial connect succeeds.

But readiness should not perform heavy ping every request at high rate.

Cache/reuse health strategy.

Liveness should not restart app for a brief primary election unless process itself unhealthy.

Graceful shutdown ordering

Recommended:

text
SIGTERM
↓
set readiness false
↓
stop server accepting
↓
stop outbox/job pollers
↓
finish/cancel in-flight with deadline
↓
close change streams/cursors
↓
close MongoClient/Mongoose
↓
flush telemetry
↓
exit

Do not close Mongo first while HTTP requests still running.

Background jobs

Outbox worker:

text
must stop claiming new jobs on shutdown

then finish current bounded jobs.

Use AbortController.

If durable jobs matter, lease expires if process crashes.

Observability

For each query category track:

text
operation name
duration
result count
error code
tenant? avoid high-cardinality metric label

Trace:

text
HTTP
→ service
→ Mongo
→ external publish

Do not log raw filters containing sensitive fields.

Repository tests

Use real Mongo test deployment for:

  • compound unique indexes;
  • ObjectId behavior;
  • cursor ordering;
  • transaction;
  • duplicate error;
  • aggregation;
  • tenant scope;
  • change streams if topology supports.

Pure mocks cannot prove Mongo semantics.

Integration test example

js
test(
  'cannot load another tenant task',
  async () => {
    const task =
      await repository.insert({
        tenantId: tenantB,
        title: 'Secret',
      });

    const result =
      await repository.findById({
        tenantId: tenantA,
        taskId: task._id,
      });

    assert.equal(
      result,
      null,
    );
  },
);

This is a critical security test.

Failure injection

Simulate:

text
Mongo unavailable
primary election
duplicate key
transaction transient failure
slow query
pool saturation
SIGTERM mid-request
outbox publish failure
consumer duplicate

Define expected user/system behavior.

Failure clinic

Mongo model imported directly in every route

Coupling and inconsistent security.

tenant filter added only in some repository methods

Data leak.

raw query params become Mongo filter

Injection.

count + skip on every large list

Performance.

auto retry transaction sends email twice

Side effect bug.

pool closes before HTTP drains

Request failures during deploy.

in-memory idempotency/outbox state

Breaks across replicas/restarts.

Mongo errors sent raw to client

Information leak.

Exercises

  1. Build runtime dependency composition.
  2. Implement tenant-safe repository.
  3. Add cursor pagination with matching index.
  4. Add duplicate-key error mapping.
  5. Implement optimistic version update.
  6. Decide atomic update versus transaction for five workflows.
  7. Build order/payment/outbox transaction.
  8. Implement outbox lease publisher.
  9. Add graceful shutdown order.
  10. Write real Mongo security/integration tests.
  11. Run explain for list query and attach evidence to code review.

Mastery checklist

Explain:

  • layer boundaries;
  • runtime composition;
  • tenant-scoped repository;
  • validation before query;
  • cursor/index alignment;
  • Mongo error mapping;
  • retry/idempotency;
  • atomic vs transaction;
  • outbox;
  • pool lifecycle;
  • graceful shutdown;
  • integration testing.

Official references