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_idis the specific business (UUID). -
app_idis 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.
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;
}
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.






















