I picked MongoDB on day one. By month eight I needed Postgres.
The Day-One Decision
The decision took about nine minutes. It was the start of a new product - VirtualRx, a business-management app for small retailers in Lagos. Point of sale, inventory, invoices, the lot. I needed a database, I had a schema roughly in my head, and MongoDB let me start writing code that afternoon instead of arguing with migrations. Nine minutes. createdb, connection string, first model, move on.
That decision was fine for about eight months.
The Breaking Point
Then a client asked a question I couldn't answer: "Which of my three branches actually makes money on Tuesdays?"
It's not an exotic question. It's a GROUP BY with a couple of joins and a window function. On Postgres it's ten lines. On Mongo it was an aggregation pipeline I rewrote four times, got working, and then couldn't read a week later. I remember staring at a $lookup inside a $facet and thinking: I chose this in nine minutes, and now I'm stuck with it.
The Two-Data-Layer Problem
The obvious move was to add Postgres for reporting. Keep Mongo for the operational stuff, pipe the analytics somewhere that likes analytics. Sensible. Boring. Standard.
Except that meant two data layers. Two sets of models. Two mental models. And every helper I'd written - soft delete, tenant scoping, audit logging - needed a second implementation that behaved almost the same.
What I Actually Wanted
I wanted to write this:
const rows = await db.sale.findMany({
where: { locationId, postedAt: { gte: start } },
include: { customer: true },
});
β¦and have it not care which database was underneath. Not "abstract away SQL." Not "one query language to rule them all." Just: the ordinary 90% of queries should be portable, and the other 10% should drop to raw SQL without ceremony.
Surveying the Landscape
I looked at what existed.
- Prisma has the API shape I wanted. But it's a code generation step. Every schema change means
prisma generate, a regenerated client committed or gitignored, a Rust query engine binary in your deploy, and a real fight to run in a browser or on React Native. I've watched a teammate lose an afternoon to a stale generated client that looked fine. - Drizzle is excellent and I nearly used it. But it's SQL-shaped by design - which is the right call for a SQL-only tool, and the wrong one when one of my six targets is MongoDB.
- TypeORM and friends: decorators, metadata, a lot of machinery. None of them ran in a browser tab. That mattered more than I expected, and I'll come back to it in a later post.
So I Wrote My Own: forge-orm
It's a small, Prisma-shaped data layer for MongoDB, PostgreSQL, MySQL, SQLite, DuckDB and SQL Server. You write models once in plain TypeScript and the same query code runs against any of them.
npm install forge-orm
npm install pg # only the driver you actually use
import { createDb, f, model } from 'forge-orm';
const User = model('users', {
id: f.id(),
email: f.string().unique(),
name: f.string(),
age: f.int().optional(),
});
const db = await createDb({
url: process.env.DATABASE_URL!, // postgres:// β¦ | mongodb:// β¦ | sqlite: β¦
schema: { user: User },
});
const adults = await db.user.findMany({
where: { age: { gte: 18 } },
orderBy: { name: 'asc' },
take: 20,
});
adults is fully typed. There was no build step. There is no generated client in your repo. Change the model, and the types change on the next keystroke - because they're inferred from the object you just wrote, not produced by a tool you have to remember to run.
Point DATABASE_URL at Mongo instead and that same findMany runs as a Mongo find. Point it at SQLite and it's a SELECT. The query code doesn't move.
The Packaging Detail That Mattered
forge ships no database driver of its own. Each one is an optional peer dependency, so npm install forge-orm pulls nothing extra, and importing forge needs no driver at all.
| Database | URL starts with | Install |
|---|---|---|
| PostgreSQL | postgres:// |
npm install pg |
| MySQL / MariaDB | mysql:// |
npm install mysql2 |
| SQLite | sqlite: or file: |
npm install better-sqlite3 |
| MongoDB | mongodb:// |
npm install mongodb |
| DuckDB | duckdb: |
npm install @duckdb/node-api |
| SQL Server | mssql: |
npm install mssql |
That sounds like packaging trivia. It turned out to be the thing that let forge run in places an ORM normally can't - a browser tab, an Expo app, an embedded Postgres compiled to WebAssembly. Those posts are coming.
What It Is Not
I want to be straight about this, because I've been burned by READMEs that weren't.
forge is not a replacement for Prisma or Drizzle in maturity. It has fewer features, a much smaller ecosystem, and no GUI. Prisma's migration tooling is more battle-tested than mine. Drizzle's SQL surface is broader. If you need either of those things, use them - genuinely.
What Forge Gives You
- A small dependency you can read in an afternoon
- One query API across six databases
- Full autocomplete with nothing to regenerate
- The ability to drop to raw SQL whenever the abstraction stops helping
const rows = await db.$queryRaw`
SELECT location_id, SUM(total) AS revenue
FROM sales
WHERE posted_at >= ${start}
GROUP BY location_id
`;
That escape hatch is not an admission of failure. It's the point. An ORM that tries to own every query eventually owns the ones it's bad at.
Did It Actually Solve the Problem?
VirtualRx still runs on MongoDB. The analytics moved to a Postgres read model. Both go through the same db.<model>.findMany calls, the same models, the same soft-delete and tenant-scoping helpers. The report that started all this is about fifteen lines now. And the nine-minute decision stopped being permanent, which is really all I wanted.
Next in This Series
The codegen step - why prisma generate exists, what it's actually solving, and how you get the same autocomplete without it.
π Docs: johnsonfash.github.io/forge-orm
π¦ npm: forge-orm
β GitHub: johnsonfash/forge-orm
If you've been stuck with a day-one database decision, I'd genuinely like to hear what it was. That's half of why I wrote this.
Comments
No comments yet. Start the discussion.