Skip to main content
Version: 0.5.0 (Previous)

πŸ’Ύ Using a Custom DataStore

Nodeblocks includes MongoDB examples by default, but any storage engine can be used as long as it implements the required interface that handlers expect. This flexibility allows you to use SQL databases, Redis, flat files, or even in-memory storage for testing.

πŸ“‹ Required Interface​

Your custom datastore must implement these methods:

  • insertOne(doc) β†’ { insertedId, acknowledged }
  • findOne(filter) β†’ the document or null
  • find(filter) β†’ { toArray(): Promise<Record[]> }
  • updateOne(filter, { $set }) β†’ { modifiedCount }
  • deleteOne(filter) β†’ { deletedCount }

Below, we'll implement a JSON file adapter that demonstrates this contract and show how to integrate it with services.


1️⃣ Implement the Adapter​

datastore/jsonFileDataStore.ts
import {promises as fs} from 'fs';

interface DbRecord {
id: string;
[key: string]: unknown;
}
interface QueryFilter {
id?: string;
[key: string]: unknown;
}
interface UpdateOperation {
$set: Record<string, unknown>;
}

const dbFile = 'db.json';

export const jsonFileDataStore = {
/* Create */
async insertOne(doc: DbRecord) {
const records = await readAll();
records.push(doc);
await writeAll(records);
return { insertedId: doc.id, acknowledged: true };
},

/* Read single */
async findOne(query: QueryFilter) {
const records = await readAll();
return records.find((r) => match(r, query)) ?? null;
},

/* Read many */
find(query: QueryFilter = {}) {
return {
async toArray() {
const records = await readAll();
return records.filter((r) => match(r, query));
},
};
},

/* Update */
async updateOne(query: QueryFilter, update: UpdateOperation) {
const records = await readAll();
const idx = records.findIndex((r) => match(r, query));
if (idx === -1) return { modifiedCount: 0, acknowledged: true };
records[idx] = { ...records[idx], ...update.$set };
await writeAll(records);
return { modifiedCount: 1, acknowledged: true };
},

/* Delete */
async deleteOne(query: QueryFilter) {
const records = await readAll();
const newRecords = records.filter((r) => !match(r, query));
await writeAll(newRecords);
return { deletedCount: records.length - newRecords.length, acknowledged: true };
},
};

/* ------------------------------------- */

function match(record: DbRecord, query: QueryFilter) {
return Object.entries(query).every(([k, v]) => record[k] === v);
}

async function readAll(): Promise<DbRecord[]> {
try {
const raw = await fs.readFile(dbFile, 'utf8');
return JSON.parse(raw);
} catch {
return [];
}
}

async function writeAll(records: DbRecord[]) {
await fs.writeFile(dbFile, JSON.stringify(records, null, 2));
}

Why These Methods?​

Nodeblocks handlers only call the methods listed above. If your storage technology (SQL, Redis, REST API, etc.) can implement these methods, you can use it without modifying any service code. This abstraction provides maximum flexibility while maintaining compatibility.


2️⃣ Using the Custom DataStore​

Because services use dependency injection, you pass your custom datastore when creating the service:

import {services} from '@nodeblocks/backend-sdk';
import {jsonFileDataStore} from './datastore/jsonFileDataStore';

-- snap --
services.userService({users: jsonFileDataStore}, {});
-- snap --

That's itβ€”the userService will now persist data to db.json.

Note: This example is specifically designed for the user service. Other services (like organizationService, productService, etc.) may require additional database methods beyond the core CRUD operations shown above, such as specialized query methods or aggregation functions.


3️⃣ Implementation Checklist​

When implementing a custom datastore, ensure:

  1. Method signatures match those expected by the handlers (insertOne, findOne, etc.)
  2. Return types contain the required fields (insertedId, modifiedCount, etc.)
  3. Async support - all methods must return Promises since handlers treat them as async
  4. Error handling - implement proper error handling to prevent crashes

When these conditions are met, you gain complete freedom to use flat files, SQL databases, cloud functions, or even in-memory mocks for testing.


πŸ§ͺ Testing with Mock DataStore​

For unit tests, you can create a simple in-memory implementation that follows the same interface:

export const memoryDataStore = {
_data: [] as any[],
async insertOne(doc) { this._data.push(doc); return { insertedId: doc.id }; },
async findOne(q) { return this._data.find((r) => r.id === q.id); },
find() { return { toArray: async () => this._data }; },
async updateOne(q, u) { /* ... */ return { modifiedCount: 1 }; },
async deleteOne(q) { /* ... */ return { deletedCount: 1 }; },
};

This mock datastore is perfect for testing because it:

  • Requires no I/O - all operations happen in memory
  • Follows the same interface - can be swapped with any real datastore
  • Provides fast tests - no database setup or cleanup required

πŸ”§ Common Use Cases​

Development and Testing​

  • JSON files - Simple setup, human-readable data
  • In-memory storage - Fast tests, no persistence needed
  • SQLite - File-based SQL database, no server required

Production Environments​

  • PostgreSQL/MySQL - Robust, scalable relational databases
  • MongoDB - Document database with rich querying
  • Redis - High-performance caching and session storage

Cloud and Serverless​

  • DynamoDB - AWS managed NoSQL database
  • Firestore - Google Cloud document database
  • CosmosDB - Azure multi-model database

βœ… Summary​

  • Interface compliance - Implement the five required methods with correct signatures
  • Dependency injection - Pass your datastore when creating services
  • Storage flexibility - Use any storage technology that can implement the interface
  • Testing support - Create in-memory mocks for fast, reliable unit tests
  • Production ready - Switch between development and production datastores without code changes

This abstraction gives you complete freedom to choose the right storage solution for your needs while maintaining compatibility with all Nodeblocks services.