FLYER Enterprise · Platform Architecture PRD

Multi-tenant architecture for FLYER Enterprise

How the product becomes a platform: one codebase serving many companies, on many domains, in several regions, each with its own private AI — and how far to go with microservices before they start costing more than they return.

Product
FLYER Enterprise
Scope
Tenancy · domains · services · data · AI plane
Audience
Engineering, security, product
Status
Architecture PRD v1 · for review
Updated
10 Sep 2026
Related
Product PRD · Prototype
Answers three questions: how tenants are isolated, how one build serves many domains, and when a module becomes a service

1Summary and the decision

What runs today at training.flyer.vn is a prototype: static pages, one imaginary company, state in the browser. The product it describes has to serve hundreds of companies, each with its own documents, permissions, branding, domain and AI assistants, in a region of their choosing, with an audit trail a bank would accept. This document is about the distance between those two things.

1 codebase
many tenants, many domains, no per-customer fork
3 cells
EU, APAC, US — a tenant lives in exactly one
1 deployable
at launch, plus workers; services extracted only on evidence
The questionThe decisionWhy this and not the other thing
How are tenants isolated?Three tiers on the same code: pooled (shared tables, row-level security), dedicated schema, dedicated cell. Every row, log line, metric and cost record carries tenant_id.A database per customer is simple to reason about and miserable to operate at a hundred customers: migrations, connection pools, backups and upgrades all multiply. Row-level isolation with a tier escape hatch keeps the common case cheap and the demanding case possible.
How does one build serve many domains?Hostname resolves to a tenant at the edge; branding, language and data follow from the tenant, not from the build. Customer-owned domains get certificates issued automatically.Building a copy per domain means a copy per domain to patch. We already proved the principle on the marketing site this week: one build, self-canonical on every host.
Microservices?Not yet. One modular monolith plus asynchronous workers, with module boundaries enforced in code. Each service is extracted later against a named pressure, not a diagram.Microservices trade a compile error for a production incident. With one team and no traffic yet, that trade is a loss. The boundaries still matter, so we draw them now and keep them honest inside one process.
The part that cannot be retrofittedSplitting a module into a service later is a week of work. Adding a tenant column to a year of data, moving a customer between regions, or reconstructing an audit trail that was never written is a quarter of work and a difficult conversation with a customer. So tenancy, cell placement, the token shape and the event log are designed in full from day one; the service topology is deliberately left cheap to change.

2Principles

  1. The tenant is a dimension, not a filter. Every table, log line, metric, trace span, queue message, cache key, object path and invoice line carries the tenant. Code that can express a query without a tenant should not compile, or should fail a test.
  2. Isolation is architectural where it must be, logical where it may be. Documents, embeddings, prompts and audit logs never cross a tenant boundary or a region boundary. Stateless compute is shared.
  3. Region is placement, not configuration. A tenant belongs to exactly one cell. There is no global database of customer content, so there is no way to accidentally serve EU documents from Virginia.
  4. Extract a service against a named pressure. Independent scaling, independent release cadence, a different runtime, or a blast radius worth containing. "It feels cleaner" is not one of them.
  5. Provenance is a feature. Which chunks a model saw, which were excluded by permissions, which model answered, in which region, and who reviewed the result — this is the product, not logging. It is written on the request path, not reconstructed afterwards.
  6. Boring and replaceable. Postgres, a queue, object storage, one language on the server. Every managed dependency is behind an interface with a named alternative, because customers will demand a substitute at exactly the wrong moment.

3Tenancy model

3.1 Three tiers, one codebase

TierWhoDatabaseVector indexModel callsBlast radius
Pooled
default
Mid-market, 300–5,000 usersShared tables in the cell database, row-level security on tenant_idShared index, one namespace per tenant and scopePlatform keys, region-pinnedCell
Dedicated schemaLarge or regulated customersOwn schema, own connection role, in the same clusterOwn indexPlatform or customer keysSchema
Dedicated cellSingle-tenant and private-cloud contractsOwn cluster in a dedicated cell, optionally in the customer's accountOwn clusterCustomer keys and endpointCustomer only

The tier is a row in the tenant registry. The application does not branch on it: the same code opens a connection whose role and search path were chosen at connection time, and writes to an index whose namespace came from the tenant context. Moving a tenant up a tier is a data migration, not a rewrite.

3.2 How tenant context travels

Edge
Host → tenantDomain map, cached at the edge; unknown host serves the marketing site
Cell routingRequest is sent to the tenant's cell, never fanned out
API
Verify tokenTenant in the token must equal tenant from the host, or the request is rejected
Request contexttenant, cell, user, roles, groups, locale — immutable for the life of the request
No client-supplied tenantA tenant id in a body or query string is ignored
Data
SET LOCAL app.tenant_idSet on the transaction; row-level security policies read it. The application cannot see another tenant's rows even with a wrong query.
Index namespaceDerived from context, never from a parameter
Object prefixtenant/<id>/… with bucket policies to match

Enforced, not documented. A test walks the schema and fails the build if any table lacks a tenant column and a row-level security policy. A second test runs the common queries as tenant A while tenant B's rows exist, and fails if a single row leaks. Background jobs run inside the same context: a job without a tenant cannot be scheduled.

3.3 Noisy neighbours

4Many domains, one build

Kind of domainExampleCertificateResolves to
Platform domainenterprise.flyer.vnOursMarketing site and demo tenant
Tenant subdomainacme.flyerenterprise.comWildcard, oursThat tenant, immediately on signup
Customer domainlearn.acme.comIssued automatically once the customer points a CNAME at usThat tenant, with their branding

Resolution is a lookup, not a build step. The edge holds a cached map of hostname to tenant and cell; the shell is served from the same artefact for every customer, and branding — logo, theme colour, default language — arrives as tenant settings. The prototype already carries that model in the product: a company uploads a logo, the palette is derived from it, and the interface language is a tenant default.

Precedent, shipped this weekThe marketing site is the trivial version of the same idea and it is live: one build, deployed once, served on training.flyer.vn and enterprise.flyer.vn. Absolute URLs — canonical, hreflang, Open Graph, structured data, sitemap and llms.txt — are rewritten to the requesting host at the edge, and in-page links are root-relative, so every domain is self-canonical without a rebuild. Adding a third domain is attaching it to the project.

Search-engine rule for tenant domains. Customer domains and tenant subdomains are marked noindex and excluded from the sitemap. Exactly one public marketing domain is indexed; the others are private front doors that happen to be on the internet.

5Service map and when to split

5.1 What ships at launch

One API deployable, one worker deployable, one edge layer. Inside the API, modules own their tables and talk to each other through published interfaces, not through each other's data. This is the discipline that makes a later extraction a refactor rather than an excavation.

ModuleOwnsDepends onExtract later?
Identity & tenancyTenants, domains, users, groups, roles, SSO and SCIM configBecomes part of the control plane
Catalogue & contentCourses, lessons, versions, media references, SCORM packagesIdentityUnlikely
Assignment & complianceAssignments, rules, recurrence, certifications, attestationsCatalogue, IdentityUnlikely
Player & learning recordsAttempts, scores, xAPI statementsCatalogueCandidate write volume
AssistantAssistants, scopes, conversations, citations, feedbackKnowledge, Identity, AI gatewayCandidate latency and scaling
Knowledge ingestionConnectors, documents, chunks, ACL mirror, index stateIdentityFirst to go
Course StudioDrafts, review state, source linksKnowledge, Catalogue, AI gatewayLater
Skills & analyticsSkills, assessments, gap signals, report definitionsRecords, AssistantCandidate read patterns
NotificationsTemplates, delivery log, digestsEventsLater
Metering & billingActive users, AI usage, quotas, invoicesEventsControl plane

5.2 How modules talk

5.3 Extraction triggers

ServiceExtract whenWhy it is first or last
Knowledge ingestionCrawl work competes with request traffic, or a connector needs a runtime we do not want in the APIAlready asynchronous, owns its tables, talks in events. The cheapest extraction and the most likely pressure.
Assistant / retrievalAnswer latency is dominated by our own queueing, or AI traffic scales on a different curve from page trafficLatency-sensitive and bursty; deserves its own autoscaling long before anything else does.
Analytics & reportingReports interfere with transactional load beyond what a read replica absorbsSolve with a replica and a warehouse export first. Extraction is the second answer, not the first.
Learning recordsStatement volume dominates database writesAppend-only and easy to move, but only worth moving at real scale.
Control planeImmediately — it is global while everything else is cell-localDifferent lifecycle, different blast radius, different availability requirement. This is the one genuine service on day one.
Two failure modes to avoid by nameA distributed monolith: services that must be deployed together because they share a database or a synchronous chain. And a service per noun: a courses service, a lessons service, a tags service, each a thin wrapper over a table, with a network hop where a function call would do. If a proposed service does not own its data and cannot be released alone, it is not a service.

6Data architecture

Per cell
PostgresPrimary plus read replica; row-level security; per-tenant schema for the dedicated tier
Vector + keyword indexNamespace per tenant and scope; ACL stored on every chunk
Object storageMedia, SCORM packages, evidence packs; prefix per tenant; region-locked bucket
Queue and eventsIngestion jobs, outbox relay, consumers
CacheSessions, permission sets, hot config; tenant in every key
Warehouse exportNightly, per tenant, for reporting and their own BI
↕ nothing below this line ever leaves the region
Global
Control plane onlyTenant registry, domain map, plan and entitlements, feature flags, aggregate usage counters. No documents, no embeddings, no prompts, no personal data beyond an admin contact.

Rules that carry a contractual promise

Deleted or de-permissioned source
Out of the index within 24 hours, target one hour; the purge is an event with a receipt
Tenant offboarding
Everything, including backups and logs, within 30 days, with a signed certificate of deletion
Encryption
Envelope encryption, a data key per tenant; enterprise tier holds the master key in their own KMS
Backups
In-region only; restore rehearsed per cell, not per tenant, with a per-tenant restore path for accidental deletion
Schema changes
Expand, migrate, contract — never a blocking rewrite; rolled out cell by cell

7The AI plane

The assistants are the reason a customer's security team is in the room, so this layer is designed to be inspected. Everything a model saw and everything it was not allowed to see is recorded on the request path.

Ask
ContextTenant, user, groups, assistant, locale
RedactPersonal data masked before any model call
Permission filterMirrored source ACLs applied before ranking, never after
Retrieve and re-rankVector plus keyword, recency and version preference
Answer
Grounding gateBelow threshold: no approved source, log the gap, route to the owner
Model gatewayRegion-pinned; platform keys or the tenant's own; zero retention; failover to a second provider in the same region; per-tenant token accounting
Verify citationsEvery claim resolves to a chunk the asker may open
RecordPrompt, sources, exclusions, model, region, cost — to the tenant's audit trail
ConcernMechanism
Bring your own modelThe gateway is the only component that knows a provider exists. Azure OpenAI, Bedrock, Vertex, or a customer-hosted endpoint are configuration on the tenant, including their own keys held in the cell's secret store.
Quality does not regress silentlyA golden-question set per assistant, seeded at onboarding and grown from real unanswered questions. Any prompt, model or retrieval change runs against it in CI; a drop in grounded rate blocks the release.
Cost is attributableTokens are metered per tenant, per assistant, per feature. The same counter drives the customer's usage console, the quota, and our margin dashboard — one number, three audiences.
Embeddings stay homeEmbedding and re-ranking models run inside the cell, so document text never crosses a region boundary even during indexing.
Human oversightAnything that evaluates a person — scored practice, skills inference — is behind a human-in-the-loop switch that is on by default, with model cards and logs kept for the EU AI Act file.

8Identity and access

9Regional cells

A cell is a complete, independent copy of the platform in one region: compute, database, index, storage, queue. Cells do not talk to each other. This is what makes "your data stays in Frankfurt" a property of the topology rather than a promise in a policy document.

CellRegionDefault model endpointServes
EUFrankfurt, recovery in DublinAzure OpenAI EU WestEuropean customers, the strictest residency requirements
APACSingapore, recovery in SydneyAzure OpenAI or Bedrock, SingaporeSoutheast Asia, Japan, Australia
USVirginia, recovery in OregonAzure OpenAI East USNorth America
DedicatedCustomer's own accountCustomer's endpointSingle-tenant contracts

Moving a tenant between cells is a supported operation, not an incident: freeze writes, snapshot and ship the schema and objects, re-index in the destination, repoint the domain map, verify with the tenant's own golden questions, then release. Measured in hours, rehearsed before it is sold.

What the control plane may hold is deliberately dull: which tenants exist, which cell and tier they are on, which domains point at them, what they are entitled to, and how much they have used. If the control plane is unavailable, existing sessions keep working in each cell; only signup, domain changes and plan changes pause.

10Platform concerns

ConcernApproach
ObservabilityTenant and cell on every log line, span and metric. Dashboards answer "is this customer having a bad time" before they answer "is the fleet healthy", because that is the question support actually receives.
Quotas and rate limitsEnforced at the gateway, visible to the customer, with soft warnings before hard stops.
Background workOne scheduler, jobs scoped to a tenant, fair queueing, retries with backoff, a dead-letter queue that a human reads. Recurring compliance assignments and connector syncs are the two big producers.
Feature flagsPer tenant and per cell, so a feature reaches one design partner before a region, and a flag can be turned off without a deploy.
Cost attributionInfrastructure and model cost per tenant, tracked from the first month, because unit economics discovered late are unit economics discovered painfully.
Back officeProvisioning, tier changes, cell moves, impersonation, evidence-pack export. Boring internal tooling, built early, saves an engineer from being an operations department.

11Environments and delivery

12Non-functional targets

TargetValueNote
Platform availability99.9% monthlyPer cell, measured per tenant
Assistant availability99.5%Lower because it depends on model providers; failover to a second provider in-region
Time to first token≤ 1.5 s p95Budget: edge and auth 30 ms, permission filter and retrieval 200 ms, re-rank 80 ms, model 900 ms, overhead the rest
Page performanceLCP ≤ 2.0 sMid-range laptop, cold cache
Scale per cell200 tenants · 200k users10k concurrent learners, 100 questions per second burst
Scale per tenant50k users · 1M documentsAbove this, dedicated tier
Index freshness≤ 15 minSource change to citable answer
RecoveryRPO 15 min · RTO 4 hPer cell, rehearsed quarterly

13Roadmap

PhaseBuildDone when
0 · FoundationsTenant registry and domain map, token shape, row-level security with the isolation test suite, one cell in the EU, the modular monolith skeleton with two real modulesTwo tenants coexist on two domains with a passing isolation suite
1 · Product on the platformLearning core, compliance engine, four connectors with ACL mirroring, the assistant and the model gateway, audit trail, back officeA design partner runs real training and real assistants in production
2 · Scale outAPAC and US cells, custom domains with automatic certificates, ingestion extracted to its own service, skills and analytics, warehouse exportCustomers in three regions; a cell move rehearsed end to end
3 · EnterpriseDedicated tier and private-cloud deployment, customer-managed keys, 21 CFR Part 11, extended enterprise portals, MCP serverFirst single-tenant contract in production

14Decisions and rejected options

DecisionChosenRejected, and why
Service topologyModular monolith plus workers, with named extraction triggersMicroservices from day one — multiplies operational surface before there is traffic to justify it, and freezes boundaries we have not learned yet
Tenant isolationShared tables with row-level security, tiers for stricter needsA database per tenant — operationally heavy at a hundred customers. A single shared table with an application-level filter — one forgotten predicate is a breach
RegionsIndependent cells, no cross-cell trafficOne global database with regional replicas — makes residency a policy question instead of a physical fact
DomainsOne build, hostname resolves to tenant, certificates issued automaticallyA deployment per domain — every domain becomes something to patch and something to forget
Model accessA gateway owning every provider callProviders called from feature code — makes bring-your-own-model, failover, metering and zero retention impossible to guarantee
SearchVector plus keyword with permissions applied before rankingVector-only, or filtering after ranking — the second is how permission-aware systems leak
EventsOutbox in the writing transaction, relayed to the busPublishing directly from application code — loses events precisely when something is already wrong

15Risks and open questions

RiskMitigation
Connector maintenance is a treadmill — every source system changes its API and its permission modelFour connectors at launch, each with its own permission test suite; evaluate a unified connector vendor against building in-house before the fifth
Model cost per question erodes the included allowanceMetering from day one, small models for simple questions, answer caching, provider volume pricing, per-tenant cost visible internally from the first month
The isolation suite gives false confidenceAdd a case with every new table, and commission an external penetration test focused on cross-tenant access before the first enterprise contract
Premature extraction turns into a distributed monolithThe trigger table in section 5 is the gate; an extraction proposal without a named pressure is declined
Region moves are sold before they are rehearsedRehearse on the demo tenant every quarter and record the elapsed time

Open questions for the team

  1. Cloud and runtime. Which provider per cell, and containers on Kubernetes versus a managed serverless platform. This decides the operations budget more than any other choice here.
  2. Vector store. Postgres with pgvector, which keeps one database to operate, or a dedicated engine for scale. The first is likely right until a tenant passes roughly a million chunks.
  3. Connectors: build or buy. Buying is faster to five sources and slower to fix when a customer's permission edge case breaks.
  4. Billing. A billing vendor versus our own metering plus invoices, given that active-user pricing is unusual enough to fight most billing products.
  5. Reuse from the existing FLYER platform. Which of the assignment engine, question bank and grading services can be lifted, and whether their data model survives the tenant dimension.
  6. Team shape. How many engineers, because the honest answer to "microservices or not" is mostly a function of that number.

Companion documents: the product PRD for scope, positioning and pricing, and the working prototype for the interface this architecture has to serve.