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.
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
-
- Two POS providers with opposite rules behind one core: 30-day tokens against 30-minute rotating ones, signed webhooks against one static app code.
- Two restaurant units in one deployment, each with its own menu, prices, orders and Clover sandbox merchant, and a real 404 across units.
- 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.
- 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.
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.
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.
/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.
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
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.
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
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.
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.
What is proven and what is not
| Area | Status, and what it means |
|---|---|
| Square and Clover integrations | Proven in sandbox. Tokens refresh, catalogs sync and orders push against sandbox accounts. No real merchant is connected. |
| Real merchant, customers, revenue | None. Both restaurants are demonstrations I operate. No customers, uptime figures or testimonials. |
| Readiness for a real merchant | Not yet. Named activation conditions remain, among them inventory write-back, catalog webhook concurrency, cancellation sync and per-unit admin roles. |
| Separation between restaurants | Proven, 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 pushes | Closed, 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 POS | Square only. Square order webhooks update the website order; the Clover adapter does not sync order status back. |
| Cancellations and refunds to the POS | Not built. A cancelled website order stays open in the POS. |
| Inventory | Pull only. Stock counts come from the POS; write-back to Clover is built and deployed switched off. |
| Couriers and payments | Sandbox 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
- The Cash Register Is the Database
- Why I Open-Sourced a Square Integration for Spree Commerce
- The Subscriber That Was Never Subscribed
- I wrote the integration checklist. Then I ignored it.
- The Assistant That Couldn't Say Hello
- I'm Learning MCP by Building a Server Claude Can Actually Log Into and Remote MCP on Rails: everything that broke
- Doorkeeper's most dangerous defaults are the ones you didn't set
- CanCan authorizes STI base classes before Rails reclassifies them
Facing a build like this?