CrustLogic

An offline-first point of sale for independent pizzerias.

Order entry at the counter, tickets to the kitchen, printed receipts, card and cash. It runs on a small computer in the back of the shop, and the shop's internet connection is not part of the order path.

How it is built

Three codebases and one event log. A Go binary on a mini PC in the shop, a Flutter app on the counter tablet, and a Next.js storefront on the web. The Go binary owns the log. The other two are clients of it.

Counter tablet Flutter, on the shop's own wifi. No internet involved.
Mini PC in the back Go binary and the SQLite event log. Authoritative during a shift.
Two printers Thermal for receipts at the counter, impact for tickets in the kitchen. Ethernet, fixed addresses.
Needs the internet Web orders, which arrive from the storefront and land in the same queue as the counter's. Cloud replication, which is a sync target and catches up after an outage. The card reader, which the payment processor drives from its own cloud.
The solid path is local. It keeps working with the line down. Everything in the dashed box depends on the connection, and the card reader is the only one of the three that costs you a sale while it is out.

Offline first

A pizzeria on a Friday night that loses its internet still has a line at the counter. That constraint is the reason for every other decision here.

The order path is local. A tablet on the counter talks to a Go binary on a mini PC over the shop's own wifi, and that binary writes to a SQLite database on its own disk. SQLite is authoritative during a shift. Orders are entered, tickets print in the kitchen, receipts print at the counter, and cash is handled, all without anything leaving the building.

The cloud is a sync target: replication runs when the connection is up, catches up when it returns, and nothing on the order path ever waits for it.

Card authorization needs the internet. The reader is driven by the payment processor's own cloud, not by the local machine, so a card cannot be authorized while the line is down. Cash and the rest of the shift carry on.

Event sourced

Every change to an order is an event appended to a log. Current state is a projection of that log. Nothing overwrites a row.

The payoff is what you can answer afterwards. What was on the ticket before the customer changed their mind. When exactly the kitchen was told. Whether two devices sent the same order twice. When someone calls at eleven the next morning about last night's order, there is a record to read.

Projections are rebuilt by replaying the log, so a reporting bug is a fix and a replay.

Orders from the web

The shop's website is a Next.js storefront, and an order placed there has to reach the same tablet as an order taken at the counter, with the same kitchen routing and the same receipt.

The hard part is that taking the money and creating the order are separate events in time, and the gap between them is where a customer ends up with a receipt and no pizza. So the order is staged before the payment, not after it. The storefront posts the cart, the server prices and validates it and writes the resolved order to its own event stream, and only then opens a hosted checkout session. The customer pays on the processor's page, so no card data touches CrustLogic. The payment webhook replays that stream, gets the priced order back, and injects it into the same queue the counter writes to.

Two useful properties fall out of staging it that way. Prices are frozen at order time, so a menu edit between checkout and payment cannot change what the customer was charged, and the webhook needs no menu lookup at all. And because fulfillment is itself an event on the checkout stream, a webhook delivered twice is a no-op, and a payment that succeeds against an injection that fails leaves a paid-but-not-injected record to work from.

The physical reality

The figure above names the boxes, but a few things about them only matter once you are standing in a kitchen.

The two printers are not interchangeable, and for a while I treated them as one surface. That was a bug. The impact unit prints 33 columns and the thermal 48, both measured on the actual hardware rather than taken from a datasheet, and a layout that fits only one of them does not fail in CI. It fails on paper, in a kitchen, mid-service.

Interfaces from day one

Five things sat behind interfaces before there was a second implementation of any of them: the payment gateway, the receipt printer, the order source, the event store, and the clock.

The payment gateway is the obvious one. Nothing outside the payments package imports the processor's SDK, so adding a second processor means writing one adapter. The order source is the same idea pointed at intake: a counter order and a web order enter the system as the same kind of thing, tagged with where they came from.

The clock is the one people ask about. Domain code never calls the system clock directly, it asks an injected one. That is what makes the event log deterministic under test, and it is what lets a replay produce the same projections it produced the first time.

I put all five in before there was a second implementation of anything, which is easy to call over-engineering until the day you need one. Threading a seam through a codebase that has already assumed a single implementation everywhere is the expensive version of this work. Writing the interface first costs an afternoon.

A comment that expired

The storefront's pickup button sits behind a flag, and a hook used to read that flag from a cookie after hydration. There was a line in that file saying a brief reveal on mount was acceptable, and when I wrote it that was true, because the flag was off and the reveal never fired. Then pickup went live and the trade quietly expired underneath me: every page on that site is statically prerendered and edge cached, so the document going out to every visitor carried the flag-off variant, and the most important button on the site started arriving late and shifting the layout under the customer's thumb. It was reported from the floor. No monitor caught it, because there was no probe on that path to catch it with.

The fix renders both variants and picks one in CSS, with a blocking inline script stamping the answer onto the root element before first paint. Every page stays static and edge cached.

Then CI went red, which the ticket I had written said it would not. The new pattern moves the invariant from the wrong variant being absent to the wrong variant being hidden, so every test asserting absence became a failure and one selector that had matched a single element started matching two. Assert visibility, never presence.

Where it runs

One restaurant. A family-owned pizzeria in Las Vegas, thirty years in business, running in production since August 2026.

It takes orders at the counter and from the shop's website, prints kitchen tickets and customer receipts, and handles card and cash payments during real service.

One customer is the honest number and there is no reason to imply otherwise. The interesting claim was never how many shops run this. It is that one does, on a Friday night, with real money and real people waiting on the order.

Contact

If you are an engineer with a question about any of the above, or a shop owner wondering what this would mean for your counter, email is the way in.

nick@crustlogic.com

I read it.