A GraphQL-first booking system (the core of an OpenTable / Calendly): venues publish time slots, authenticated users claim them, no two users can ever book the same slot, and every watcher sees a slot flip to BOOKED the instant it's claimed. Built on AWS AppSync with DynamoDB single-table design.
AWS AppSync (GraphQL) is the only front door.
Reads resolve straight to DynamoDB with no Lambda; booking runs as a Step
Functions saga; DynamoDB Streams power live subscriptions. Cognito authenticates
every request. Runs for ~$0/month, is account-agnostic (clone and apply into any
AWS account), and is designed to be terraform destroyed between sessions.
Cognito user pool ── JWT ──┐
▼
client ──GraphQL/HTTPS──► AppSync ──┬─ venues / venueSlots / myBookings ─► DynamoDB
(queries, mutations) │ │ (APPSYNC_JS resolvers, (single table,
▲ │ │ direct to DynamoDB) GSI1, TTL, Stream)
│ onSlotUpdate│ └─ bookSlot ─► Lambda ─► Step Functions (Express, sync)
│ (subscription)│ PlaceHold → TakePayment → ConfirmBooking
│ │ └ payment fails → ReleaseHold (compensate)
│ ▼ │
│ publishSlotUpdate (NONE, DynamoDB Streams (status change / TTL expiry)
└──── @aws_iam) ◄── Streams Lambda ◄───────────────────────────┘
(SigV4) — also reverts TTL-expired holds to OPEN
A booking never double-books: PlaceHold and ConfirmBooking are conditional
TransactWriteItems. If payment fails, the saga's ReleaseHold compensation
returns the slot to OPEN. If a hold's 15-minute TTL fires first, DynamoDB deletes
it, the Streams Lambda sees the deletion and reverts the slot — and that write
re-enters the stream, so the "open again" update is published for free.
Everything lives in one DynamoDB table. The sort-key prefix plus one GSI cover every access pattern:
pk sk entity gsi1pk gsi1sk
VENUE#<vid> META Venue VENUE <name>
VENUE#<vid> SLOT#<startsAt> Slot — —
BOOKING#<bid> META Booking — —
USER#<sub> BOOKING#<createdAt>#<bid> BookingRef — —
| Access pattern | How |
|---|---|
| list venues | Query GSI1 (gsi1pk = "VENUE"), alphabetical — never scans the table |
| a venue's slots | Query base table (pk = VENUE#vid, sk begins_with "SLOT#"), chronological |
| my bookings | Query base table (pk = USER#<sub>), newest first — partition key is the caller's identity |
| book a slot | TransactWriteItems: conditional slot update (status = OPEN) + booking + user ref |
The booking transaction is the heart of it: two callers racing for the same
slot both submit the transaction, but only one satisfies status = OPEN. The
loser's condition fails, DynamoDB cancels the whole transaction (no orphan
booking row), and the resolver returns slot unavailable.
make venv # local dev deps (moto, pytest, websockets)
make test # unit tests — in-memory DynamoDB via moto, no AWS needed
make bootstrap # one-time: create the S3 remote-state bucket (survives destroys)
make init # connect the dev stack to the state bucket (name derived from your account)
make apply # deploy (~90s)
make seed # load a demo venue + open slots
make smoke # end-to-end GraphQL test (Cognito user, booking saga, compensation, …)
make destroy # tear it all down (state bucket survives for next session)See the real-time path in action: run make watch in one terminal (live
onSlotUpdate subscription), then make smoke in another and watch slots flip
OPEN → BOOKED as they're claimed.
A React + Vite app (AWS Amplify for Cognito auth + AppSync, including the subscription WebSocket) that shows the whole thing in a browser. After the stack is applied:
make demo # seeds data + demo users, writes web config, starts the dev server
# → open http://localhost:5173, sign in with a demo login shown on the pagemake demo bundles make seed + make seed-users + make web-config (which
generates web/.env.local from the terraform outputs) and runs npm run dev.
First time only: cd web && npm install.
The money demo: open the app in two browsers, sign in as [email protected] and
[email protected], and book a slot in one — it flips to BOOKED in the other
instantly (the onSlotUpdate subscription). Tick Simulate payment failure
before booking to watch the saga's compensation: the booking is rejected and the
slot stays OPEN.
The frontend always runs locally (Vite dev server) pointed at whatever stack is currently deployed — there's nothing to host while the backend is torn down between sessions.
To remove everything including the state bucket, use make destroy-all
(dev stack first, then bucket).
Requires: Terraform ≥ 1.10, AWS credentials, curl, python3, AWS CLI v2.
There's no local emulator for AppSync, so the loop is logic-local,
AppSync-on-AWS: all the Lambda logic (the saga starter, the payment mock, and
the Streams processor — including the conditional hold-revert) is unit-tested
against in-memory DynamoDB with moto via make test (fast, free, no AWS). The
thin AppSync layer — resolvers, Cognito auth, subscriptions — is integration-
tested against a deployed stack via make smoke and make watch.
type Query {
venues: [Venue!]!
venueSlots(venueId: ID!): [Slot!]!
myBookings: [Booking!]! # scoped to the caller's Cognito sub
}
type Mutation {
bookSlot(venueId: ID!, slotId: ID!, failPayment: Boolean): Booking! # runs the saga
publishSlotUpdate(slot: SlotInput!): Slot @aws_iam # system-only (Streams Lambda)
}
type Subscription {
onSlotUpdate(venueId: ID!): Slot @aws_subscribe(mutations: ["publishSlotUpdate"])
}failPayment: true forces the saga's payment step to decline — a demo affordance
for exercising the compensation path. Every client request carries a Cognito
user-pool JWT in the Authorization header.
Resolvers read the caller from ctx.identity.sub, so a user can only ever see
their own bookings — the partition key is derived from the verified token, not
from a client-supplied argument.
Call it by hand once deployed:
GQL=$(terraform -chdir=terraform/envs/dev output -raw graphql_url)
# (see scripts/smoke.sh for getting a TOKEN from Cognito)
curl -s -X POST "$GQL" -H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"query":"query{ venues{ venueId name } }"}'Slate grew in three green, independently deployable checkpoints:
- Phase 1 ✅ single-table + Cognito + AppSync JS resolvers + an atomic
bookSlot(conditionalTransactWriteItems). - Phase 2 ✅ DynamoDB Streams → a Lambda →
publishSlotUpdate(IAM-authed, NONE data source) → liveonSlotUpdate(venueId)GraphQL subscriptions. TTL holds; an expired hold's stream event reverts the slot to OPEN. - Phase 3 ✅
bookSlotis now a Step Functions Express saga (hold → pay → confirm) with a mock payment and compensation (release the hold when payment fails). The conditional holds are issued via Step Functions' DynamoDB SDK integration — no per-step Lambda.
PAY_PER_REQUEST DynamoDB, AppSync, and Cognito are all pay-per-use, so an idle
stack costs ~$0. CloudWatch log groups are created explicitly with 7-day
retention so they don't linger after terraform destroy. Tear down between
sessions; the state bucket is the only thing that persists.