Over the last few years building Node.js backend services on MongoDB and Firestore, I kept running into the same pattern:
- Every team rolls their own “repository layer” on top of the database.
- Everyone re‑implements the same things: multi‑tenancy, soft deletes, timestamps, versioning, audit trails.
- Business logic ends up littered with
{ tenantId, _deleted: { $ne: true } }filters and hand‑rolled update operators.
At the same time, full‑blown ODMs (like Mongoose and similar libraries) often try to abstract the database too much. They introduce their own query DSLs and life‑cycle hooks, can make it harder to use advanced database features (like MongoDB aggregations), and you still end up dropping down to the native driver for anything non‑trivial.
I wanted something in between.
That’s why I built Slire.
Slire is a small Node.js library that gives you a consistent, type‑safe repository layer over MongoDB and Firestore. It handles the boring but important parts of data access—like scope, soft deletes, timestamps, versioning, and tracing—while staying out of the way when you need the full power of the native driver.
What Slire is (and isn’t)
Slire is intentionally minimal. It doesn’t try to be an ODM or replace your database driver.
Instead, it:
- Wraps your existing MongoDB or Firestore collections.
- Gives you a small, well‑typed API for the most common CRUD and query operations.
- Automatically enforces scope (e.g. per‑tenant data isolation).
- Manages common consistency fields for you:
- Soft delete (
_deleted) - Timestamps (
_createdAt,_updatedAt,_deletedAt) - Versioning (
_version) - Optional per‑write trace data (who did what, when)
- Soft delete (
- Exposes helpers like
applyFilterandbuildUpdateOperationso you can safely use the native driver for complex operations, while keeping behavior (scope, soft delete, versioning, tracing) consistent.
What it doesn’t try to do:
- No custom query DSL—just plain MongoDB/Firestore filters.
- No magic model lifecycle hooks or hidden queries.
- No attempt to make MongoDB “feel like SQL” or vice versa.
If you like the expressiveness of the native drivers but are tired of rewriting the same repository boilerplate, this is for you.
For the full rationale and how this approach compares to ODMs and other heavier data‑access abstractions, see:
👉 Why Slire?
Quickstart: a scoped Task repository
Slire is built around repository factories. You define your domain type and a small factory function that wires it to your database client and scope.
Let’s say you have a simple Task type and a multi‑tenant MongoDB app:
// task.ts
export type Task = {
id: string;
tenantId: string; // scope
title: string;
status: 'todo' | 'in_progress' | 'done' | 'archived';
dueDate?: Date;
_createdAt?: Date;
};
You can create a repository factory like this:
import { MongoClient } from 'mongodb';
import { createMongoRepo } from 'slire';
import type { Task } from './task';
export function createTaskRepo(client: MongoClient, tenantId: string) {
return createMongoRepo<Task>({
collection: client.db('app').collection<Task>('tasks'),
mongoClient: client,
scope: { tenantId }, // enforced on all reads/updates/deletes
options: {
softDelete: true,
traceTimestamps: 'server',
version: true,
},
});
}
Usage in a service:
const taskRepo = createTaskRepo(mongoClient, 'tenant-123');
// Create a task (id, scope, timestamps, version, and trace are handled for you)
const id = await taskRepo.create({
title: 'Draft onboarding guide',
status: 'todo',
});
// Update – only allowed fields, `_updatedAt` and `_version` are handled automatically
await taskRepo.update(id, { set: { status: 'in_progress' } });
// Scoped read (only returns a task if it belongs to tenant-123 and isn’t soft-deleted)
const task = await taskRepo.getById(id, { id: true, title: true, status: true });
You still have full access to taskRepo.collection for complex queries or aggregations, but you don’t have to manually wire scope/soft‑delete/versioning every time.
For more details, check the full 👉 README.
Designing a clean data access layer
Slire is more than just a set of helpers; it's built around some core data‑access design principles that help keep your application code maintainable as it grows—whether you use Slire itself, talk directly to native drivers, or build a similar repository layer of your own.
1. Explicit dependencies instead of global repositories
Instead of injecting a huge TaskRepo everywhere and calling methods ad‑hoc, Slire encourages narrow, explicit ports:
// task-repo.ts
export type TaskRepo = ReturnType<typeof createTaskRepo>;
const TaskSummaryProjection = { id: true, title: true, status: true } as const;
type TaskSummary = Projected<Task, typeof TaskSummaryProjection>;
export type GetTaskSummary = (id: string) => Promise<TaskSummary | undefined>;
export type SetTaskStatus = (id: string, status: Task['status']) => Promise<void>;
export function makeGetTaskSummary(repo: TaskRepo): GetTaskSummary {
return (id) => repo.getById(id, TaskSummaryProjection);
}
export function makeSetTaskStatus(repo: TaskRepo): SetTaskStatus {
return (id, status) => repo.update(id, { set: { status } });
}
Your business logic then depends on simple functions, not on a giant repository interface:
type CompleteTaskInput = { taskId: string; projectId: string; userId: string };
type CompleteTaskDeps = {
getTaskSummary: GetTaskSummary;
setTaskStatus: SetTaskStatus;
// other domain-level capabilities…
};
export async function completeTaskFlow(
deps: CompleteTaskDeps,
input: CompleteTaskInput
): Promise<{ task: TaskSummary; projectProgressChanged: boolean }> {
const { getTaskSummary, setTaskStatus } = deps;
const task = await getTaskSummary(input.taskId);
if (!task) throw new Error('Task not found');
if (task.status === 'done') {
return { task, projectProgressChanged: false };
}
await setTaskStatus(input.taskId, 'done');
// …maybe call other ports here (e.g. notify manager, recalc project)
const updated = await getTaskSummary(input.taskId);
return { task: updated!, projectProgressChanged: true };
}
Why this helps:
- Business logic is easy to test with simple stubs/mocks.
- Changing how you fetch data (e.g. switching from MongoDB to Firestore or changing query shapes) doesn’t require touching the orchestration logic.
- It’s clear what each use case depends on (
getTaskSummary,setTaskStatus, etc.).
I go much deeper into this here:
👉 Data Access Design Guide
2. Client-side “stored procedures” without giving up control
Sometimes you need to perform batch operations or complex updates that don’t fit nicely into simple CRUD methods. For example, recomputing per‑project task summaries once a day.
With Slire, you can write a client‑side stored procedure that:
- Uses MongoDB’s
aggregatedirectly for performance and flexibility. - Still benefits from Slire’s
applyFilterandbuildUpdateOperationhelpers, so you don’t forget about scope, soft deletes, timestamps, or versioning.
Here’s a (simplified) example:
export async function recomputeProjectTaskSummaries({
mongoClient,
tenantId,
now = new Date(),
}: {
mongoClient: MongoClient;
tenantId: string;
now?: Date;
}): Promise<void> {
const taskRepo = createTaskRepo(mongoClient, tenantId);
const projectRepo = createProjectRepo(mongoClient, tenantId);
await mongoClient.withSession(async (session) => {
await session.withTransaction(async () => {
const summaries = await taskRepo.collection
.aggregate<{
_id: string;
openTaskCount: number;
completedTaskCount: number;
overdueOpenTaskCount: number;
nextDueDate?: Date;
}>(
[
{ $match: taskRepo.applyFilter({}) },
{
$group: {
_id: '$projectId',
openTaskCount: {
$sum: {
$cond: [{ $in: ['$status', ['todo', 'in_progress']] }, 1, 0],
},
},
completedTaskCount: {
$sum: { $cond: [{ $eq: ['$status', 'done'] }, 1, 0] },
},
overdueOpenTaskCount: {
$sum: {
$cond: [
{
$and: [
{ $in: ['$status', ['in_progress']] },
{ $lt: ['$dueDate', now] },
],
},
1,
0,
],
},
},
nextDueDate: {
$min: {
$cond: [{ $in: ['$status', ['todo', 'in_progress']] }, '$dueDate', null],
},
},
},
},
],
{ session }
)
.toArray();
await projectRepo.collection.bulkWrite(
summaries.map((s) => ({
updateOne: {
filter: projectRepo.applyFilter({ _id: s._id }),
update: projectRepo.buildUpdateOperation({
set: {
openTaskCount: s.openTaskCount,
completedTaskCount: s.completedTaskCount,
overdueOpenTaskCount: s.overdueOpenTaskCount,
hasOverdueTasks: s.overdueOpenTaskCount > 0,
nextDueDate: s.nextDueDate ?? null,
},
}),
},
})),
{ session }
);
});
});
}
Here:
- The query is pure MongoDB (
aggregatewith$match,$group,$min, etc.). -
taskRepo.applyFilter(...)ensures you only touch active tasks in the right tenant. -
projectRepo.buildUpdateOperation(...)ensures_updatedAt,_version, and_traceare applied consistently.
This is a good example of Slire’s “native‑first with guardrails” philosophy.
How it fits into a larger architecture
Slire is designed to be composed:
- Use
createTaskRepo/createProjectRepoin application services. - Wrap repositories into domain-specific data access modules (
createTaskDataAccess,createProjectDataAccess) that expose only the operations your business logic needs. - Optionally, compose those modules into a single
createDataAccessfactory for convenience in HTTP handlers, jobs, and tests.
The Data Access Design Guide goes into:
- Explicit dependencies vs injecting repositories everywhere.
- The “sandwich method” for clean read/process/write flows.
- Specialized data access functions and adapters.
- Using specifications without over‑abstracting your queries.
- Deciding between unified vs modular factories.
If you care about keeping your services maintainable as they grow, I think you’ll find it useful even beyond Slire itself.
Status, roadmap, and how to try it
Slire is currently at v1.0.2. It’s still young, but the core API is stable and:
- Tested against MongoDB (via
mongodbdriver) and Firestore (@google-cloud/firestore). - Uses TypeScript and targets modern Node.js (20+).
- Comes with examples, a detailed README, and design docs.
You can install it today:
pnpm add slire
# or
npm install slire
👉 GitHub: dchowitz/slire
👉 Docs: Why Slire?, Data Access Design Guide, and Audit Trail Strategies with Tracing
I’d love feedback, questions, or ideas:
- Is this a pattern that would help your team?
- What’s missing for you to try it in a side project or production service?
- Do you have war stories from ODMs or ad‑hoc repos that Slire could help with?
Drop a comment, open an issue, or ping me on LinkedIn—I'd be happy to chat and improve this together. 🙌
Top comments (0)