Slire: A Minimal Repository Layer for Node.js + MongoDB/Firestore

Slire: A Minimal Repository Layer for Node.js + MongoDB/Firestore

Source: Dev.to

What Slire is (and isn’t) ## Quickstart: a scoped Task repository ## Designing a clean data access layer ## 1. Explicit dependencies instead of global repositories ## 2. Client-side “stored procedures” without giving up control ## How it fits into a larger architecture ## Status, roadmap, and how to try it Over the last few years building Node.js backend services on MongoDB and Firestore, I kept running into the same pattern: 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. Slire is intentionally minimal. It doesn’t try to be an ODM or replace your database driver. What it doesn’t try to do: 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? 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: You can create a repository factory like this: 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. 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. Instead of injecting a huge TaskRepo everywhere and calling methods ad‑hoc, Slire encourages narrow, explicit ports: Your business logic then depends on simple functions, not on a giant repository interface: I go much deeper into this here: 👉 Data Access Design Guide 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: Here’s a (simplified) example: This is a good example of Slire’s “native‑first with guardrails” philosophy. Slire is designed to be composed: The Data Access Design Guide goes into: If you care about keeping your services maintainable as they grow, I think you’ll find it useful even beyond Slire itself. Slire is currently at v1.0.2. It’s still young, but the core API is stable and: You can install it today: 👉 GitHub: dchowitz/slire 👉 Docs: Why Slire?, Data Access Design Guide, and Audit Trail Strategies with Tracing I’d love feedback, questions, or ideas: Drop a comment, open an issue, or ping me on LinkedIn—I'd be happy to chat and improve this together. 🙌 Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: // task.ts export type Task = { id: string; tenantId: string; // scope title: string; status: 'todo' | 'in_progress' | 'done' | 'archived'; dueDate?: Date; _createdAt?: Date; }; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: // task.ts export type Task = { id: string; tenantId: string; // scope title: string; status: 'todo' | 'in_progress' | 'done' | 'archived'; dueDate?: Date; _createdAt?: Date; }; CODE_BLOCK: // task.ts export type Task = { id: string; tenantId: string; // scope title: string; status: 'todo' | 'in_progress' | 'done' | 'archived'; dueDate?: Date; _createdAt?: Date; }; CODE_BLOCK: 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, }, }); } Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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, }, }); } CODE_BLOCK: 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, }, }); } CODE_BLOCK: 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 }); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: 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 }); CODE_BLOCK: 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 }); COMMAND_BLOCK: // 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 } }); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: // 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 } }); } COMMAND_BLOCK: // 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 } }); } COMMAND_BLOCK: 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 }; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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 }; } COMMAND_BLOCK: 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 }; } COMMAND_BLOCK: 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 } ); }); }); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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 } ); }); }); } COMMAND_BLOCK: 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 } ); }); }); } COMMAND_BLOCK: pnpm add slire # or npm install slire Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: pnpm add slire # or npm install slire COMMAND_BLOCK: pnpm add slire # or npm install slire - 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. - 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 (_deleted) - Timestamps (_createdAt, _updatedAt, _deletedAt) - Versioning (_version) - Optional per‑write trace data (who did what, when) - Exposes helpers like applyFilter and buildUpdateOperation so you can safely use the native driver for complex operations, while keeping behavior (scope, soft delete, versioning, tracing) consistent. - Soft delete (_deleted) - Timestamps (_createdAt, _updatedAt, _deletedAt) - Versioning (_version) - Optional per‑write trace data (who did what, when) - 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. - 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.). - Uses MongoDB’s aggregate directly for performance and flexibility. - Still benefits from Slire’s applyFilter and buildUpdateOperation helpers, so you don’t forget about scope, soft deletes, timestamps, or versioning. - The query is pure MongoDB (aggregate with $match, $group, $min, etc.). - taskRepo.applyFilter(...) ensures you only touch active tasks in the right tenant. - projectRepo.buildUpdateOperation(...) ensures _updatedAt, _version, and _trace are applied consistently. - Use createTaskRepo / createProjectRepo in 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 createDataAccess factory for convenience in HTTP handlers, jobs, and tests. - 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. - Tested against MongoDB (via mongodb driver) and Firestore (@google-cloud/firestore). - Uses TypeScript and targets modern Node.js (20+). - Comes with examples, a detailed README, and design docs. - 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?