Case study · POS
Food service shipped without adding a single order table
Retail and restaurant point of sale are sold as separate products. This one treats them as one order lifecycle at two speeds, and the claim is falsifiable rather than asserted: when the food service milestone landed, it added no new order tables.
Snapshot
| What this is | A study project in applied object-oriented design, and a candidate system for the company I work at |
| Built | Point of sale for retail and food service across multiple locations |
| Stack | Laravel 13, PostgreSQL, React register, Next.js back office, Tauri v2 with Rust, Docker |
| Timeline | Milestones M0 to M7, one developer |
| Status | Feature complete and tested. No store has run a trading day on it. |
The Question
A business selling both across a counter and at tables buys two systems and reconciles them by hand. The obvious explanation is that the two domains are genuinely different. The question this project set out to answer is whether they are, or whether the industry just ships them separately.
They are the same four steps: open an order, attach lines to it, take money, close it. Retail runs that in sixty seconds, so the open phase is invisible. Food service holds it for an hour, so the open phase gets a name, a screen, and the ability to be handed between staff. Everything else is presentation.
If that is true, one system can serve both and there is nothing to reconcile.
The Approach
Money is a value object, not a number. Amounts live in Domain/Money as integers in
the currency's minor unit, with one rounding rule in one place. No float touches a total
at any layer. The consequence is that split payments and refunds reconcile to the penny
by construction rather than by testing, which is the difference between a cash drawer
that balances and one that nearly does.
One system action is one Action class. 168 of them. Each takes an Input DTO, performs one operation, and returns a domain object. A form request never reaches the domain, so the rules that decide what happens to money are not entangled with the shape of an HTTP payload. The practical effect is that "what happens when a cashier voids a line" has one answer in one file.
A monolith, deliberately. One Laravel application, one database, four surfaces on top of it. Splitting a point of sale into services would buy independent deployment and cost distributed transactions across exactly the operations that must not half-happen. For a system where the failure mode is charging someone twice, one transaction boundary is worth more than one deployment boundary.
Tauri and Rust for the register shell. A browser tab cannot open a cash drawer or drive a thermal printer. The desktop shell handles those; the server still decides what a receipt says and whether a drawer may open, and audits who authorised it. The shell turns that into bytes and a pulse and decides nothing.
The Build
Online only, chosen with the cost written down. The terminal requires the server, so the server issues every ID and owns every total. That removes ID allocation, conflict resolution and client schema versioning in one decision. The cost is stated in the design document rather than hidden: a wifi outage stops sales. The mitigation is that every mutating request already carries an idempotency key, committed in the same transaction as the work, which is precisely the groundwork an offline queue would need.
The drawer count is blind. Closing a shift requires the counted cash before the expected total is shown. Showing the expected figure first turns a count into a transcription. It is slower and cashiers dislike it, which is the tradeoff.
One correction worth recording. Payment method screens described offline behaviour the system does not have, and refund-only methods were named as though they could take sales. The interface was making a claim the engineering did not support. Corrected by changing the copy to match the system.
Where It Stands
| Order model | One lifecycle, two selling modes, proved by food service adding no order tables |
| Money | Integer minor units end to end, one rounding rule |
| Surfaces | Register, back office, desktop shell, API |
| Test coverage | 550 backend test cases |
| Trading days run | Zero |
Every statement above is a property of the code, not of a business. No pilot store has opened, so there is no throughput figure, no real variance data and no user feedback, and none is claimed. Load testing is deferred in the roadmap with "first pilot store scheduled" as its trigger.
Next
Built to answer a design question and to be a candidate for a real floor. Get in touch if you run both a counter and tables.