Queues, Tenancy, and the ORM Trap

Every early-stage codebase has three or four decisions baked in on day one that nobody thought of as decisions. They just picked whatever got a demo working by Friday. Most of the time that's the right call — you don't architect for scale you don't have yet. But three specific shortcuts show up again and again in codebases that later need a rewrite, and all three look completely reasonable at the time.

The queue you didn't build

Somewhere around the third feature that needs to "happen after" something else — send an email once a signup completes, resize an image after upload, sync a record to a third-party API — someone reaches for the fastest tool available: fire it off inline, in the request handler, and move on.

It works. Right up until the third-party API has a bad day and takes your signup flow down with it, or the image resize job runs long enough to hit a request timeout and the user sees a 500 for something that had nothing to do with their signup.

The fix isn't "add Redis and Sidekiq/BullMQ on day one" — that's its own kind of premature complexity for a project with twelve users. It's drawing the line early: anything that isn't strictly required to answer the current request doesn't belong in the request. Even a dumb setTimeout or a database table you poll every thirty seconds beats doing slow, unreliable work synchronously. The real queue can come later. The separation between "must happen now" and "can happen after" can't — retrofitting that boundary into a codebase where every handler does five unrelated things at once is where weeks go to die.

Tenancy: the assumption that's everywhere and nowhere

Single-tenant thinking doesn't announce itself as a decision. It shows up as a users table with no organization_id column, a cache key that's just the resource ID with no account scope, a background job that loops over "all records" instead of "all records for this tenant." None of this is wrong for a single-customer MVP. It becomes a problem the day a second customer signs up and their data shows up in the first customer's dashboard, because a query somewhere forgot to filter.

Retrofitting tenancy after the fact means auditing every query, every cache key, every job, every log line — anywhere an ID could leak across a boundary that didn't exist when the code was written. That's not a refactor, it's closer to reading the entire codebase twice.

You don't need multi-tenant infrastructure — separate databases, row-level security, any of it — before you need it. What you need is to put the tenant ID in the schema and thread it through every query from day one, even if there's only ever one tenant. It costs almost nothing now. Adding it later costs a security incident, or at minimum a very uncomfortable customer call.

The ORM trap

ORMs are good at the thing they're good at: turning INSERT INTO users (...) into User.create(...). The trap isn't using one. It's letting the ORM's object model become the application's actual data model, so that "how do I query this" gets answered by "whatever the ORM makes easy" instead of "what does the database actually need to do here."

The tell is usually an N+1 query nobody notices until the users table hits six figures, or a report that needs three joins and a window function getting built as five separate ORM calls glued together in application code because nobody remembers how to drop into raw SQL. Prisma, ActiveRecord, TypeORM — doesn't matter which one, they all encourage this same drift if nothing pushes back on it.

The fix costs one sentence of discipline: the ORM is for the 80% of queries that are simple CRUD, and raw SQL (or the query builder underneath it) is for anything involving aggregation, joins across more than two tables, or performance that actually matters. Mixing both in the same codebase isn't a compromise — it's just using each tool for what it's for.

What these three have in common

None of them are wrong choices. Inline processing, no tenant scoping, ORM-only queries — every one of them is the fastest path to a working demo, and for a project that might not exist in six months, fast is correct. The trap isn't making the cheap choice. It's not knowing you made one, so nobody revisits it once the constraints that justified it are gone.