S
supero.docs
Documentation/Application Architectures/Appointment & Service Booking
Solution Architecture

Appointment & Service Booking

A priced service catalog, a set of staff who deliver it, and appointments that move through a real state machine — requested, confirmed, completed, cancelled, no_show. What the appointment service handles, and why contention is still your problem.

A booking application built from the service and appointment base types with a staff resource
Publish this elsewhere:

The Project

Booking applications sell time rather than objects. The unit of sale is a named service with a duration and a price, delivered by a specific person, in a window that has to be free. That last constraint is what separates this shape from a storefront: a catalog item can be sold a thousand times over, and a Tuesday at two o'clock cannot.
This shape fits when the following describe your problem. Salons, clinics, studios, repair shops, consultancies and inspection services all land here; so do internal applications that book rooms, equipment or specialists.
  • •What you sell has a duration as well as a price, and the duration determines how much of the day it consumes.
  • •Delivery is tied to a named person or resource, and customers care which one.
  • •An appointment has a lifecycle that outlives the booking moment — confirmed, rescheduled, completed, cancelled, and the awkward fifth case of the customer simply not turning up.
  • •Reminders matter, because unreminded appointments become no-shows.
  • •Staff need a calendar view and a customer history, and customers need a self-service booking surface.

ℹ️ The exemplar behind this page

This page follows app_m71-salon_new, a generated salon booking application in the namespace luxe_locks. It carries 25 schemas and imports nine services. It is a deliberately narrow example: it books and manages appointments, and it takes no money at all. That gap is discussed rather than glossed over.

Architecture & Design

Five of the salon's seven own types extend a platform base type, and two deliberately do not. The appointment is the centre of the model, and it is the only type carrying a state machine.
json
{
  "name": "booking",
  "namespace": "luxe_locks",
  "schema_type": "object",
  "schema_content": {
    "name": "booking",
    "parent_type": "tenant",
    "extends": "appointment:base_appointment",
    "attributes": [
      {"name": "start_time",   "type": "string",  "mandatory": true},
      {"name": "end_time",     "type": "string",  "mandatory": true},
      {"name": "stylist_uuid", "type": "string"},
      {"name": "booked_at",    "type": "string"},
      {"name": "hair_length",  "type": "string"},
      {"name": "hair_density", "type": "string"},
      {"name": "first_time_visit",   "type": "boolean"},
      {"name": "cancellation_reason","type": "string"},
      {"name": "check_in_time",      "type": "string"}
    ],
    "references": [
      {"name": "Stylist",      "cardinality": "one",  "role": "resource"},
      {"name": "Customer",     "cardinality": "one",  "role": "actor"},
      {"name": "Service",      "cardinality": "one",  "role": "service"},
      {"name": "ServiceAddon", "cardinality": "many"}
    ]
  }
}
The three roles on those references are load-bearing. The appointment service reads role actor to know whose appointment this is, role resource to know whose calendar it occupies, and role service to know what is being done. Rename the types all you like — the roles are what the service matches on.
Type in luxe_locksExtendsNotes
bookingappointment:base_appointmentinherits status of requested, confirmed, cancelled, completed or no_show, plus duration_minutes, confirmation_code, reminder_sent_at, and confirmed_at / cancelled_at / completed_at / no_show_at
serviceservice:base_serviceinherits price, currency, duration_minutes, pricing_unit and a status of active, inactive or discontinued; adds category, requires_consultation, aftercare_instructions and maintenance_weeks
service_addonservice:base_servicethe same base type used a second time for optional extras; references Service with cardinality many
customercustomer:base_customeradds salon-specific history — hair_type, hair_history, allergies, referral_source
attachmentattachment:base_attachmentfile records, backed by your own S3 bucket
stylistnonea plain tenant-parented record: full_name, email, photo, specialization, is_active, years_experience, working_days
time_slotnonea plain record with date, start_time, end_time, is_booked, slot_type and block_reason, referencing Stylist as resource

💡 Why stylist and time_slot extend nothing

There is no staff base type and no availability service, so there is nothing for them to extend. A stylist is a record that appointments point at, and a time_slot is a record that makes a stylist's published availability queryable — a grid you generate, not a calendar the platform keeps. Extending a base type you do not need would buy you a state machine you then have to work around.
The remaining schemas arrive with the services: the email service brings campaign, template, sender, message and suppression, the SMS service brings its own campaign, template and message, service_category is a plain value list, and the workflow engine brings workflow_definition, workflow_instance and workflow_step_log.

The End-to-End Flow

Every transition below is one call to POST /api/v1/services/execute against service_id appointment, with the operation named and the input identifying the extending schema. There is no scheduling API separate from this.
  1. 1

    Choose a service

    The customer browses service records. duration_minutes and price come from base_service, so the surface knows both how long the booking will run and what it costs without any extra modelling. Add-ons are service_addon records with a many-reference back to the services they pair with.

  2. 2

    Check availability

    appointment / check_provider_availability is a read-only query over existing appointments for a resource in a window, using half-open interval comparison so that a booking ending at two o'clock does not collide with one starting at two. It answers a question. It does not reserve anything.

  3. 3

    Book

    appointment / schedule_appointment moves the record from requested to confirmed and stamps confirmed_at. Calling it twice is idempotent — the original timestamp is preserved rather than restamped — so a double-submitted form does not produce two bookings from one request.

  4. 4

    Confirm to the customer

    base_appointment carries confirmation_code. Delivering it is the email or SMS service: a send_email or send_sms operation, or a workflow bound to the create event on the booking type so it happens without an explicit call.

  5. 5

    Remind

    reminder_sent_at is a field on the appointment. Something has to set it — a scheduled workflow that queries tomorrow's confirmed bookings and calls send_sms is the usual shape. The platform does not run a reminder loop on your behalf.

  6. 6

    Reschedule

    appointment / reschedule_appointment changes start_time and end_time and leaves the status alone. This one is deliberately field-only and is not idempotent: it is an edit, not a transition. A rescheduled appointment stays confirmed.

  7. 7

    Close it out

    complete_appointment, cancel_appointment or mark_no_show, each stamping its own timestamp. The state machine refuses the illegal paths — completing an appointment that was already cancelled returns HTTP 409 with INVALID_STATE_TRANSITION, and the record keeps its previous state and its references intact.

ℹ️ Two audiences, one dataset

The salon ships a public landing page for booking and a generated admin panel for staff: sections for appointments, stylists, customers, services and add-ons, a dedicated booking calendar configuration, and metric cards for today's appointments, active customers and a revenue trend. Both surfaces are records — see Dynamic UI.

What You Get Without Building It

The salon imports nine services: appointment, service, customer, notification, email, sms, attachment, amazon_s3 and workflows. Between them:
  • •The appointment state machine, with idempotent transitions, per-transition timestamps, and 409 on an illegal move.
  • •A read-only availability query with correct half-open overlap semantics, so you are not writing interval arithmetic.
  • •A priced, timed service catalog from one base type — used twice in this app, once for services and once for add-ons.
  • •Customer profiles tied to user accounts, so a returning customer's history is a reference walk rather than an email-address match.
  • •SendGrid email and Twilio SMS as configured integrations: four keys for email, three for SMS, held in the keystore and masked on read.
  • •File storage on your own S3 bucket for stylist photos and service imagery.
  • •A staff admin panel and a public booking surface generated from the schemas, plus a calendar configuration for the appointment view.
  • •Tenancy and the platform roles underneath, so a multi-location chain is a tenancy decision rather than a schema change.
What is conspicuously not in that list is money. This exemplar books time and records who is coming; it never charges for it. That is a choice the generated application made, not a platform limit, and the next section says what closing the gap involves. Note also that none of the above is a template you now own: the services belong to the platform, so an improvement to the appointment state machine is not a merge into your repository.

How to Implement in Supero

Two kinds of work. First you model the shape above and point each type at its base type; then you write the scheduling policy the base types deliberately leave to you.

Model it

  1. 1

    Build the schema

    Describe the app in App Studio — it generates the schemas and a reviewable UI — or author it over MCP from your editor. Aim at the object model above: a type opts into a service by extending its base type. Schemas are live on the CRUD API the moment they upload — there is no separate build step to run.

  2. 2

    Configure the services

    Imported services arrive uncredentialed. Put the keys for email and SMS in the keystore (SendGrid, Twilio) so confirmations and reminders can send — see Email & Notifications. This shape takes no payment by default, so there is nothing to credential on the money side unless you add it.

What you write yourself

Booking applications fail in a specific way, and it is worth being blunt about where the platform stops.

⚠️ Double-booking is not prevented for you

check_provider_availability and schedule_appointment are two separate calls and they are not one transaction. Two requests can both read an open window and both book it. The platform's own scheduling test documents this as a known limit rather than working around it. Making the resource reference present on your appointment type is what makes availability queryable — it is not a lock. If contention is real for you, the rule is yours to define: a uniqueness constraint you enforce in a workflow, an optimistic re-check after the write, a queue in front of booking, or a time_slot row you claim before you schedule. Decide it early, because retrofitting it after the first double-booked Saturday is worse.
The rest, in the order they tend to surface:
DecisionThe state of it in the exemplar
is_booked on a time_slotA boolean with a default of false that nothing flips. Generating the slot grid, marking slots taken, and reconciling that with the appointments themselves are all yours.
Working hours and blackout datestime_slot carries slot_type and block_reason, and stylist carries working_days as a string. Neither is interpreted by anything. Rota rules are application logic.
Taking moneyThis exemplar imports no payment service and defines no payment type — it books, it does not charge. Deposits, no-show fees and pay-at-booking mean adding a type that extends payment:base_payment and importing that service. The platform's own scheduling test composes appointment with payment for exactly this, so the composition is available; it is simply not in this app.
Remindersreminder_sent_at is a field. The schedule that populates it is a workflow you write with trigger_source scheduled.
Cancellation policycancel_appointment is an operation, not a rule. Notice periods, fees and who may cancel whose booking are yours — the last of those is an access-policy question.
Denormalised display fieldsThe generated booking type carries stylist_name and service_name alongside the real references. They are a display convenience and nothing keeps them in step with the referenced records. Either maintain them in a workflow or read through the reference.
Recurring appointmentsA standing weekly slot is a series of records you generate. There is no recurrence rule on base_appointment.
Most of these belong in workflows rather than in a surface. A workflow is a record of type wf:workflow_definition; its steps are service_call, crud_operation, condition, parallel, transform or loop; each step chooses stop, continue, retry or compensate on error with a retry count and a timeout — see the Workflow Engine Reference for the full contract; and an event binding can fire the whole thing when a booking record is created. Runs are inspectable — one workflow_instance per run and one workflow_step_log per step. See Workflows & Automation and Email & Notifications.

Deployment

The path from this shape to a running booking application at a public URL, end to end:
  1. 1

    Preview while you iterate

    A preview deploy answers at https://preview-{domain}-{project}.supero.live for a short lease while you build and test. Publish Your App covers how preview and live differ.

  2. 2

    Go Live

    Go Live publishes to a permanent, Supero-hosted URL — https://live-{domain}-{project}.supero.live — with no cloud account to connect and nothing to provision. It is asynchronous: you get a 202 and a poll URL, then the live address. The full contract is in Publish Your App.

  3. 3

    Watch the logs

    When a deploy fails or the live app misbehaves, the **Cloud Deploy Logs** page in the dashboard shows the published service’s container logs — pick the Live lane to read the published app rather than the preview build (up to ~24 hours back, ~500 lines per request). For platform activity — requests, auth, errors across your domain — GET /api/v1/logs is a filtered read, and a single request traces across services at /api/v1/logs/trace/{correlation_id} when you set your own X-Correlation-ID. See Operations & Observability.

Final Thoughts

This shape fits when you sell time rather than objects: a named service with a duration and a price, delivered by a specific person, in a window that has to be free. That last constraint is what separates it from a storefront — a catalog item can be sold a thousand times over, and a Tuesday at two o'clock cannot. If you also need to take money, this exemplar does not: charging means adding a type that extends payment:base_payment and importing that service.
If you take one thing into the build, take this: double-booking is not prevented for you. check_provider_availability and schedule_appointment are two separate calls and not one transaction, so two requests can both read an open window and both book it. Decide the contention rule early, because retrofitting it after the first double-booked Saturday is worse.
And what you walk away with is yours. The appointment state machine, the availability query, SendGrid and Twilio as configured integrations — those belong to the platform, so an improvement to them is not a merge into your repository. What you own is the schema, the access policy, and the scheduling and contention logic above them.