Skip to main content

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 (pendingconfirmedchecked_incompleted)
  • 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.

StateMeaning
pendingReservation created but not yet paid. This is the initial state for every new reservation.
confirmedPayment received. The booking is guaranteed for the customer.
checked_inThe customer has arrived and started using the resource.
completedThe session is finished. This is a terminal state.
cancelledThe reservation was cancelled before use. This is a terminal state.
no_showThe 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:

  • confirmedcancelled: The guard now() < reservation.startDate ensures cancellation is only possible before the booking starts. Once the start time passes, this transition is blocked.
  • confirmedno_show: The guard now() > 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.

InvariantExpressionPurpose
endDateAfterStartreservation.endDate > reservation.startDatePrevents logically impossible date ranges (e.g., ending before starting).
totalPricePositivereservation.totalPrice > 0Ensures no zero or negative-price reservations can exist.
depositNotExceedTotalreservation.depositAmount <= reservation.totalPricePrevents 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:

FieldTypePurpose
iduuidPrimary key, auto-generated
createdAtdatetimeRecord creation timestamp
updatedAtdatetimeLast modification timestamp
deletedAtdatetime (nullable)Soft delete marker (Fascia never hard-deletes)
versionintegerOptimistic 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:

StepActionWhat happens in this Tool
1. Validate inputCheck input against the declared JSON SchemareservationId must be a UUID, paymentMethod must be "card" or "bank_transfer"
2. AuthorizeVerify JWT, check RBAC role, apply row-level accessCaller must own the reservation (via customerId match)
3. Policy checkRun registered policiesRow limit and rate limit policies are evaluated
4. Start transactionOpen a database transactionPostgreSQL BEGIN
5. Execute flow graphRun nodes in DAG orderRead reservation, assert status, create payment record, transition to confirmed, capture via Stripe, send email
6. Enforce invariantsCheck all Entity invariantstotalPricePositive, endDateAfterStart, depositNotExceedTotal
7. Commit / rollbackFinalize the transactionCOMMIT on success, ROLLBACK on any failure
8. Write audit logRecord what happenedEntity ID, operation, before/after state, user ID, timestamp
9. Return outputSend the responseNormalized 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

SignalStatusLevelRationale
Transaction boundary presentPassGreenAll write nodes (createPayment, confirmReservation) are inside txBoundary.
No raw SQL writesPassGreenAll writes go through the Entity abstraction (write nodes), never raw SQL.
Payment call outside transactionPassGreencapturePayment is correctly placed outside the transaction boundary. If it were inside, a rollback could not undo the Stripe charge.
Compensation flow presentPassMitigatedThe compensation block handles refunds if post-payment steps fail. Without this, payment-with-no-rollback would be a Red signal.
Email with retry configuredPassGreenretryEmail wraps sendConfirmation with 3 attempts and exponential backoff.
Payment external callWarningYellowAny Tool that processes payments inherently carries risk. The compensation flow mitigates this, but acknowledgment is still required.
IdempotencyWarningYellowNo 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:

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

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

  3. Risk analysis — The Risk Engine evaluated the Tool's safety properties at design time, flagging areas that need attention before deployment.

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