AZ
All writing
4 min read

The reference architecture I clone across every commerce app

nextjsarchitecturemongodbredispatterns
Part of the projectManagerentaA multi-tenant property/rental management SaaS — and the Next.js Model→Service→Route reference architecture (with a real IAM engine) the other commerce apps clone.View project

Managerenta is a property/rental management app. It is also, quietly, the most reused thing I've built — because its layering became the template every other commerce app inherits. This is a tour of that template and, more usefully, of why each piece is shaped the way it is.

Why have a template at all

Every new product arrives with genuinely novel parts — a domain, a lifecycle, a pricing rule — and a large sameness underneath: how a request authenticates, becomes a validated call, hits the database, gets cached, and comes back as JSON. If that sameness is re-improvised each time, the novelty and the plumbing get debugged together, forever. Managerenta freezes the plumbing so a new app's budget goes to its actual problem. Chekka and Mogadget are literal descendants.

One boundary, three layers

All server code lives under src/server/ and imports "server-only", so the DB, S3, and secrets can't leak into the client bundle. Above that boundary sit three layers, each with one job:

LayerJobForbidden from
ModelMongoose schema + flat xxxDB() fns; invariants in schema hooksHTTP, caching
Servicebusiness logic + Redis caching; one function per filereq / Response
Routeauthorize → safeParse → call service → envelopeDB, cache keys, S3

The rule that makes the layers independently testable is that model reads never throw. Each *DB function wraps a Prometheus timer and, on error, returns null/empty — so the service layer decides what "nothing" means and the route decides the HTTP, with no exceptions crossing a boundary by surprise:

const timer = databaseResponseTimeHistogram.startTimer();
try {
  const result = await Property.findOneAndUpdate(
    { _id, userId, deleted: false }, { $set: payload }, { returnDocument: "after" });
  if (!result) throw ErrPropertyNotFound;
  timer({ operation, collection, method: "updatePropertyDB", success: "true" });
  return { ...result.toObject(), id: result.id };
} catch { timer({ ...,  success: "false" }); return null; }

Notice ownership is a query filter, not a later check: a handler that forgot to scope its read simply selects nothing, because there is no unfiltered read to call by mistake.

The wrapper's order is load-bearing

withApiHandler composes the cross-cutting concerns, and the sequence is a design decision, not an accident. The CSRF Origin/Referer gate runs before the rate limiter — it's the cheaper check (no Redis round-trip), and a rejected request must not spend one of the caller's rate-limit tokens:

if (options.csrf !== false) {
  const reason = csrfReject(req);
  if (reason) { const res = fail(403, reason); observe(req, res.status, options.route, startNs); return res; }
}
if (rl) { rlResult = await enforceRateLimit(req, rl);
  if (!rlResult.allowed) return applyRateLimitHeaders(fail(429, "Too many requests"), rlResult); }
await connectMongoDB();

Authorization that survives a bad policy

Authorization is a real AWS-IAM-style policy engine, not scattered role checks. withAuth first resolves an effectiveOwnerId — the user's own id in personal scope, or the org owner's id once they've switched into an organization — so org members transparently act on the owner's resources through one evaluation path. Personal-scope users even get an in-memory selfScopePolicy, so the engine has no special case:

if (!auth.organizationId) {
  const principalArn = principalArnForUser(auth.userId, auth.userId);
  return { principalArn, policies: [selfScopePolicy(auth.userId)] };
}

The engine itself is pure — no I/O, no clock read (time is injected via the condition context) — which is exactly why it can be exhaustively unit-tested, and it follows AWS semantics: default-deny, explicit-Deny-wins. But the property that lets me trust multi-tenancy sits above the policy loop: a tenant-isolation floor denies any cross-scope org request before a single statement is read.

if (target.plane === "org" && target.orgId !== resourceScope(auth)) {
  return { decision: "deny", reason: "implicit deny (cross-scope org resource)" };
}

Even a wildcard policy can't cross tenants, because the boundary is checked before the policy is.

Receipts, not vibes

The reason it's a reference is that it's proven. 93 Vitest files run against throwaway scratch DBs that a globalSetup drops on teardown; 16 Playwright specs drive auth, 2FA, passkeys, and a full-app path; and a three-pass SECURITY_REVIEW.md catalogues roughly forty findings. The one I quote most is S1: "2FA was never enforced on login; the toggle was decorative" — a feature that looked done, wasn't, and was found by writing the audit down. It was fixed with a two-step ticket flow: password success mints a 5-minute signed ticket and sets no session cookie until a second call verifies the TOTP code.

Getting the skeleton right once, cloning it, and re-running that discipline on each descendant is the whole payoff: the next app argues about its domain, never about how a request becomes a database write.

Back to the projectManagerentaSee the full case study and related write-ups.View project
The reference architecture I clone across every commerce app · Abdullah Zakariyya