Build a notes SaaS (end to end)
This tutorial builds a complete multi-tenant SaaS from an empty folder to a production-shaped app: tenants with a plan-based note quota (basic = 5, pro = 10), authentication, an operator admin dashboard, and a swap to Prisma (database) and Redis (cache, queues, quota) for production.
By the end you'll have exactly the reference notes demo — API, tests, and a two-area web app (admin console + tenant workspace).
What we're building
Operator ──(admin key)──▶ Admin console: metrics + create tenants
Tenant ──(JWT + slug)─▶ Workspace: create/list/delete notes (quota-limited)- Tenancy — each tenant is isolated (
x-tenant-idheader or subdomain). - Quota as a consumable feature — a plan's
noteslimit is spent one note at a time, enforced atomically by the subscriptions usage store. - Admin dashboard — billing metrics + per-tenant usage via
@machize/dashboard. - Production swaps — in-memory → Prisma + Redis, no domain changes.
1. Scaffold
npm create machize notes -- --billing --cli
cd notes && pnpm install--billing adds @machize/subscriptions; --cli adds the mach generators. Add the two extra packages this demo uses:
pnpm add @machize/dashboard @machize/permissions2. Environment
Secrets are fail-closed in production — secret() requires a real value there and rejects placeholders, while keeping a dev default for local runs.
// src/env.ts
import { defineEnv, secret } from '@machize/env'
import { z } from 'zod'
export const env = defineEnv({
PORT: z.coerce.number().default(3000),
HOST: z.string().default('0.0.0.0'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
APP_SECRET: secret({ devDefault: 'dev-only-insecure-secret-value' }),
ADMIN_KEY: secret({ devDefault: 'dev-only-admin-key-value' }),
// Used later, for production:
DATABASE_URL: z.string().optional(),
REDIS_URL: z.string().optional(),
})3. Plans and the note quota
notes is a consumable feature — creating a note consumes one, and the usage store enforces the limit atomically (so a race never overshoots).
// src/billing.ts
import { definePlans } from '@machize/subscriptions'
export const plans = definePlans({
basic: { price: 0, features: { notes: 5 } },
pro: { price: { monthly: 9, yearly: 90 }, trial: '14d', features: { notes: 10 } },
})
export type PlanName = 'basic' | 'pro'4. Roles
// src/access.ts
import type { AccessStore } from '@machize/permissions'
export const ROLES = {
owner: ['*'],
member: ['notes:read', 'notes:create', 'notes:delete'],
} as const
export type Role = keyof typeof ROLES
export async function seedRoles(store: AccessStore, tenantId: string): Promise<void> {
for (const [role, permissions] of Object.entries(ROLES)) {
await store.grantToRole(role, [...permissions], tenantId)
}
}5. The domain
// src/domain.ts
import { createToken } from '@machize/core'
import { defineEvent } from '@machize/events'
import { z } from 'zod'
export const NoteSchema = z.object({
id: z.string(),
title: z.string(),
body: z.string(),
authorId: z.string(),
createdAt: z.string(),
})
export type Note = z.infer<typeof NoteSchema>
export const CreateNoteSchema = z.object({ title: z.string().min(1).max(120), body: z.string().max(10_000).default('') })
export type CreateNoteInput = z.infer<typeof CreateNoteSchema>
export const NoteCreated = defineEvent('note.created', NoteSchema)
export const NoteDeleted = defineEvent('note.deleted', NoteSchema)
export const NOTE_SERVICE = createToken<import('./services.js').NoteService>('notes:service')
export const ONBOARDING = createToken<import('./services.js').OnboardingService>('notes:onboarding')
export const ADMIN_SERVICE = createToken<import('./services.js').AdminService>('notes:admin')6. A tenant-scoped repository
The repository reads the tenant from the request context and partitions data by it — the same isolation a real database gives you. (We swap this for Prisma in step 12.)
// src/repositories.ts
import { randomUUID } from 'node:crypto'
import { tryCtx } from '@machize/core'
import type { CreateNoteInput, Note } from './domain.js'
export class NoteRepository {
private readonly partitions = new Map<string, Map<string, Note>>()
private store(): Map<string, Note> {
const tenant = (tryCtx()?.['tenant'] as { id?: string } | undefined)?.id ?? 'central'
let p = this.partitions.get(tenant)
if (!p) this.partitions.set(tenant, (p = new Map()))
return p
}
all() { return [...this.store().values()] }
find(id: string) { return this.store().get(id) }
create(input: CreateNoteInput, authorId: string): Note {
const note: Note = { id: randomUUID(), ...input, authorId, createdAt: new Date().toISOString() }
this.store().set(note.id, note)
return note
}
delete(id: string) { return this.store().delete(id) }
}7. Services
The heart of the app. NoteService.create consumes the quota; OnboardingService provisions a tenant; AdminService builds the dashboard.
// src/services.ts
import { ctx } from '@machize/core'
import type { EventBus } from '@machize/events'
import { HttpError } from '@machize/fastify'
import type { Auth } from '@machize/auth'
import type { AccessStore } from '@machize/permissions'
import type { Subscriptions, SubscriptionStore, Plans } from '@machize/subscriptions'
import type { MemoryTenantSource } from '@machize/tenancy'
import { computeBillingMetrics } from '@machize/dashboard'
import { seedRoles, type Role } from './access.js'
import { NoteCreated, type CreateNoteInput, type Note } from './domain.js'
import type { NoteRepository } from './repositories.js'
import type { PlanName } from './billing.js'
const tenantId = (): string => {
const id = (ctx()['tenant'] as { id?: string } | undefined)?.id
if (!id) throw new HttpError(400, 'TENANT_REQUIRED', 'Missing tenant (x-tenant-id header).')
return id
}
const currentUserId = (): string => (ctx()['user'] as { id: string }).id
export class NoteService {
constructor(
private readonly repo: NoteRepository,
private readonly bus: EventBus,
private readonly subscriptions: Subscriptions,
) {}
list() { return this.repo.all() }
async quota() {
const f = this.subscriptions.features(tenantId())
return { used: await f.usage('notes'), quota: await f.limit('notes'), remaining: await f.remaining('notes') }
}
/** Consumes one from the plan quota; throws QuotaExceededError (402) when spent. */
async create(input: CreateNoteInput): Promise<Note> {
await this.subscriptions.features(tenantId()).consume('notes')
const note = this.repo.create(input, currentUserId())
await this.bus.emit(NoteCreated, note)
return note
}
}
export interface SignupInput { tenant: string; tenantName: string; name: string; email: string; password: string; plan: PlanName }
export class OnboardingService {
constructor(
private readonly tenants: MemoryTenantSource,
private readonly access: AccessStore,
private readonly auth: Auth,
private readonly subscriptions: Subscriptions,
) {}
async signup(input: SignupInput) {
const id = input.tenant.toLowerCase()
if (await this.tenants.find(id)) throw new HttpError(409, 'TENANT_TAKEN', `"${id}" already exists.`)
await this.tenants.add({ id, name: input.tenantName })
await seedRoles(this.access, id)
const owner = await this.auth.register(input.email, input.password)
await this.access.assignRole(owner.id, 'owner' satisfies Role, id)
await this.subscriptions.subscribe(id, input.plan)
const { tokens } = await this.auth.login(input.email, input.password)
return { tenant: { id, name: input.tenantName }, plan: input.plan, owner, ...tokens }
}
}
export class AdminService {
constructor(
private readonly tenants: MemoryTenantSource,
private readonly subscriptions: Subscriptions,
private readonly subStore: SubscriptionStore,
private readonly plans: Plans,
) {}
async dashboard() {
const tenants = await this.tenants.list()
const rows = await Promise.all(tenants.map(async (t) => {
const f = this.subscriptions.features(t.id)
const record = await this.subStore.get(t.id)
return {
id: t.id, name: String(t['name'] ?? t.id), plan: record?.plan ?? null,
notesUsed: await f.usage('notes'), notesQuota: await f.limit('notes'), notesRemaining: await f.remaining('notes'),
}
}))
const metrics = computeBillingMetrics(await this.subStore.all(), this.plans)
return { metrics, tenants: rows, totals: { tenants: rows.length, notesUsed: rows.reduce((s, r) => s + r.notesUsed, 0) } }
}
}8. Wire the domain plugin
// src/plugin.ts
import { definePlugin } from '@machize/core'
import { EVENTS } from '@machize/events'
import { AUTH } from '@machize/auth'
import type { AccessStore } from '@machize/permissions'
import { SUBSCRIPTIONS, type Plans, type SubscriptionStore } from '@machize/subscriptions'
import type { MemoryTenantSource } from '@machize/tenancy'
import { ADMIN_SERVICE, NOTE_SERVICE, ONBOARDING } from './domain.js'
import { NoteRepository } from './repositories.js'
import { AdminService, NoteService, OnboardingService } from './services.js'
export interface NotesPluginDeps { tenants: MemoryTenantSource; access: AccessStore; subStore: SubscriptionStore; plans: Plans }
export function notesPlugin(deps: NotesPluginDeps) {
const notes = new NoteRepository()
return definePlugin({
name: 'app:notes',
dependsOn: ['machize:events', 'machize:auth', 'machize:permissions', 'machize:subscriptions'],
register({ container }) {
container.singleton(NOTE_SERVICE, (c) => new NoteService(notes, c.get(EVENTS), c.get(SUBSCRIPTIONS)))
container.singleton(ONBOARDING, (c) => new OnboardingService(deps.tenants, deps.access, c.get(AUTH), c.get(SUBSCRIPTIONS)))
container.singleton(ADMIN_SERVICE, (c) => new AdminService(deps.tenants, c.get(SUBSCRIPTIONS), deps.subStore, deps.plans))
},
})
}9. Routes
Route meta drives the guards: auth: true requires a login; can requires a permission. The admin routes check the operator key.
// src/routes.ts
import { ctx, type Container, type Token } from '@machize/core'
import { HttpError, route, type HttpRequest } from '@machize/fastify'
import { z } from 'zod'
import { env } from './env.js'
import { ADMIN_SERVICE, CreateNoteSchema, NOTE_SERVICE, ONBOARDING } from './domain.js'
const service = <T>(token: Token<T>): T => (ctx().container as Container).get(token)
function requireAdmin(request: HttpRequest): void {
if (request.headers['x-admin-key'] !== env.ADMIN_KEY) throw new HttpError(401, 'ADMIN_UNAUTHORIZED', 'A valid x-admin-key is required.')
}
export const appRoutes = [
route({ method: 'POST', url: '/signup',
body: z.object({ tenant: z.string().min(2), tenantName: z.string().min(2), name: z.string().min(2), email: z.string().email(), password: z.string().min(8), plan: z.enum(['basic', 'pro']).default('basic') }),
async handler({ body, reply }) { return reply.code(201).send(await service(ONBOARDING).signup(body)) } }),
route({ method: 'GET', url: '/notes', meta: { auth: true, can: 'notes:read' }, async handler() { return service(NOTE_SERVICE).list() } }),
route({ method: 'GET', url: '/notes/quota', meta: { auth: true, can: 'notes:read' }, async handler() { return service(NOTE_SERVICE).quota() } }),
route({ method: 'POST', url: '/notes', meta: { auth: true, can: 'notes:create' }, body: CreateNoteSchema,
async handler({ body, reply }) { return reply.code(201).send(await service(NOTE_SERVICE).create(body)) } }),
route({ method: 'DELETE', url: '/notes/:id', meta: { auth: true, can: 'notes:delete' }, params: z.object({ id: z.string() }),
async handler({ params, reply }) { /* … delete … */ return reply.code(204).send() } }),
// SaaS operator
route({ method: 'GET', url: '/admin/dashboard', async handler({ request }) { requireAdmin(request); return service(ADMIN_SERVICE).dashboard() } }),
route({ method: 'POST', url: '/admin/tenants',
body: z.object({ tenant: z.string().min(2), tenantName: z.string().min(2), ownerName: z.string().min(2), ownerEmail: z.string().email(), ownerPassword: z.string().min(8), plan: z.enum(['basic', 'pro']).default('basic') }),
async handler({ body, request, reply }) {
requireAdmin(request)
const r = await service(ONBOARDING).signup({ tenant: body.tenant, tenantName: body.tenantName, name: body.ownerName, email: body.ownerEmail, password: body.ownerPassword, plan: body.plan })
return reply.code(201).send({ tenant: r.tenant, plan: r.plan, owner: r.owner })
} }),
]10. Assemble the app
// src/app.ts
import { createApp } from '@machize/core'
import { configPlugin } from '@machize/config'
import { eventsPlugin } from '@machize/events'
import { fastifyPlugin } from '@machize/fastify'
import { loggerPlugin } from '@machize/logger'
import { headerResolver, MemoryTenantSource, subdomainResolver, tenancyPlugin } from '@machize/tenancy'
import { authPlugin, authRoutes, MemoryUserSource } from '@machize/auth'
import { MemoryAccessStore, permissionsPlugin } from '@machize/permissions'
import { MemorySubscriptionStore, MemoryUsageStore, subscriptionsPlugin } from '@machize/subscriptions'
import { env } from './env.js'
import { plans } from './billing.js'
import { notesPlugin } from './plugin.js'
import { appRoutes } from './routes.js'
export function buildApp(options: { logLevel?: string } = {}) {
const tenants = new MemoryTenantSource()
const subStore = new MemorySubscriptionStore()
return createApp({
plugins: [
configPlugin({ app: { name: 'notes' } }),
loggerPlugin({ level: options.logLevel ?? 'info' }),
eventsPlugin(),
tenancyPlugin({ source: tenants, resolvers: [headerResolver(), subdomainResolver({ base: 'localhost' })] }),
authPlugin({ users: new MemoryUserSource(), secret: env.APP_SECRET }),
permissionsPlugin({ store: new MemoryAccessStore() }),
subscriptionsPlugin({ plans, fallbackPlan: 'basic', store: subStore, usage: new MemoryUsageStore() }),
notesPlugin({ tenants, access: /* the same store */ new MemoryAccessStore(), subStore, plans }),
fastifyPlugin({ routes: [...appRoutes, ...authRoutes()] }),
],
})
}One store, two readers
Create the MemoryAccessStore / MemorySubscriptionStore once and pass the same instance to both the plugin and your services, so the admin dashboard reads the same data the app writes.
11. Run and try it
pnpm dev# operator provisions a tenant on the basic (5-note) plan
curl -s localhost:3000/admin/tenants -H "x-admin-key: dev-only-admin-key-value" \
-H content-type:application/json \
-d '{"tenant":"acme","tenantName":"Acme","ownerName":"Ada","ownerEmail":"ada@acme.test","ownerPassword":"password123","plan":"basic"}'
# the owner signs in and consumes notes — the 6th returns 402
TOKEN=$(curl -s localhost:3000/auth/login -H content-type:application/json \
-d '{"email":"ada@acme.test","password":"password123"}' | jq -r .accessToken)
for i in $(seq 1 6); do
curl -s -o /dev/null -w "note $i → %{http_code}\n" localhost:3000/notes \
-H "x-tenant-id: acme" -H "authorization: Bearer $TOKEN" \
-H content-type:application/json -d "{\"title\":\"Note $i\"}"
done
# → note 5 → 201, note 6 → 40212. Going to production: Prisma
Swap the in-memory NoteRepository for a database. Nothing else changes — the tenant is still read from the request context.
a. Add Prisma and a Note model with a tenantId column:
// prisma/schema.prisma
model Note {
id String @id @default(cuid())
tenantId String
title String
body String @default("")
authorId String
createdAt DateTime @default(now())
@@index([tenantId])
}b. Configure prismaPlugin with the tenancy extension — every query is auto-scoped to ctx().tenant, reads and writes:
import { PrismaClient } from '@prisma/client'
import { prismaPlugin, tenancyExtension } from '@machize/prisma'
const prisma = new PrismaClient().$extends(tenancyExtension()) // scopes by tenantId
// in buildApp plugins, before notesPlugin:
prismaPlugin({ client: prisma })c. Rewrite the repository with db() — no manual where: { tenantId }, the extension adds it:
import { db } from '@machize/prisma'
import type { PrismaClient } from '@prisma/client'
export class PrismaNoteRepository {
private get notes() { return db<PrismaClient>().note }
list() { return this.notes.findMany() }
find(id: string) { return this.notes.findUnique({ where: { id } }) }
create(input: CreateNoteInput, authorId: string) { return this.notes.create({ data: { ...input, authorId } }) }
delete(id: string) { return this.notes.delete({ where: { id } }).then(() => true).catch(() => false) }
}Generate this whole vertical automatically with the mach CLI:
pnpm mach make:resource Note --prismaDatabase per tenant
For hard isolation, give each tenant its own database instead of a shared one with a tenantId column — the domain code is identical:
prismaPlugin({
forTenant: (id) => new PrismaClient({ datasourceUrl: `${env.DATABASE_URL}/${id}` }),
})An LRU pool keeps connection counts bounded. Run migrations across every tenant:
import { migrateTenants } from '@machize/prisma'
await migrateTenants({ tenants: await listTenantIds(), target: { mode: 'database', urlFor: (id) => `${env.DATABASE_URL}/${id}` } })See Going to Production → Persistence for the three tenancy strategies (shared / schema-per-tenant / database-per-tenant).
13. Going to production: Redis
Three places benefit from Redis. All are one-line swaps.
a. The note quota — back the subscriptions usage store with Redis so the atomic check-and-increment works across instances:
import { Redis } from 'ioredis'
import { RedisUsageStore } from '@machize/subscriptions'
const redis = new Redis(env.REDIS_URL!)
subscriptionsPlugin({ plans, fallbackPlan: 'basic', store: subStore, usage: new RedisUsageStore(redis) })b. Cache — memoize reads (e.g. the admin dashboard) with a Redis cache:
import { cachePlugin, CACHE } from '@machize/cache'
cachePlugin({ driver: 'redis', url: env.REDIS_URL })
// in a service:
const dashboard = await cache.remember('admin:dashboard', '30s', () => this.build())c. Background jobs — send a welcome email off note.created without blocking the request, via a BullMQ (Redis) queue:
import { queuePlugin, defineJob, QUEUE } from '@machize/queue'
const SendWelcome = defineJob('welcome', z.object({ email: z.string() }), async ({ email }) => {
await mailer.send({ to: email, subject: 'Welcome!' })
})
queuePlugin({ connection: env.REDIS_URL, jobs: [SendWelcome], workers: [{ queue: 'welcome', concurrency: 5 }] })
// dispatch from a domain-event listener (in your plugin's boot):
bus.on('note.created', () => void container.get(QUEUE).dispatch(SendWelcome, { email }))Without a connection, the queue runs synchronously — so tests and local dev need no Redis at all.
14. Harden the edge
The framework-neutral edge plugins add production concerns without touching your routes (Security, Observability):
import { securityPlugin, healthPlugin, metricsPlugin, tracingPlugin } from '@machize/fastify'
securityPlugin({ rateLimit: { limit: 300, windowMs: 60_000 }, cors: { origin: env.WEB_ORIGIN }, headers: true }),
healthPlugin({ checks: { db: () => ({ ok: prisma != null }), redis: async () => ({ ok: (await redis.ping()) === 'PONG' }) } }),
metricsPlugin(), // Prometheus /metrics
tracingPlugin(), // W3C trace-context + OTLPAuth already throttles brute-force logins, and POST can be made idempotent with idempotencyPlugin().
15. The web app
A React + shadcn/ui frontend (type-safe via @machize/sdk) gives the operator a dashboard + create-tenant console and each tenant a workspace to consume their notes — see the web/ folder of the reference notes app. It's the same routes you built here, consumed through a generated client; the Vite dev server proxies /api to the backend (no CORS). See HTTP Adapters for serving on Express or Hono instead of Fastify.
16. Test it end to end
// tests/notes.e2e.test.ts
import { FASTIFY } from '@machize/fastify'
import { buildApp } from '../src/app.js'
const app = await buildApp({ logLevel: 'silent' }).boot()
const server = app.container.get(FASTIFY)
// basic plan allows exactly 5 notes, then 402
for (let i = 1; i <= 5; i++) expect((await createNote('acme', `n${i}`)).statusCode).toBe(201)
expect((await createNote('acme', 'overflow')).statusCode).toBe(402)Everything runs on in-memory stores, so the whole suite passes with no database or Redis — swap them in only for production. That's the point: the domain code you wrote in steps 3–9 never changes.