Tutorial: Build a Booking System with Fascia
This tutorial walks you through building a complete reservation booking system using Fascia's spec-driven approach. You will define an Entity, design a Tool with a payment flow, analyze its risk level, and register both specs via the API.
By the end, you will understand how Fascia transforms structured specifications into a fully functional, production-safe backend — without writing any runtime code.
What You'll Build
A reservation booking system consisting of:
- Reservation Entity with a status machine (
pending→confirmed→checked_in→completed) - Payment processing Tool with a compensation flow for automatic refunds
- Risk analysis demonstrating how the Safety Engine evaluates your design before deployment
Prerequisites
- Access to a running Fascia platform instance (API server on
localhost:3001) - A valid JWT token for your workspace
- Basic understanding of Entity and Tool concepts
Step 1: Define the Reservation Entity
An Entity is the core business object in Fascia. It encapsulates fields, relationships, a status machine, and invariants — everything the platform needs to auto-generate the database schema and enforce business rules at runtime.
Create a file called reservation-entity.json with the following spec:
{
"name": "Reservation",
"version": 1,
"description": "A booking made by a customer for a specific time slot and resource",
"fields": {
"customerId": { "type": "reference", "referenceTo": "Customer", "required": true },
"resourceId": { "type": "reference", "referenceTo": "Resource", "required": true },
"startDate": { "type": "datetime", "required": true },
"endDate": { "type": "datetime", "required": true },
"totalPrice": { "type": "number", "required": true },
"depositAmount": { "type": "number", "required": false, "default": 0 },
"notes": { "type": "string", "required": false },
"cancellationReason": { "type": "string", "required": false }
},
"relationships": {
"customer": { "type": "belongsTo", "target": "Customer", "foreignKey": "customerId" },
"resource": { "type": "belongsTo", "target": "Resource", "foreignKey": "resourceId" },
"payments": { "type": "hasMany", "target": "Payment" }
},
"statusMachine": {
"states": ["pending", "confirmed", "checked_in", "completed", "cancelled", "no_show"],
"initialState": "pending",
"transitions": [
{ "from": "pending", "to": "confirmed" },
{ "from": "pending", "to": "cancelled" },
{ "from": "confirmed", "to": "checked_in" },
{ "from": "confirmed", "to": "cancelled", "guard": "now() < reservation.startDate" },
{ "from": "confirmed", "to": "no_show", "guard": "now() > addHours(reservation.startDate, 1)" },
{ "from": "checked_in", "to": "completed" }
]
},
"invariants": [
{ "name": "endDateAfterStart", "expression": "reservation.endDate > reservation.startDate", "message": "End date must be after start date" },
{ "name": "totalPricePositive", "expression": "reservation.totalPrice > 0", "message": "Total price must be positive" },
{ "name": "depositNotExceedTotal", "expression": "reservation.depositAmount <= reservation.totalPrice", "message": "Deposit cannot exceed total price" }
],
"rowLevelAccess": true,
"ownerField": "customerId"
}
Understanding the Status Machine
The status machine defines the lifecycle of a reservation. Each state represents a distinct phase, and transitions define the only allowed movements between them.
| State | Meaning |
|---|---|
pending | Reservation created but not yet paid. This is the initial state for every new reservation. |
confirmed | Payment received. The booking is guaranteed for the customer. |
checked_in | The customer has arrived and started using the resource. |
completed | The session is finished. This is a terminal state. |
cancelled | The reservation was cancelled before use. This is a terminal state. |
no_show | The customer did not arrive within the allowed window. This is a terminal state. |
Notice that transitions are explicit — there is no wildcard "any state to cancelled" rule. For example, a checked_in reservation cannot be cancelled; it can only move to completed. This prevents invalid state changes at the engine level.
Guard Expressions
Two transitions include guard conditions written in the Value DSL:
confirmed→cancelled: The guardnow() < reservation.startDateensures cancellation is only possible before the booking starts. Once the start time passes, this transition is blocked.confirmed→no_show: The guardnow() > addHours(reservation.startDate, 1)allows marking a no-show only after a 1-hour grace period from the start time.
Guards are pure expressions evaluated at runtime — they use built-in functions like now() and addHours() with no side effects.
Invariants
Invariants are business rules enforced at step 6 of the Execution Contract — after the flow executes but before the transaction commits. If any invariant fails, the entire transaction rolls back.
| Invariant | Expression | Purpose |
|---|---|---|
endDateAfterStart | reservation.endDate > reservation.startDate | Prevents logically impossible date ranges (e.g., ending before starting). |
totalPricePositive | reservation.totalPrice > 0 | Ensures no zero or negative-price reservations can exist. |
depositNotExceedTotal | reservation.depositAmount <= reservation.totalPrice | Prevents deposits larger than the total, which would indicate a data error. |
These invariants run on every write to the Reservation entity — creates, updates, and status transitions alike.
Row-Level Access
Setting rowLevelAccess: true with ownerField: "customerId" means that when a customer queries reservations, they automatically see only their own records. Admin and staff roles bypass this filter. This is enforced at step 2 (Authorize) of the Execution Contract.
System Fields
You do not need to define the following fields — Fascia adds them automatically to every Entity:
| Field | Type | Purpose |
|---|---|---|
id | uuid | Primary key, auto-generated |
createdAt | datetime | Record creation timestamp |
updatedAt | datetime | Last modification timestamp |
deletedAt | datetime (nullable) | Soft delete marker (Fascia never hard-deletes) |
version | integer | Optimistic locking counter |
Step 2: Design the Payment Tool
A Tool is an executable unit of server logic — equivalent to an API endpoint. It is defined entirely by a spec that includes how it is triggered, what data flows through it, and what safety policies apply.
Create a file called process-payment-tool.json:
{
"name": "processPayment",
"version": 1,
"description": "Process payment for a reservation",
"trigger": { "type": "http", "method": "POST", "path": "/reservations/:reservationId/pay" },
"input": {
"type": "object",
"required": ["reservationId", "paymentMethod"],
"properties": {
"reservationId": { "type": "string", "format": "uuid" },
"paymentMethod": { "type": "string", "enum": ["card", "bank_transfer"] },
"cardToken": { "type": "string" }
}
},
"flow": {
"startNode": "readReservation",
"nodes": {
"readReservation": { "type": "read", "config": { "entity": "Reservation", "filter": { "id": "input.reservationId" }, "single": true } },
"checkStatus": { "type": "assert", "config": { "expression": "reservation.status == 'pending'", "message": "Reservation must be in pending status" } },
"txBoundary": { "type": "transaction", "config": { "contains": ["createPayment", "confirmReservation"] } },
"createPayment": { "type": "write", "config": { "entity": "Payment", "operation": "create", "fields": { "reservationId": "reservation.id", "amount": "reservation.totalPrice", "method": "input.paymentMethod", "status": "pending" } } },
"capturePayment": { "type": "payment", "config": { "provider": "stripe", "action": "capture", "amount": "reservation.totalPrice", "token": "input.cardToken" } },
"confirmReservation": { "type": "write", "config": { "entity": "Reservation", "operation": "transition", "target": "reservation", "toStatus": "confirmed" } },
"sendConfirmation": { "type": "email", "config": { "to": "reservation.customer.email", "template": "reservation_confirmed" } },
"retryEmail": { "type": "retry", "config": { "target": "sendConfirmation", "maxAttempts": 3, "backoff": "exponential" } }
},
"edges": [
{ "from": "readReservation", "to": "checkStatus" },
{ "from": "checkStatus", "to": "txBoundary" },
{ "from": "txBoundary", "to": "capturePayment" },
{ "from": "capturePayment", "to": "sendConfirmation" }
]
},
"compensation": {
"trigger": "capturePayment",
"flow": {
"nodes": {
"refundPayment": { "type": "payment", "config": { "provider": "stripe", "action": "refund" } }
}
}
}
}
Key Design Decisions in This Flow
Transaction boundary placement. The txBoundary node wraps only the database writes (createPayment and confirmReservation). This is intentional — if the payment capture or email sending fails, the database writes roll back atomically.
External calls outside the transaction. The capturePayment (Stripe) and sendConfirmation (email) nodes are placed outside the transaction boundary. This follows a critical safety rule: external side effects cannot be rolled back by a database transaction. If Stripe captured a payment but the transaction then rolled back, you would have an inconsistent state — money charged but no record in the database. Fascia's Risk Engine flags this pattern as Red (blocked) if detected.
Compensation flow. The compensation block defines what happens if steps after capturePayment fail. If the email sending fails after payment capture, the compensation flow automatically triggers a Stripe refund. This is how Fascia handles the inherent risk of external payment calls.
Retry with exponential backoff. The email node has a retry wrapper (maxAttempts: 3, backoff: "exponential"). Transient failures (network timeouts, temporary SMTP issues) are handled automatically without manual intervention.
Step 3: Understand the Flow Graph
The flow forms a directed acyclic graph (DAG). Here is the execution sequence:
readReservation
|
v
checkStatus (assert: status == 'pending')
|
v
txBoundary ─────────────────────────┐
| |
v v
createPayment confirmReservation
└──────────┬─────────────────────┘
|
v (transaction commits)
capturePayment (Stripe)
|
v
sendConfirmation (email, with retry)
Execution Contract Walkthrough
When a client calls POST /reservations/:reservationId/pay, the Executor follows the 9-step Execution Contract:
| Step | Action | What happens in this Tool |
|---|---|---|
| 1. Validate input | Check input against the declared JSON Schema | reservationId must be a UUID, paymentMethod must be "card" or "bank_transfer" |
| 2. Authorize | Verify JWT, check RBAC role, apply row-level access | Caller must own the reservation (via customerId match) |
| 3. Policy check | Run registered policies | Row limit and rate limit policies are evaluated |
| 4. Start transaction | Open a database transaction | PostgreSQL BEGIN |
| 5. Execute flow graph | Run nodes in DAG order | Read reservation, assert status, create payment record, transition to confirmed, capture via Stripe, send email |
| 6. Enforce invariants | Check all Entity invariants | totalPricePositive, endDateAfterStart, depositNotExceedTotal |
| 7. Commit / rollback | Finalize the transaction | COMMIT on success, ROLLBACK on any failure |
| 8. Write audit log | Record what happened | Entity ID, operation, before/after state, user ID, timestamp |
| 9. Return output | Send the response | Normalized JSON with the updated reservation and payment |
If any step fails, execution stops and all database changes roll back. The compensation flow handles external side effects that cannot be rolled back by the database.
Step 4: Check Risk Level
Before a Tool can be deployed, the Risk Engine analyzes its flow graph and assigns a risk level. Here is how this Tool is evaluated:
Risk Analysis
| Signal | Status | Level | Rationale |
|---|---|---|---|
| Transaction boundary present | Pass | Green | All write nodes (createPayment, confirmReservation) are inside txBoundary. |
| No raw SQL writes | Pass | Green | All writes go through the Entity abstraction (write nodes), never raw SQL. |
| Payment call outside transaction | Pass | Green | capturePayment is correctly placed outside the transaction boundary. If it were inside, a rollback could not undo the Stripe charge. |
| Compensation flow present | Pass | Mitigated | The compensation block handles refunds if post-payment steps fail. Without this, payment-with-no-rollback would be a Red signal. |
| Email with retry configured | Pass | Green | retryEmail wraps sendConfirmation with 3 attempts and exponential backoff. |
| Payment external call | Warning | Yellow | Any Tool that processes payments inherently carries risk. The compensation flow mitigates this, but acknowledgment is still required. |
| Idempotency | Warning | Yellow | No explicit idempotency key is defined on the write operations. For production, consider adding one derived from reservationId + paymentMethod. |
Overall Risk Level: Yellow
The Tool is deployable, but requires explicit acknowledgment of the Yellow signals before deployment. The platform will present these warnings in the deployment UI, and the deploying user must accept each one. This acceptance is recorded in the audit log.
A Tool rated Green deploys without friction. A Tool rated Red is blocked entirely — the user must fix the flagged issues before deployment is possible. Red signals cannot be acknowledged or dismissed.
Step 5: Register via API
With both specs defined, register them in the Spec Registry using the platform API.
Register the Reservation Entity
curl -X POST http://localhost:3001/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @reservation-entity.json
Expected response:
{
"id": "spec_abc123",
"name": "Reservation",
"version": 1,
"type": "entity",
"createdAt": "2026-02-11T10:00:00Z"
}
Register the Payment Tool
curl -X POST http://localhost:3001/api/v1/specs/tool \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @process-payment-tool.json
Expected response:
{
"id": "spec_def456",
"name": "processPayment",
"version": 1,
"type": "tool",
"riskLevel": "yellow",
"riskSignals": [
{ "level": "yellow", "signal": "Payment external call requires compensation acknowledgment" },
{ "level": "yellow", "signal": "No explicit idempotency key on write operations" }
],
"createdAt": "2026-02-11T10:00:05Z"
}
Note that the API response includes the computed riskLevel and individual riskSignals. The Risk Engine evaluates the Tool spec at registration time — before it ever runs.
Verify Registration
You can retrieve the registered specs to confirm they were stored correctly:
# List all entity specs
curl http://localhost:3001/api/v1/specs/entity \
-H "Authorization: Bearer $TOKEN"
# Get the specific Tool spec
curl http://localhost:3001/api/v1/specs/tool/processPayment \
-H "Authorization: Bearer $TOKEN"
What You've Learned
In this tutorial, you defined a complete booking system without writing any runtime code:
-
Entity spec — Declared the data model, status lifecycle, invariants, and access control in a single JSON document. Fascia derives the database schema, migrations, and query layer from this spec.
-
Tool spec — Designed the payment flow as a DAG of typed nodes. The transaction boundary, compensation flow, and retry logic are all declared, not coded.
-
Risk analysis — The Risk Engine evaluated the Tool's safety properties at design time, flagging areas that need attention before deployment.
-
Spec registration — Both specs were registered via the API, versioned immutably, and ready for deployment to a customer's GCP project.
This is the core Fascia workflow: define specs, validate safety, deploy deterministically.
What's Next
- Entity Spec Reference — Full schema documentation for all Entity spec fields
- Tool Spec Reference — Complete reference for Tool specs, node types, and flow graph rules
- Risk Rules — Detailed breakdown of Green, Yellow, and Red risk signals
- Value DSL — Syntax and built-in functions for guard expressions and computed values