Tutorial: Build a SaaS Backend with Fascia
This tutorial walks you through building a SaaS subscription management system — the backend behind a multi-tenant product with workspaces, subscription plans, and invoicing. You will define Entities with relationships, design a subscription upgrade Tool with payment processing, and analyze its risk level.
By the end, you will understand how Fascia handles multi-entity relationships, payment flows with compensation, and status-driven business logic.
What You'll Build
A SaaS backend consisting of:
- Workspace Entity with a status machine (
trial→active→suspended→closed) - Subscription Entity tracking plan changes and billing cycles
- Invoice Entity for payment records
- upgradeSubscription Tool with Stripe integration and compensation flow
- Risk analysis demonstrating how payment patterns affect risk classification
Prerequisites
- A Fascia account — sign up at fascia.run
- A valid JWT token for your workspace
- Basic understanding of Entity and Tool concepts
- Familiarity with the Booking System tutorial is helpful but not required
Step 1: Define the Workspace Entity
The Workspace is the top-level container in a SaaS product — each customer gets their own workspace with isolated data.
{
"name": "Workspace",
"version": 1,
"description": "A tenant workspace representing a customer organization",
"fields": {
"name": { "type": "string", "required": true },
"slug": { "type": "string", "required": true },
"ownerId": { "type": "reference", "referenceTo": "User", "required": true },
"plan": { "type": "enum", "values": ["free", "starter", "pro", "enterprise"], "required": true, "default": "free" },
"maxSeats": { "type": "number", "required": true, "default": 1 },
"currentSeats": { "type": "number", "required": true, "default": 1 },
"trialEndsAt": { "type": "datetime", "required": false }
},
"relationships": {
"owner": { "type": "belongsTo", "target": "User", "foreignKey": "ownerId" },
"subscriptions": { "type": "hasMany", "target": "Subscription" },
"invoices": { "type": "hasMany", "target": "Invoice" }
},
"statusMachine": {
"states": ["trial", "active", "suspended", "closed"],
"initialState": "trial",
"transitions": [
{ "from": "trial", "to": "active" },
{ "from": "trial", "to": "closed" },
{ "from": "active", "to": "suspended" },
{ "from": "active", "to": "closed" },
{ "from": "suspended", "to": "active" },
{ "from": "suspended", "to": "closed" }
]
},
"invariants": [
{ "name": "seatsNotExceedMax", "expression": "workspace.currentSeats <= workspace.maxSeats", "message": "Current seats cannot exceed maximum seats" },
{ "name": "maxSeatsPositive", "expression": "workspace.maxSeats > 0", "message": "Maximum seats must be positive" }
],
"rowLevelAccess": true,
"ownerField": "ownerId"
}
Key Design Decisions
Enum for plan type. Using an enum field (free, starter, pro, enterprise) makes plan comparisons type-safe and prevents invalid plan names.
Trial as initial state. Every workspace starts in trial. The transition to active happens when the first subscription is created. This makes the trial-to-paid conversion explicit in the status machine.
Suspended state. When a payment fails, the workspace moves to suspended — not closed. This gives the customer time to fix their payment method. The suspended → active transition is available for recovery.
Step 2: Define the Subscription Entity
Subscriptions track billing cycles and plan changes for each workspace.
{
"name": "Subscription",
"version": 1,
"description": "A billing subscription for a workspace, linked to a payment provider",
"fields": {
"workspaceId": { "type": "reference", "referenceTo": "Workspace", "required": true },
"plan": { "type": "enum", "values": ["starter", "pro", "enterprise"], "required": true },
"priceMonthly": { "type": "number", "required": true },
"billingCycleStart": { "type": "datetime", "required": true },
"billingCycleEnd": { "type": "datetime", "required": true },
"stripeSubscriptionId": { "type": "string", "required": false },
"cancelledAt": { "type": "datetime", "required": false },
"cancellationReason": { "type": "string", "required": false }
},
"relationships": {
"workspace": { "type": "belongsTo", "target": "Workspace", "foreignKey": "workspaceId" }
},
"statusMachine": {
"states": ["pending", "active", "past_due", "cancelled"],
"initialState": "pending",
"transitions": [
{ "from": "pending", "to": "active" },
{ "from": "pending", "to": "cancelled" },
{ "from": "active", "to": "past_due" },
{ "from": "active", "to": "cancelled" },
{ "from": "past_due", "to": "active" },
{ "from": "past_due", "to": "cancelled" }
]
},
"invariants": [
{ "name": "pricePositive", "expression": "subscription.priceMonthly > 0", "message": "Monthly price must be positive" },
{ "name": "cycleEndAfterStart", "expression": "subscription.billingCycleEnd > subscription.billingCycleStart", "message": "Billing cycle end must be after start" }
]
}
Status Machine Explained
| State | Meaning |
|---|---|
pending | Subscription created, awaiting first payment confirmation |
active | Payment confirmed, workspace has full access |
past_due | Payment failed on renewal — grace period before suspension |
cancelled | Subscription ended (terminal state) |
The past_due → active transition allows recovery when the customer updates their payment method.
Step 3: Define the Invoice Entity
Invoices record each payment event for billing history and compliance.
{
"name": "Invoice",
"version": 1,
"description": "A billing invoice recording a payment event",
"fields": {
"workspaceId": { "type": "reference", "referenceTo": "Workspace", "required": true },
"subscriptionId": { "type": "reference", "referenceTo": "Subscription", "required": true },
"amount": { "type": "number", "required": true },
"currency": { "type": "string", "required": true, "default": "usd" },
"stripeInvoiceId": { "type": "string", "required": false },
"paidAt": { "type": "datetime", "required": false },
"failureReason": { "type": "string", "required": false }
},
"relationships": {
"workspace": { "type": "belongsTo", "target": "Workspace", "foreignKey": "workspaceId" },
"subscription": { "type": "belongsTo", "target": "Subscription", "foreignKey": "subscriptionId" }
},
"statusMachine": {
"states": ["draft", "paid", "failed", "refunded"],
"initialState": "draft",
"transitions": [
{ "from": "draft", "to": "paid" },
{ "from": "draft", "to": "failed" },
{ "from": "paid", "to": "refunded" },
{ "from": "failed", "to": "paid" }
]
},
"invariants": [
{ "name": "amountPositive", "expression": "invoice.amount > 0", "message": "Invoice amount must be positive" }
]
}
Step 4: Design the upgradeSubscription Tool
Now for the core business logic — upgrading a workspace's subscription plan. This Tool handles the complete flow: validate the upgrade, process payment via Stripe, update the subscription, and activate the workspace.
{
"name": "upgradeSubscription",
"version": 1,
"description": "Upgrade a workspace subscription to a higher plan",
"trigger": { "type": "http", "method": "POST", "path": "/workspaces/:workspaceId/upgrade" },
"input": {
"type": "object",
"required": ["workspaceId", "targetPlan"],
"properties": {
"workspaceId": { "type": "string", "format": "uuid" },
"targetPlan": { "type": "string", "enum": ["starter", "pro", "enterprise"] }
}
},
"flow": {
"startNode": "readWorkspace",
"nodes": {
"readWorkspace": {
"type": "read",
"config": { "entity": "Workspace", "filter": { "id": "input.workspaceId" }, "single": true }
},
"checkEligible": {
"type": "assert",
"config": {
"expression": "workspace.status != 'closed'",
"message": "Cannot upgrade a closed workspace"
}
},
"lookupPrice": {
"type": "transform",
"config": {
"output": "planPrice",
"expression": "if(input.targetPlan == 'starter', 1900, if(input.targetPlan == 'pro', 4900, 0))"
}
},
"txBoundary": {
"type": "transaction",
"config": {
"contains": ["createSubscription", "createInvoice", "updateWorkspace", "activateWorkspace"]
}
},
"createSubscription": {
"type": "write",
"config": {
"entity": "Subscription",
"operation": "create",
"fields": {
"workspaceId": "workspace.id",
"plan": "input.targetPlan",
"priceMonthly": "planPrice",
"billingCycleStart": "now()",
"billingCycleEnd": "addDays(now(), 30)"
}
}
},
"createInvoice": {
"type": "write",
"config": {
"entity": "Invoice",
"operation": "create",
"fields": {
"workspaceId": "workspace.id",
"subscriptionId": "subscription.id",
"amount": "planPrice",
"currency": "'usd'"
}
}
},
"updateWorkspace": {
"type": "write",
"config": {
"entity": "Workspace",
"operation": "update",
"target": "workspace",
"fields": { "plan": "input.targetPlan" }
}
},
"activateWorkspace": {
"type": "write",
"config": {
"entity": "Workspace",
"operation": "transition",
"target": "workspace",
"toStatus": "active"
}
},
"capturePayment": {
"type": "payment",
"config": {
"provider": "stripe",
"action": "capture",
"amount": "planPrice",
"currency": "usd"
}
},
"markInvoicePaid": {
"type": "write",
"config": {
"entity": "Invoice",
"operation": "transition",
"target": "invoice",
"toStatus": "paid"
}
},
"sendConfirmation": {
"type": "email",
"config": {
"to": "workspace.owner.email",
"template": "subscription_upgraded"
}
},
"retryEmail": {
"type": "retry",
"config": { "target": "sendConfirmation", "maxAttempts": 3, "backoff": "exponential" }
}
},
"edges": [
{ "from": "readWorkspace", "to": "checkEligible" },
{ "from": "checkEligible", "to": "lookupPrice" },
{ "from": "lookupPrice", "to": "txBoundary" },
{ "from": "txBoundary", "to": "capturePayment" },
{ "from": "capturePayment", "to": "markInvoicePaid" },
{ "from": "markInvoicePaid", "to": "sendConfirmation" }
]
},
"compensation": {
"trigger": "capturePayment",
"flow": {
"nodes": {
"refundPayment": { "type": "payment", "config": { "provider": "stripe", "action": "refund" } }
}
}
}
}
Flow Walkthrough
readWorkspace
|
v
checkEligible (assert: status != 'closed')
|
v
lookupPrice (transform: plan → price in cents)
|
v
txBoundary ────────────────────────────────────────┐
| | | |
v v v v
createSubscription createInvoice updateWorkspace activateWorkspace
└──────────────┴──────────────┴───────────────┘
|
v (transaction commits)
capturePayment (Stripe)
|
v
markInvoicePaid (transition invoice → paid)
|
v
sendConfirmation (email, with retry)
Why this order matters:
-
Transaction first, payment second. Database records are created in a transaction. If the Stripe charge succeeds but a later step fails, the compensation flow triggers a refund.
-
Invoice created before payment. The Invoice starts in
draftstatus. After Stripe confirms, it transitions topaid. If payment fails, the Invoice stays indraft— visible in the billing history as an attempted charge. -
Workspace activation inside transaction. The workspace transitions from
trial(orsuspended) toactiveatomically with the subscription creation. No partial state is possible.
Step 5: Check Risk Level
Risk Analysis
| Signal | Status | Level | Rationale |
|---|---|---|---|
| Transaction boundary present | Pass | Green | All write nodes inside txBoundary |
| No raw SQL writes | Pass | Green | All writes use Entity abstraction |
| Payment call outside transaction | Pass | Green | capturePayment correctly placed outside tx boundary |
| Compensation flow present | Pass | Mitigated | Refund flow handles post-payment failures |
| Email with retry | Pass | Green | 3 attempts with exponential backoff |
| Payment external call | Warning | Yellow | Payment processing inherently carries risk |
| Idempotency | Warning | Yellow | No explicit idempotency key |
Overall Risk Level: Yellow
Deployable with acknowledgment. The Yellow signals come from the payment processing — any Tool that charges money requires explicit review before deployment.
Step 6: Register via API
Register the specs in order (Entities first, then Tools):
# 1. Register Workspace Entity
curl -X POST https://api.fascia.run/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @workspace-entity.json
# 2. Register Subscription Entity
curl -X POST https://api.fascia.run/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @subscription-entity.json
# 3. Register Invoice Entity
curl -X POST https://api.fascia.run/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @invoice-entity.json
# 4. Register upgradeSubscription Tool
curl -X POST https://api.fascia.run/api/v1/specs/tool \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @upgrade-subscription-tool.json
What You've Learned
In this tutorial, you built a SaaS subscription backend without writing any runtime code:
- Multi-entity relationships — Workspace → Subscription → Invoice, connected through references with clear ownership
- Status machines across entities — Each entity has its own lifecycle, and transitions between entities are coordinated (workspace activation happens with subscription creation)
- Payment flow with compensation — Stripe integration with automatic refund if later steps fail
- Transform nodes — Using the Value DSL to compute values (plan → price lookup) without custom code
- Risk-aware design — Understanding why payment patterns trigger Yellow risk and how compensation mitigates them
What's Next
- Export & Eject — Export your specs as OpenAPI, DDL, TypeScript, or a standalone project
- MCP Integration — Connect AI assistants to your workspace for frontend development
- Managing Your Backend — Monitor, browse data, and manage users after deployment
- Risk Rules Reference — Detailed breakdown of all risk signals