Episode 43 builds a multi-tenant SaaS application: per-tenant vs shared database architecture, tenant context propagation, organization management with invitations and roles, billing with Stripe subscriptions, and enterprise features like SSO and audit logs.

The last project in the real-world phase: a multi-tenant SaaS application — one app serving many customer organizations with secure data isolation and billing policies. This is the architecture pattern nearly every modern SaaS company uses. Episode 43 builds it from the multi-tenancy foundation to enterprise features: isolation architecture, tenant context propagation, organization management, billing, and features like SSO and audit logs.
model Project {
id Int @id @default(autoincrement())
tenantId Int
name String
@@index([tenantId])
}For this series, we'll use a shared database with tenantId on every table — the most common SaaS pattern.
tenantId filter.Query: {
projects: async (_, __, ctx) => {
return ctx.prisma.project.findMany({
where: { tenantId: ctx.tenant.id },
});
},
},Most importantly: the tenant is determined from context (the authentication result), not from arguments sent by the client — if a client can choose its tenant, isolation collapses.
context: async ({ req }) => {
const user = await authenticate(req.headers.authorization);
if (user) {
const membership = await findMembership(user.id);
return { user, tenant: membership.tenant, role: membership.role };
}
return { user: null, tenant: null, role: null };
},Every resolver reads ctx.tenant and ctx.role, for example ctx.tenant.id. This propagates the tenant automatically across all queries and mutations without repeated code.
An organization (tenant) has members with roles — this uses the RBAC pattern from episode 14:
type Organization {
id: ID!
name: String!
members: MemberConnection!
plans: [Plan!]!
}
type Member {
user: User!
role: OrgRole!
}
enum OrgRole {
OWNER
ADMIN
MEMBER
VIEWER
}Member invitations use the pattern: an invite is created with email + token, the prospective member receives a link, and claims membership at signup or join. Privileges are limited: only OWNER and ADMIN can invite and change roles — checked with ctx.role.
Billing uses Stripe subscriptions, not one-time checkout; install with npm install stripe:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createSubscription(_, args, ctx) {
const customer = await stripe.customers.create({
email: ctx.user.email,
metadata: { tenantId: String(ctx.tenant.id) },
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: args.input.priceId }],
payment_settings: { save_default_payment_method: "on_subscription" },
});
return { subscriptionId: subscription.id };
}For usage-based billing, send usage metrics to Stripe periodically; plan management compares plan.quota with current usage and shows an upgrade prompt when limits are exceeded. Invoice generation is handled by Stripe; override the template when custom branding is needed.
type AuditLog {
id: ID!
actor: User!
action: String!
resource: String!
tenantId: ID!
createdAt: DateTime!
}Write audit logs from resolvers that handle sensitive actions (role updates, data deletion, billing changes).
const quota = await ctx.redis.get(`quota:${ctx.tenant.id}`);
if (Number(quota) >= ctx.tenant.plan.limit) {
throw new Error("Kuota bulanan habis");
}Key takeaways:
In the next episode, episode 44, you'll learn about API design patterns and best practices — naming conventions, structural consistency, documentation standards, performance and security best practices, and schema evolution strategies. You'll design production-ready APIs from scratch!