Skip to main content

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 (trialactivesuspendedclosed)
  • 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

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

StateMeaning
pendingSubscription created, awaiting first payment confirmation
activePayment confirmed, workspace has full access
past_duePayment failed on renewal — grace period before suspension
cancelledSubscription 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:

  1. 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.

  2. Invoice created before payment. The Invoice starts in draft status. After Stripe confirms, it transitions to paid. If payment fails, the Invoice stays in draft — visible in the billing history as an attempted charge.

  3. Workspace activation inside transaction. The workspace transitions from trial (or suspended) to active atomically with the subscription creation. No partial state is possible.

Step 5: Check Risk Level

Risk Analysis

SignalStatusLevelRationale
Transaction boundary presentPassGreenAll write nodes inside txBoundary
No raw SQL writesPassGreenAll writes use Entity abstraction
Payment call outside transactionPassGreencapturePayment correctly placed outside tx boundary
Compensation flow presentPassMitigatedRefund flow handles post-payment failures
Email with retryPassGreen3 attempts with exponential backoff
Payment external callWarningYellowPayment processing inherently carries risk
IdempotencyWarningYellowNo 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:

  1. Multi-entity relationships — Workspace → Subscription → Invoice, connected through references with clear ownership
  2. Status machines across entities — Each entity has its own lifecycle, and transitions between entities are coordinated (workspace activation happens with subscription creation)
  3. Payment flow with compensation — Stripe integration with automatic refund if later steps fail
  4. Transform nodes — Using the Value DSL to compute values (plan → price lookup) without custom code
  5. Risk-aware design — Understanding why payment patterns trigger Yellow risk and how compensation mitigates them

What's Next