For the last few months I've been building Tilly POS, a point of sale and back office for shops and small retail chains. It's live, there's a demo you can break, and this post is the tour: what it does, how it's put together, and what I'd tell anyone building something similar.
Try it first: open the demo as the cashier or as the owner. No sign-up; it resets every night.
What it does
- Checkout — scan or search, held sales, discounts with manager approval, split payments, receipts
- Cash drawer — shifts with an opening float, paid in/out, a denomination count at close, manager review
- Returns and voids — partial refunds to the original payment method
- Stock in every branch — a ledger per item, weighted-average cost, counts, adjustments, transfers
- Purchasing — suppliers, purchase orders, goods received
- Reports — sales, profit, stock, purchases and cash, with CSV export
- Administration — users, custom roles, branches and registers, and an audit log nobody can edit
The stack
| Layer | Choice | Why |
|---|---|---|
| API | ASP.NET Core 10 minimal APIs | Small handlers, typed results, accurate OpenAPI for free |
| Data | EF Core + SQL Server | Transactions and row locks are the whole game in a POS |
| Front end | Angular 22 (standalone, signals) + Tailwind v4 | Fast, keyboard-first checkout screen |
| Hosting | Docker Compose (SQL Server + API + nginx) | One command on a small VPS |
| Tests | xUnit + WebApplicationFactory against a real SQL Server |
104 integration tests |
The back end is a modular monolith: Identity, Tenancy, Catalog, Inventory, Sales, Registers, Purchasing, Reports, Billing and Audit are separate folders with their own endpoints and their own SQL schema, but one process and one database. A sale writes to sales, payments, stock and audit — and in one database that's one transaction instead of a distributed saga.
Three decisions I'd make again
1. Idempotent checkout. The browser sends a key with every sale attempt; a unique index on (business, key) makes a retried Charge return the original sale instead of charging twice.
2. The stock check is the write. "Is there enough?" is a conditional, row-locked UPDATE — if no row comes back, the sale is refused. A read-then-write check lets two tills sell the last unit.
3. Read-only, not locked out. When a trial ends or a payment fails, the business can still see and export everything; it just can't change anything. Nobody loses access to their own records over a billing problem.
I wrote up the first two in detail, with code: Three bugs a point-of-sale can't afford.
What I'd tell you if you're building one
- Test against the real database. Every bug that matters in a POS lives in transactions, locks and unique indexes. An in-memory provider passes and proves nothing.
- Design for the bad Wi-Fi day, not the demo. Retries, double clicks and half-finished requests are normal.
- Make the cashier's error messages say what happened and what to do. "Only 0 of 'Basmati rice 5 kg' left in this branch. Change the quantity or remove the item; nothing was charged." beats "Error 409".
Using it
- Shops can use it as a monthly service: studioyume.pages.dev/pos
- Developers and agencies can buy the full source code (Docker setup, docs and all 104 tests): Tilly POS on Gumroad
Questions about any of it — the architecture, the checkout, the stock ledger — are very welcome in the comments.
Top comments (0)