Skip to main content
Version: 4.0.0-preview

In-Memory DB

The InMemoryDBProvider is a built-in provider that ships with @expressots/core. It offers a high-performance, Prisma-like query API backed entirely by in-process memory.

Scope: development, testing, and prototyping

The in-memory database is designed for development, testing, and rapid prototyping — not as a durable production datastore. Data lives in process memory (with optional file snapshots), so it does not provide the crash safety, concurrency, or multi-process guarantees of a real database engine.

The provider implements the universal IDataAdapter contract, so when you are ready for production you can swap it for a real database adapter (Prisma, TypeORM, etc.) without rewriting your repositories.

Register the provider

InMemoryDBProvider is not auto-registered. Bind it explicitly as a singleton in configureServices():

src/app.ts
import { AppExpress } from "@expressots/adapter-express";
import { InMemoryDBProvider, Scope } from "@expressots/core";

export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Provider.register(InMemoryDBProvider, Scope.Singleton);
}
}

Registering as a singleton is required so data persists across requests and so the provider's lifecycle hooks (bootstrap, shutdown) run on a single shared instance.

Optional configuration

Configure global behavior by retrieving the provider and calling configure():

async configureServices(): Promise<void> {
this.Provider.register(InMemoryDBProvider, Scope.Singleton);

const db = this.Provider.get(InMemoryDBProvider);
db.configure({
timestamps: true, // auto-manage createdAt / updatedAt (default: true)
softDelete: false, // soft-delete via deletedAt (default: false)
logging: false, // log operations to console (default: false)
persist: {
storage: "file",
path: "./data/db.json",
interval: 30000, // auto-save every 30s
},
});
}

Define an entity

Use the schema decorators to describe your entity. See Entities for the full decorator reference.

src/entities/user.entity.ts
import { Entity, PrimaryKey, AutoGenerate, Index, Unique } from "@expressots/core";

@Entity({ name: "users", timestamps: true })
export class User {
@PrimaryKey()
@AutoGenerate("uuid")
id!: string;

name!: string;

@Index()
@Unique()
email!: string;

age!: number;
}

Access a table

Get a table adapter from the provider with table<T>(name, EntityClass). Passing the entity class enables schema metadata such as indexes, auto-generated fields, and relation include resolution.

import { IEntity } from "@expressots/core";

interface UserModel extends IEntity {
name: string;
email: string;
age: number;
}

const users = db.table<UserModel>("users", User);

Prisma-like queries

The table adapter exposes a Prisma-compatible API:

// Create
const user = await users.create({
data: { name: "John", email: "[email protected]", age: 30 },
});

// Create many
await users.createMany({
data: [
{ name: "Jane", email: "[email protected]", age: 25 },
{ name: "Bob", email: "[email protected]", age: 40 },
],
});

// Find with complex filters
const adults = await users.findMany({
where: {
OR: [{ name: { contains: "Jo" } }, { age: { gte: 18 } }],
},
orderBy: { age: "desc" },
take: 10,
});

// Find one
const byId = await users.findUnique({ where: { id: user.id } });
const byEmail = await users.findFirst({ where: { email: "[email protected]" } });

// Update
await users.update({ where: { id: user.id }, data: { age: 31 } });
await users.updateMany({ where: { age: { lt: 18 } }, data: { age: 18 } });

// Upsert
await users.upsert({
where: { id: "missing-id" },
update: { name: "Updated" },
create: { name: "New", email: "[email protected]", age: 20 },
});

// Delete
await users.delete({ where: { id: user.id } });
await users.deleteMany({ where: { age: { gte: 65 } } });

// Count & aggregate
const total = await users.count({ where: { age: { gte: 18 } } });
const stats = await users.aggregate({ _avg: { age: true }, _max: { age: true } });

Supported where operators include equals, not, in, notIn, lt, lte, gt, gte, contains, startsWith, endsWith, and case-insensitive matching via mode: "insensitive", combined with the logical operators AND, OR, and NOT.

Repository pattern

For a cleaner separation, extend BaseRepository<T> and inject the provider. The repository delegates to the table adapter and is where you add custom query methods.

src/repositories/user.repository.ts
import { BaseRepository, InMemoryDBProvider, provide, inject } from "@expressots/core";
import { User } from "../entities/user.entity";

interface UserModel {
id?: string;
name: string;
email: string;
age: number;
}

@provide(UserRepository)
export class UserRepository extends BaseRepository<UserModel> {
constructor(@inject(InMemoryDBProvider) db: InMemoryDBProvider) {
super(db, "users", User);
}

async findByEmail(email: string): Promise<UserModel | null> {
return this.findFirst({ where: { email } });
}
}

BaseRepository exposes findUnique, findFirst, findMany, create, createMany, update, updateMany, upsert, delete, deleteMany, count, aggregate, and transaction.

Usage in a use case

src/usecases/user-create.usecase.ts
import { provide, inject } from "@expressots/core";
import { UserRepository } from "../repositories/user.repository";

@provide(UserCreateUseCase)
export class UserCreateUseCase {
constructor(@inject(UserRepository) private userRepo: UserRepository) {}

async execute(payload: IUserCreateRequestDTO): Promise<IUserCreateResponseDTO> {
const user = await this.userRepo.create({
data: { name: payload.name, email: payload.email, age: payload.age },
});

return { id: user.id, name: user.name, email: user.email };
}
}

Relations

Declare relations with @HasOne, @HasMany, @BelongsTo, and @ManyToMany, then resolve them with include:

const userWithPosts = await users.findUnique({
where: { id: user.id },
include: { posts: true },
});

Relation include requires the table adapters to be created with their entity classes (db.table("posts", Post)) so the schema metadata is available. See Entities → Relation decorators.

Transactions

Run multiple operations atomically. If the function throws, all changes roll back.

await db.transaction(async (tx) => {
const user = await tx.table<UserModel>("users", User).create({
data: { name: "John", email: "[email protected]", age: 30 },
});

await tx.table<PostModel>("posts", Post).create({
data: { title: "Hello", authorId: user.id },
});
});

Persistence

By default data lives only in memory and is cleared on restart. Enable file-based snapshots through configure({ persist: ... }) (see above). Snapshots are written as a JSON dump of all tables and reloaded on bootstrap(). This is intended for dev convenience, not high-throughput durability.

Testing

The in-memory DB shines in tests — it is fast, isolated, and requires no external services.

Unit testing a repository

import { InMemoryDBProvider } from "@expressots/core";
import { UserRepository } from "../repositories/user.repository";

describe("UserRepository", () => {
let repository: UserRepository;

beforeEach(() => {
const db = new InMemoryDBProvider();
repository = new UserRepository(db);
});

it("creates and finds a user", async () => {
const user = await repository.create({
data: { name: "John", email: "[email protected]", age: 30 },
});

expect(user.id).toBeDefined();
expect(await repository.findByEmail("[email protected]")).toMatchObject({
name: "John",
});
});
});

E2E testing with controllers

describe("User CRUD E2E", () => {
let app: IWebServerPublic;
let port: number;

beforeAll(async () => {
app = await bootstrap(App, { port: 0 });
port = await app.getPort();
});

afterAll(async () => {
await app.close();
});

it("creates and retrieves a user", async () => {
const createRes = await fetch(`http://localhost:${port}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "John", email: "[email protected]" }),
});
const created = await createRes.json();
expect(created.id).toBeDefined();

const getRes = await fetch(`http://localhost:${port}/users/${created.id}`);
const user = await getRes.json();
expect(user.name).toBe("John");
});
});

For a dedicated lightweight test store, see also createTestDatabase() in Testing.

Health checks and metrics

InMemoryDBProvider implements IHealthCheck and IMetrics, so it contributes to the aggregated health and metrics dashboards automatically:

const health = await this.Provider.checkHealth();
// includes "In-Memory Database Provider": { status: "healthy", details: { tables, records } }

const metrics = this.Provider.collectMetrics();
// includes db.tables, db.records.total, db.queries.total, per-table records/memory

Migrating to a real database

Because repositories depend on the table adapter's IDataAdapter contract rather than on the in-memory implementation directly, moving to a production database is a swap rather than a rewrite: register a real adapter implementing the same contract and keep your repository and use-case code unchanged.


Support the Project

ExpressoTS is MIT-licensed open source. See the support guide to contribute.