147: 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:
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:
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
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:
http ↓ service/domain ↓ repository interface/implementation ↓ Mongo driver/Mongoose
Avoid:
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
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:
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
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.
export function createTaskRepository({
db,
}) {
const tasks = db.collection(
'tasks',
);
return {
async findById({
tenantId,
taskId,
}) {
return tasks.findOne({
_id: taskId,
tenantId,
});
},
};
}
Service should not know:
$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:
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:
res.locals.auth = {
userId,
tenantId,
permissions,
};
Route:
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:
const task =
await taskRepository.findById({
tenantId: actor.tenantId,
taskId,
});
Never:
await tasks.findOne({
_id: taskId,
});
then return error only after examining another tenant's document.
Scope first.
Tenant field cannot come from body
Danger:
await Task.create(req.body);
with:
{
"tenantId": "victimTenant"
}
Correct:
await taskService.createTask({
actor,
input: validatedBody,
});
Repository constructs:
{
tenantId: actor.tenantId,
title: input.title,
...
}
Server derives tenant.
Validation before query construction
Public filter request:
GET /tasks?status=open&priority=high
Validate:
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:
collection.find(req.query);
Query builder
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:
createdAt DESC _id DESC
Index:
{
tenantId: 1,
completed: 1,
createdAt: -1,
_id: -1
}
if completed is commonly filtered.
Cursor payload:
{
"createdAt": "2026-08-27T10:00:00.000Z",
"id": "..."
}
Encode opaque URL-safe token.
Validate after decoding.
Page query
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:
.limit(limit + 1)
If extra exists:
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:
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:
{
tenantId: 1,
externalId: 1
}
unique.
Mongo duplicate key commonly surfaces as error code:
11000
Repository maps:
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:
DuplicateKey DatabaseUnavailable DatabaseTimeout
Service/domain:
NotFound Conflict Forbidden Validation
HTTP maps:
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:
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:
Idempotency-Key
Server stores record:
{ tenantId, key, requestHash, status, result, expiresAt }
Unique index:
{
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:
{
version: 7
}
PATCH:
{
"version": 7,
"title": "..."
}
Repository:
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:
complete task only if open
Use one atomic update:
findOneAndUpdate(
{
_id: taskId,
tenantId,
completed: false,
},
{
$set: {
completed: true,
},
},
);
No transaction needed.
Transaction boundary
Use transaction when invariant truly spans multiple documents.
Example:
mark order paid insert payment write outbox event
All inside Mongo transaction.
Then worker publishes outbox later.
Do not include:
send WhatsApp charge external gateway
inside Mongo retryable transaction callback.
Outbox
Collection:
{
_id,
tenantId,
type: "order.paid",
aggregateId: orderId,
payload: {...},
createdAt,
publishedAt: null,
attempts: 0
}
Transaction:
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:
publishedAt null lease expired
claim one worker.
Do not have every replica publish same event simultaneously.
Mongoose integration option
If using Mongoose:
routes → services → repositories → models
not:
routes → model everywhere
Repository might use:
Task.findOne(...).lean()
Task.updateOne(...)
Architecture remains.
lean() in list paths
If Mongoose:
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:
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:
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:
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:
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:
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:
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:
operation name duration result count error code tenant? avoid high-cardinality metric label
Trace:
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
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:
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
- Build runtime dependency composition.
- Implement tenant-safe repository.
- Add cursor pagination with matching index.
- Add duplicate-key error mapping.
- Implement optimistic version update.
- Decide atomic update versus transaction for five workflows.
- Build order/payment/outbox transaction.
- Implement outbox lease publisher.
- Add graceful shutdown order.
- Write real Mongo security/integration tests.
- 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.
