惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
The Blog of Author Tim Ferriss
IT之家
IT之家
Engineering at Meta
Engineering at Meta
WordPress大学
WordPress大学
博客园 - 三生石上(FineUI控件)
博客园 - 聂微东
C
Check Point Blog
T
Tailwind CSS Blog
博客园 - Franky
H
Help Net Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Google DeepMind News
Google DeepMind News
博客园 - 叶小钗
J
Java Code Geeks
腾讯CDC
罗磊的独立博客
爱范儿
爱范儿
阮一峰的网络日志
阮一峰的网络日志
Martin Fowler
Martin Fowler
酷 壳 – CoolShell
酷 壳 – CoolShell
I
InfoQ
B
Blog
V
Visual Studio Blog
F
Fortinet All Blogs

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
One backend, four products: why we bet on platform-per-brand
MD RASHEDUL · 2026-05-23 · via DEV Community

by Rashed

We shipped an auth bandaid at 2am. Cookies wouldn't flow between platform.ginilab.com and our gateway, which was running under a different registrable domain. Browsers blocked them, correctly. The bandaid that unblocked the demo was a 5-minute bearer token held in a Zustand store on the frontend, attached by hand to every request. It worked.

Within 24 hours we'd shipped four PRs of cookie-domain workarounds. Then someone asked the obvious question: "why isn't api.ginilab.com just another hostname on the same gateway?" It was. We were deep into a problem we'd solved in a single DNS record.

That bug — cookie-domain mismatch in a multi-brand platform — is the one-paragraph version of why this post exists.

One caveat before we go further. v3 is in staging. It's not yet processing live payments. 300+ restaurants run on our legacy PHP/MySQL stack today, and the cohort migration hasn't started. What follows is the architecture we bet on and the pain we hit getting here, not a victory lap. If you want a "we scaled to a billion requests" story, this isn't it. If you want an honest mid-migration account from a small team, read on.

What "platform-per-brand" actually means

Ginilab is one backend platform that runs four products. Tomafood is restaurant ordering, with 300+ restaurants live on the legacy stack and the full rewrite to v3 in progress. CloudPOS is a POS for non-food retail. iSchool is school management. Ecommerce is generic ecommerce. Tomafood is the full rewrite. The other three consume shared services via REST or SDK. They aren't separate codebases. They aren't separate backends. They're different products mounted on the same platform.

This is unusual. Most SaaS teams either build one product and stay there, or build separate platforms per product when product two arrives. The shared-platform-across-products shape is the third path, and it has a tax: every architectural decision has to assume more than one consumer, more than one brand, more than one domain. The tax shows up early and never goes away.

We took the tax on purpose. We knew CloudPOS and iSchool were coming. Without that, this would have been overengineering.

[INSERT DIAGRAM 1 HERE — architecture sketch: four products on top, shared multi-hostname gateway in the middle, shared services below keyed by business_id + app_id, Tomafood-only restaurant-service off to the side.]

We rejected the obvious answer twice

The obvious answer when a second product appears is to fork. Take the codebase that works for product one, copy it, change the domain, run a second backend. Engineers know how to do this. It feels safe.

We rejected it twice. The first time was when CloudPOS came online and the temptation was to fork Tomafood's auth service and run it as a second backend behind the POS product. The second time was when iSchool was scoped and the temptation flipped: extract microservices per product, one stack per vertical. Both options were wrong for the same underlying reason. A customer who orders on Tomafood and later signs up for CloudPOS should be the same identity. Forking the auth service means reconciling those identities later. Per-product microservices means reconciling them four times.

The version that doesn't require reconciliation is one auth service, multi-tenant by design, with every shared service carrying both who the business is and which product they're using.

The design rule that makes it work

Every shared service in the platform — auth, addresses, payments, notifications, gateway — carries two identifiers on every query:

  • business_id is the specific business (UUID).
  • app_id is which product they're using: tomafood, cloudpos, ischool, and so on.

Not restaurant_id. There is no restaurant_id column anywhere in a shared service. restaurant_id is a Tomafood-only concept that lives only in the Tomafood product service.

The pair flows through JWT claims and is enforced at the repository layer. We say this in the CLAUDE.md at the root of the repo about as bluntly as we can:

Shared services NEVER use restaurant_id — always business_id + app_id.
Repository enforces WHERE business_id = ? on every query.
JWT claims include businessId + appId.

Enter fullscreen mode Exit fullscreen mode

In practice that means a row in auth_db.users doesn't know what a restaurant is. It knows it belongs to a business, and the business runs on an app. A row in restaurant_db.recipes does know what a restaurant is, because restaurant_db belongs to Tomafood and restaurant_id is meaningful there.

The boundary is consistent. Shared services see businesses. The Tomafood product service sees restaurants. That sentence took us a long time to write down, and longer to enforce.

[INSERT DIAGRAM 2 HERE — decision tree: shared service? then business_id + app_id. restaurant-service? then restaurant_id is fine. Neither? then you're in the wrong file.]

The multi-brand pain, made concrete

The cookie story from the opener is what happens when "multi-brand" stops being an abstract design rule and becomes a Tuesday. Each restaurant on Tomafood can run on its own white-label domain — their brand, their registrable domain. The platform has its own brand. The gateway has to accept cookies from all of them.

The first version of the cookie-domain helper was a security hole. It checked host.includes('ginilab.com') to decide whether to set the cookie's domain attribute. A lookalike host like ginilab.com.evil.example would have passed that check. The second version checks suffix-with-leading-dot:

// packages/shared/src/cookies/pick-domain.ts
export function pickCookieDomain(
  origin: string | undefined
): string | undefined {
  if (!origin) return undefined;
  const host = new URL(origin).hostname;
  if (host.endsWith('.ginilab.com')) return '.ginilab.com';
  // ... plus one branch per brand registrable domain
  // Lookalike-domain defence: must end with the LEADING dot,
  // not just contain the string.
  return undefined;
}

Enter fullscreen mode Exit fullscreen mode

A handful of lines. They exist because we have more than one brand on one platform. If we'd had one brand, this would have been a hardcoded constant. If we'd had four separate backends, each one would have hardcoded its own constant, and the bug would live in four places.

This is the smallest, ugliest example of the platform-per-brand tax. There are larger ones. They all have the same shape: a thing that would be a constant in a single-brand world becomes a function in a multi-brand one. The function is the cost.

What we picked, what we rejected, why

We picked one backend platform, multi-product, multi-brand. Shared services keyed by business_id + app_id. The Tomafood-only product service keeps restaurant_id. JWT carries both identifiers. The same gateway is exposed under per-brand hostnames so cookies flow.

We rejected one codebase per product — four backends, four auth services, four databases. This is the standard SaaS path and most teams' default. We rejected it because the products share customers and the reconciliation cost compounds. A user who shops on Ecommerce and orders on Tomafood and whose kid is on iSchool is one human. Four backends would turn that human into four accounts with four passwords and four address books, held together by sync code. We would be writing and maintaining that sync code for years.

We rejected microservices-per-product from day one. Per-vertical stacks, one platform-org per product. We rejected this because we're a small team and the operational surface scales with services rather than users. Splitting before a second consumer exists for any given surface is premature. Our restaurant-service today is a deliberate monolith — it contains menu, orders, kitchen, tables, drivers, reservations, and reviews in one deployable. We will split a surface out the moment a second consumer (CloudPOS, iSchool) needs that surface, and not before.

We gave up the freedom to ship a product-specific schema change without thinking about other products. Every shared schema change has to consider all current and plausible future consumers. That slows down week-to-week work. The bet is that it speeds us up over the lifespan of the platform.

What we got is more boring than it sounds: one auth service, one identity model, one set of secrets to rotate, and a single place to fix every helper.

The takeaway

Platform-per-brand is a bet on product-multiplication. We made it because we knew CloudPOS and iSchool were coming. If you only ever ship one product, this is pure overhead. Every shared-service decision costs more than it would in a single-product codebase, and you get none of the payoff. If you'll ship two, the difference is one team versus four. If you'll ship four, there is no version of this where fork-and-clone stays survivable.

Two questions worth sitting with before betting the same way. Do you know what product two looks like? Does it share customers with product one? If both answers are yes, the platform shape pays off. If either is no, it's overhead.

We'll come back to specific pieces of this in later posts — the idempotency middleware on money paths, the multi-zone CDN purge, the Valkey vs Redis pricing fight, the strategic monolith. Each is its own story. This post is the foundation. Every later decision in the series only makes sense because the platform shape was already chosen.