An online checkout stores a snapshot of the cart on the payment itself, and the order is only created—inside the same transaction that marks the payment paid—once the money has settled. An abandoned checkout leaves no order behind and decrements no stock; cash-on-delivery orders are still created up front.
Trade-off: the checkout snapshot has to carry everything an order needs.← AP EnterprisesEngineering deep-dive
This is what sits behind a store
that has to get money right.
How the platform is built, and run—continuing the story from the case study: how the code is shaped, how payments survive failure, how it ships, and where the trade-offs are.
One Go API, one Flutter app for Android and web
PostgreSQL, 43 tables, typed SQL via sqlc
GitHub Actions → Railway, Cloudflare Pages, Google Play
Separate staging and production—no shared data, keys, or apps
System shape 01
One process owns every business rule.
A single Go process serves the whole API: request handling, the payment webhook processor, the reconcilers, and the account-deletion sweeper all run inside it as goroutines, started and stopped with the server. There's no queue, cache, or separate worker to deploy, monitor, or keep in sync.
The Flutter app is a view over that API—customers, delivery staff, and admins all use the same build, with role-based screens. On a phone it's an app; in a desktop browser customers get a website layout and staff get an admin console. Prices, stock, totals, and tax are always recomputed on the server; the client never sends an amount that gets charged.
Module map 02
Dependencies point one way
Nineteen domain packages—auth, catalog, inventory, cart, coupons, orders, payments, deliveries, invoices, returns, reviews, notifications, and more—each depend only on domains below them. When a lower domain has to call “up” the graph (a payment confirming an order, a refund closing a return ticket), it declares a small interface in its own package and the server wires in the implementation at startup. No import cycles, and every cross-domain call is visible in one place.
Typed SQL, no repository layer
Every domain has the same shape: routes, handlers, a service holding the business rules, and hand-written SQL that sqlc compiles into typed Go. Services call those queries directly and wrap multi-step writes in a single transaction—stock balance and stock ledger, for example, can never diverge. Swapping the database later was costed and judged not worth pre-building an abstraction for.
Payments that assume failure 03
The app is the source of truth, not the gateway.
Every payment has a local row before Razorpay is ever called, amounts are stored in integer paise, and the provider sits behind a small gateway interface—only the adapter imports Razorpay's SDK, and a mock adapter stands in for tests. On both Android and web, the app ignores the SDK's own “success” callback and polls the API for the real outcome.
Webhooks are verified before the payload is read, de-duplicated on the provider's event ID, acknowledged immediately, and processed a few seconds later from a stored-events table. A reconciler polls the gateway every minute for anything still pending after five minutes, so a lost webhook delays a confirmation rather than losing it. Refunds have the same two backstops.
How idempotent retries are designed, more generally ↗Reliability under real money 04
Money-moving requests carry an idempotency key: a replay returns the stored response without a second gateway call, an in-flight duplicate (a double-tap) gets a 409, and the same key with a different payload is rejected as a client bug. Gateway retries are handled separately, by the unique event ID.
Trade-off: an extra table and a middleware every money-moving route must use.A webhook and a reconciliation poll can arrive for the same payment at once. Transitions lock the payment row and check a version column, and an event that isn't a valid move from the current state is logged and discarded—so a late, stale webhook can't overwrite a newer state.
Trade-off: every transition goes through the state machine, with no shortcuts.Side effects after an order commits—notifications, the GST invoice, stock deduction—are best-effort, with no outbox. A failure is logged at error level and emails an alert, but the fix is a manual step, not an automatic retry.
Trade-off: simpler code today, in exchange for a human in a rare failure case.Delivery pipeline 05
Two branches, two environments, three ways to ship.
The develop branch is staging and main is production; both auto-deploy, and a separate work-in-progress branch takes frequent pushes without firing anything. Staging and production never share a database, storage bucket, Firebase project, Razorpay key, signing secret—or even a Play Store app.
The backend ships through GitHub Actions running vet, build, and race-detector tests; Railway waits for that check to pass, then builds the distroless Docker image and replaces the running container behind a health check. The web app is built by a second workflow with each environment's settings compiled in and published to Cloudflare Pages. Android builds go to two separate Play apps, so a staging build can never be promoted to customers by mistake. Database migrations are applied by hand, staging first, before the release that needs them.
Security & operations 06
Least privilege, enforced on the server
Fifteen-minute access tokens, refresh tokens stored only as hashes and rotated on every use, and role checks on every route group. Delivery staff only see orders assigned to them—on lists and on direct lookups, where anything out of scope answers “not found” so IDs can't be probed. Uploads are size-capped and content-sniffed, and the container runs as non-root on a distroless image.
Run, not just deployed
The hosting plan has no managed backups, so a nightly job dumps production, encrypts it, and uploads it to a bucket with its own credentials—with a restore drill to prove it works. Structured logs ship to Axiom, with email monitors for any error-level log and for bursts of 5xx responses, plus a year-long encrypted archive.
Tech stack 07
Backend
Go with the chi router, pgx and sqlc over PostgreSQL, golang-jwt and bcrypt for auth, go-oidc for Google Sign-In, the AWS S3 SDK against Cloudflare R2, Razorpay's Go SDK behind a gateway interface, hand-rolled MSG91 and FCM clients, fpdf for GST invoices, and the standard library's slog for structured logging.
Frontend
Flutter for Android and web from one codebase, with Riverpod for state, go_router for navigation and role-based route guards, dio with automatic token refresh, secure on-device token storage, Razorpay's native SDK on Android and a small JavaScript bridge to its web checkout, and a shared design-system component library.
Deliberately scoped for now 08
A single backend instance; scaling out needs coordination first.
Background jobs run in-process, not on a queue.
No cache layer, added only against a measured hot path.
Migrations applied by hand, on purpose.
No OpenAPI spec—the route table is the source of truth.
Want the system behind your product? 09
Let's build it to
actually run.
Payments, order workflows, delivery pipelines, and the trade-offs that come with running real money in production—happy to talk through what your system needs.
Start a conversation ↗