Case Study · Independent Build

Platestead: one codebase, a deployment per restaurant brand, two POS providers behind one core

Platestead is the restaurant commerce platform I designed, built and operate: one Rails codebase, where each restaurant brand gets its own storefront, domain, database and point-of-sale connection. Two demonstration restaurants run on it against Square and Clover sandbox accounts. No real merchant has been onboarded, so this page is about the engineering, and it says plainly what is proven and what is not.

2 demonstration restaurants; no real merchant yet
3 storefront units across the two brands
2 POS providers, Square and Clover, both in sandbox
7 Rails engines, 5 of them open source

At a glance

What it is
A restaurant ordering platform: a Rails and Spree admin and Store API, Next.js storefronts, and one codebase with a separate deployment and database for each restaurant brand.
What runs today
Two demonstration restaurants I operate, on one shared server: The Local Table (Square sandbox, card payments in Stripe test mode) and Ember & Fork with two units, Mill Bend and Riverside (Clover sandbox merchants, pay at pickup). No real merchant yet; neither restaurant prepares or fulfils an order.
Hardest problems
  1. Two POS providers with opposite rules behind one core: 30-day tokens against 30-minute rotating ones, signed webhooks against one static app code.
  2. Two restaurant units in one deployment, each with its own menu, prices, orders and Clover sandbox merchant, and a real 404 across units.
  3. One kitchen ticket per order when a push is retried or raced: idempotency keys on Square; a per-order PostgreSQL lock and adopt-on-retry on Clover.
  4. Guarded releases for two tenants: a guarded deploy path, verified backups, and a failed cutover rolled back in 18 minutes with no data change.
Stack
Ruby on Rails 8.1, Spree Commerce 5.6, PostgreSQL, Solid Queue; a Next.js 16 storefront; Kamal and Sentry. Five of the seven engines are open source on RubyGems.
My role
Sole engineer: I designed, built and operate it, using AI coding agents under my direction and review.
More
platestead.com (the platform's public site) · the earlier Square-only build

From one restaurant to a platform

The first phase, in August 2026, was a first-party ordering platform for one fictional restaurant on Spree and a Square sandbox account, written up in the first-party ordering case study. It proved the integrations. It did not answer the question a restaurant group asks next: what happens with the second brand, the second POS, the second location?

Platestead is my answer, built in September 2026. One codebase (a Rails admin and Store API on Spree Commerce, a Next.js storefront, pinned integration gems) produces one image per commit, and each brand gets its own deployment. What differs between restaurants is configuration and data, never a copy of the code. Each brand has its own database, secrets and encryption keys, and one brand's API key is refused by the other's backend. The two demonstration brands do share one server. The second brand, Ember & Fork, was stood up from configuration and data on the same code, with its own theme chosen at runtime from the same storefront image.

Platform overview. One codebase builds one image per commit. Two deployments, The Local Table and Ember & Fork, each have a Spree admin with Solid Queue inside, a Next.js storefront and their own Postgres database, on one shared server behind a reverse proxy, with separate databases, secrets and keys but no network segmentation. External services: a Square sandbox for The Local Table, two Clover sandbox merchants for Ember & Fork, Stripe test mode, DoorDash and Uber Direct sandboxes, a shared Sentry, and the static platestead.com site.
One codebase, one image per commit, one deployment per brand. Every POS box is a sandbox account, and both deployments share one server. Full diagram.

Two POS providers behind one core

Phase 1 spoke only Square. For Platestead I extracted a provider-neutral core, spree_pos, which owns what every restaurant integration needs regardless of vendor: connections and their credentials, locations, external references, catalog sync, order push, stale-push sweeps, inventory sync and a nightly reconciliation at 03:00 UTC. Square became an adapter on that core, and I wrote a Clover adapter, spree_clover, against the same seams. Both run against sandbox accounts: The Local Table against a Square sandbox seller, Ember & Fork against two Clover sandbox merchants.

The providers differ on nearly every axis that decides correctness:

  • Tokens: a Square access token lasts about 30 days and its refresh token is stable; a Clover token lasts about 30 minutes and its refresh token rotates on every use, so the new one must be saved before the old one is gone.
  • Webhooks: Square signs each event; Clover authenticates with one static code shared by the whole app, so a Clover payload proves nothing on its own.
  • Orders: Square takes an order plus an already-paid external payment; Clover takes a custom order, then its lines, then a payment.
  • Idempotency: Square honours idempotency keys natively; on Clover the adapter gets the same effect by looking up its own reference before creating anything.

The core owns the shared lifecycle; each adapter owns its provider's rules, differences included. With only two adapters written, I don't call it "plug in any POS".

One Clover lesson: the gem had a token refresh method that nothing called, and it pointed at the wrong endpoint. No test revealed it, because it only mattered once a real sandbox token reached its refresh window. It surfaced there, was fixed, and was verified on the real clock. Both providers now refresh tokens automatically in production, against their sandbox accounts.

POS provider boundary. The spree_pos core holds connections, locations, external references, catalog sync with a prune guard, gated order push with a sweep and a per-order lock (spree_pos 0.6.5 and later, the Ember & Fork line; The Local Table relies on Square idempotency keys), inventory sync, a nightly reconciliation at 03:00 UTC, webhook events and sync runs. The Square and Clover adapters sit below it, with a strip contrasting their authentication, webhook authentication, idempotency and catalog behaviour. Inventory write-back is drawn dashed and labelled built, switched off.
What the core owns and what each adapter owns, both against sandbox accounts. The per-order lock is in spree_pos 0.6.5 and later, which Ember & Fork runs; The Local Table runs the 0.6.0 line and relies on Square idempotency keys. Dashed: built, switched off. Full diagram.
The Local Table's menu page: a row of category pills and the first two rows of dish cards with photos and prices.
The Local Table's menu is not edited in Spree. Categories, dishes and prices follow the Square sandbox catalog.

A restaurant unit is a store

Ember & Fork needed two restaurants in one deployment, Mill Bend and Riverside, each with its own menu, prices, kitchen stock, orders and Clover sandbox merchant. The obvious model was a unit as a stock location. Spree's own constraints ruled it out: a store has one catalog source, enforced by a validation and a partial unique index; products belong to a store and prices have no per-location column; a publishable API key belongs to one store. So a unit is a Spree::Store with its own key, and there is no new unit model to maintain.

The storefront resolves the unit from the URL (/us/en is Mill Bend, /riverside/us/en is Riverside), sends that unit's key with each request, and partitions its caches per unit; a header claiming a different unit is ignored. I verified isolation for catalog, carts, orders, stock locations and POS connections, and the live site shows it: a Riverside dish answers 200 under the Riverside path and a real 404 under Mill Bend's.

Not unit-aware: loyalty, menu chat and the courier admin pages are shared across units, and there are no per-unit admin roles yet (the code fails closed). Both units share one deployment and one database. Multi-unit runs on Ember & Fork only; The Local Table is a single unit.

Unit isolation. A request path, /us/en or /riverside/us/en, enters the storefront proxy, which resolves the unit and attaches that unit's publishable key. Two Spree store boxes, Mill Bend and Riverside, each hold a stock location, a menu and prices, orders, and a Clover connection to its own sandbox merchant. Caches are partitioned per unit, a spoofed unit header is ignored, and a cross-unit request ends in a 404. A shaded band marks what is shared: the admin, the database, loyalty, menu chat and the courier admin pages.
How a URL becomes a unit, and what stays shared. The shaded band is not unit-aware. Full diagram.
The same deployment and storefront image, two stores: Mill Bend (left, /us/en) and Riverside (right, /riverside/us/en) serve different dishes, categories and prices. Even a cola is priced per unit, $3.49 further down Mill Bend's menu and $2.49 at Riverside. Captured read-only on 27 September 2026.
A Riverside dish under Mill Bend's path: the styled page, and the status codes behind it, measured with plain read-only requests on 27 September 2026. The 404 is a real HTTP status, not a 200 with an error message.

Real 404s under Partial Prerendering

That 404 was not free. The storefront uses Next.js Partial Prerendering, where every page is served from a prerendered shell, and the shell's cached 200 overwrote the proxy's 404: unknown dishes answered 200 to browsers and crawlers alike. The fix checks existence in the proxy with the unit's own key (failing open on errors, behind a bounded cache) and marks only missing pages to render without the shell. Valid pages keep prerendering.

Show the diagram: how only missing pages skip the prerendered shell
Soft-404 fix. A request reaches the proxy, which checks whether the dish or category exists using the unit's key. Found keeps the prerendered shell and returns 200. Missing adds a marker, so the page renders without the shell and keeps its 404. Unknown, when the check itself fails, fails open. Results sit in a bounded cache.
Only missing pages skip the prerendered shell, so real pages keep their speed and missing ones keep their 404. Open full size.

One order, one kitchen ticket

When a website order completes, an event enqueues a job that pushes it to the restaurant's POS. No automatic path pushes an order twice, but manual and abnormal ones could: a retried job from a worker presumed dead but still running, or a console re-push during an incident. On Clover, a second concurrent push duplicates the kitchen ticket, or adopts a half-built order and doubles every line and the payment.

The Local Table needed no change: Square's deterministic idempotency keys make a re-push of the same order a no-op. Clover has no such key, so on Ember & Fork the adapter looks up an existing order by its external reference before creating one and adopts it on a retry, and the core wraps every push in a per-order PostgreSQL advisory lock. A second live push of the same order is refused before it claims or sends anything, and its job retries once the first has settled.

The lock is session-level on purpose. It belongs to a live database connection, so a worker killed mid-push loses it the moment its connection dies and the retry can proceed, while a live worker keeps it. No transaction stays open across the calls to Clover, and there is no lease to expire. Two negative controls back it: the race spec, run against the version before the lock, records two order creates where there should be one, and a spec run against the lock without the query-cache bypass shows the lock leaking. This is idempotent per order by design, not exactly-once, and Clover's adopt-on-retry path was validated on one sandbox order.

# One live push per order (simplified). A PostgreSQL session-level advisory lock is
# held by a live database connection and released by the server when that connection
# dies, so it tells "a worker is pushing this now" from "a dead worker left it pending".
def synchronize(order_id)
  OrderMapping.connection_pool.with_connection do |db|
    raise InFlightError unless advisory(db, "pg_try_advisory_lock", order_id) # never waits
    begin
      yield # claim the mapping, then call Clover
    ensure
      advisory(db, "pg_advisory_unlock", order_id)
    end
  end
end

# Always sent to the server: a query-cached "true" would mean a lock never taken,
# or never released.
def advisory(db, fn, order_id)
  db.uncached { db.select_value("SELECT #{fn}(#{NAMESPACE}, #{key_for(order_id)})") }
end

A second failure was quieter. A worker killed after marking a push pending left the order stranded with no alert, because Solid Queue fails the claimed execution directly and the job's retry handler never runs. A sweep every 5 minutes now finds stale pending pushes and alerts once, deduplicated so the error tracker does not open a new issue every 5 minutes.

The end-to-end acceptance order on Riverside, which I placed on 27 September 2026, was pushed once to the Clover sandbox with its required choice carried, and the confirmation email arrived. As of 04:00 UTC that day, The Local Table had pushed 25 orders to the Square sandbox, every one successfully. Ember & Fork had pushed 5 to its Clover sandbox merchants, every one successfully. All of them are test and demonstration orders.

Order push sequence. An order completes, an event enqueues OrderPushJob, which on the Ember & Fork line takes a per-order advisory lock or skips if it is held, claims the order mapping and calls the adapter. The Square branch sends idempotency keys. The Clover branch looks up the order by external reference, then adopts or creates it and sends the total, lines and payment. The mapping ends succeeded, failed or ambiguous. A side lane shows a killed worker leaving a mapping stuck pending, and a 5-minute sweep that alerts once and re-enqueues.
A per-order lock on the Ember & Fork line, a different duplicate defence per provider, and a sweep for the push nobody is watching. Full diagram.

Distrust by default: webhooks and deletions

Integration reliability is mostly distrust: of payloads, of timing, and of "missing means deleted".

  • Clover webhooks. The static code cannot authenticate the body, so the adapter treats a payload as a hint and re-fetches the real state with that merchant's own credential. In a sandbox test, a payload claiming 999 in stock produced Clover's real 24.
  • Square webhooks are checked against the signature for the URL they arrived on and deduplicated by event id.
  • Deletions. An item missing from a POS listing is archived, not deleted, and only after a complete listing; a sync that would remove more than 25 percent of the menu is withheld. This is proven in the Square sandbox, and archived dishes stay archived.
  • Direction. Catalog sync is one-way, from the POS to the storefront, by webhook, with the nightly reconciliation as the backstop.
Show the diagram: from webhook to menu change, and where each trust check sits
Sync trust boundaries. A webhook arrives and is authenticated: Square by signature on its arrival URL, Clover by a static code, so its body stays untrusted. Events are deduplicated by id, then state is re-fetched from the POS with the connection's own credential before catalog sync. A prune guard requires a complete listing and withholds removals over 25 percent; missing items are archived, not deleted, and stay archived. A nightly reconciliation runs as the backstop.
Nothing in a webhook body is written to the menu directly, and a vendor hiccup cannot empty it. Open full size.

Business rules at every entry point

The add-to-cart endpoint enforced required dish choices, such as a spice level. A second path did not: Spree's cart create and update calls also accept items, and that path skipped the check. A release sweep found it. The fix covers both paths on both restaurants and was verified in production on 25 September 2026 with a request matrix: incomplete requests are refused with a 422, valid ones succeed.

The same sweep closed:

  • a payment-integrity gap in which pay at pickup could complete a delivery order, found and fixed before any real merchant, and confirmed with a production probe;
  • anonymous file uploads to the admins, now refused, with request bodies capped at the proxy (a 413 over the limit);
  • a critical Next.js security advisory, on both storefronts.
What a diner sees on each restaurant: required groups marked with an asterisk (The Local Table, 27 September 2026) and the refusal when one is skipped (Riverside, captured 25 September 2026). The screenshots show the interface; the proof that the API refuses too is the production request matrix described above.

Releases and operations

A platform is only as safe as its worst deploy.

  • Guarded deploys. Every deploy goes through a per-tenant wrapper that refuses a build whose history does not contain what is already running, and checks its tenant's secret files. My practice for the September 2026 deploys added a verified backup and an empty queue of in-flight order pushes before each one. The deploys are not zero-downtime, and I don't claim they are.
  • A failed cutover, rolled back. On 11 September 2026 the move of The Local Table onto the new POS core stopped at a post-deploy check when a latent bug surfaced. The runbook's rollback ran: 18 minutes of maintenance, no data changed, no restore needed. The root cause: test fakes that agreed with the bug instead of with the SDK.
  • A hostname migration done in place. On 27 September 2026 The Local Table moved to its platestead.com host. Its branch was first rebuilt on top of production, after it proved to be missing live fixes. The Square sandbox webhook subscription was edited in place rather than recreated, so its signing key did not change; a test event arrived signature-verified; the old hosts kept serving; and the rollback was written before the change.
  • Backups and monitoring. Nightly verified database backups for both restaurants, and a restore rehearsal for The Local Table that matched production counts; the backups stay on one machine, by decision. Sentry and uptime monitors watch the public endpoints, and Sentry showed no error events from the final deploys through the freeze checks. The honest gaps: no alert yet for a queue backlog, a stalled worker or an out-of-memory container, an accepted debt, and both deployments report to the same Sentry projects.
Release guardrails. A tenant wrapper runs an ancestry check that the new commit contains the running one and a secret-file check; the release runbook adds a pre-deploy gate of a verified backup, zero in-flight pushes and passing suites, then the deploy, verification probes and a rollback target. Beside it, nightly verified backups and uptime and Sentry monitors. An inset timeline shows the 11 September 2026 cutover stopping at a post-deploy check and rolling back after 18 minutes of maintenance with no data change.
The deploy path every change takes, and the 11 September cutover that had to stop (it predates the wrappers). Full diagram.
An evidence card for The Local Table's hostname migration: the steps in order, the Square sandbox webhook edited in place, a signature-verified test event, the old hosts still serving, and the rollback ready.
The hostname migration, as recorded at the time.

What is proven and what is not

AreaStatus, and what it means
Square and Clover integrationsProven in sandbox. Tokens refresh, catalogs sync and orders push against sandbox accounts. No real merchant is connected.
Real merchant, customers, revenueNone. Both restaurants are demonstrations I operate. No customers, uptime figures or testimonials.
Readiness for a real merchantNot yet. Named activation conditions remain, among them inventory write-back, catalog webhook concurrency, cancellation sync and per-unit admin roles.
Separation between restaurantsProven, with limits. Separate deployments, databases, secrets and keys, and a cross-restaurant API key is refused. They share one server with no network segmentation, and one pair of Sentry projects.
Unit isolation (Ember & Fork)Proven, scoped. Catalog, carts, orders, stock locations and POS connections. Loyalty, menu chat and courier admin are shared; no per-unit admin roles.
Duplicate order pushesClosed, not exactly-once. Idempotency keys on Square (The Local Table); lookup and adopt plus a per-order lock on Clover (Ember & Fork), both in sandbox.
Order status back from the POSSquare only. Square order webhooks update the website order; the Clover adapter does not sync order status back.
Cancellations and refunds to the POSNot built. A cancelled website order stays open in the POS.
InventoryPull only. Stock counts come from the POS; write-back to Clover is built and deployed switched off.
Couriers and paymentsSandbox and test mode. DoorDash Drive and Uber Direct sandboxes, Stripe test mode.

Stack

Ruby on Rails 8.1 and Spree Commerce 5.6 for the admin, the Store API and every integration engine; Rails is my primary stack. PostgreSQL, with Solid Queue for jobs. The storefront is Spree's Next.js storefront (Next.js 16, TypeScript), which the project uses because it is MIT-licensed where Spree's Rails page builder is not. Kamal deploys behind kamal-proxy on one shared server; Sentry and uptime monitors; platestead.com is a static site on Cloudflare Pages. Five of the seven engines are open source and published on RubyGems (spree_square, spree_doordash, spree_uber_direct, spree_loyalty, spree_menu_chat); the POS core and the Clover adapter are private.

How it was built

I designed, built and operate Platestead as a solo engineer, from the first commit on 12 August 2026 to the release that closed on 27 September 2026; the Platestead work (the POS core, Clover, units, the second brand and the release) is the September half. I used AI coding agents for much of the implementation, under my direction and review. I set the architecture, wrote the acceptance bars, approved every production change and ran the releases.

Some of the work was never the code: the Square Dashboard steps of the hostname migration, rotating sandbox credentials, placing the acceptance order, and deciding which risks to accept, such as local backups and the missing queue alerts. Fixes were proven with negative controls, tests that fail without the change, and production checks were read-only probes unless I approved the write.

What this demonstrates for client work

  • Put a boundary around volatile third-party systems, so the second integration is an adapter, not a rewrite.
  • Model from the framework's real constraints before adding a new table.
  • Assume the abnormal paths exist. Close them with locks, sweeps and alerts.
  • Enforce business rules at every entry point, not in the UI.
  • Make releases boring: guarded deploys, rollbacks written before the change, verified backups.

Related writing

Facing a build like this?

← All case studies