Why it exists
The platform was built between 2023 and 2026 for a single Malaysian retailer that needed to sell the same stock through its own storefront and through Shopee, TikTok Shop and Lazada without three disconnected back offices or double-counted inventory. Orders had to land in one place regardless of channel, stock had to reserve correctly across all of them, and a direct fulfilment model had to work end to end before any of it could be automated further.
It ran in production on a single VPS, 21 Docker containers, for that store’s retail and marketplace operations. That deployment is retired. The code is actively maintained and runs end to end on a local stack. Niaga now points it at our own dropship store first; selling the platform to other companies is parked until that store has real sales.
What was built
Ten Go services behind a Next.js storefront and admin, one PostgreSQL database with seventeen schemas, and NATS JetStream carrying events between services.
- Three front ends on Next.js 14: the public storefront, the back-office admin (the largest repo by code and history), and a warehouse picking PWA that is honestly documented as a mock.
- Two public shared libraries: lib-common (Go: config, DB, NATS, auth middleware, transactional outbox, saga, circuit breaker, bulkhead, retry, event catalog) and lib-ui (shared React components).
- Data and infra: one PostgreSQL 16 database with 17 schemas, schema-per-service isolation. A Docker Compose stack with nginx, NATS, MinIO, Meilisearch, Jaeger and Rembg. A Bruno smoke collection per HTTP service, run before every demo.
| Service | Responsibility |
|---|---|
| auth | JWT, role-based access, two-factor (TOTP) |
| catalog | Products, variants, categories, flash sales, CMS pages. Apparel-specific surface behind a feature flag |
| inventory | Per-warehouse stock, transfers, low-stock alerts, Redis distributed locking. Single-warehouse mode by default |
| order | Cart, orders, payments, multi-courier shipping, invoices, refunds, returns. The largest service |
| customer | CRM, tiers, RFM segmentation, back-in-stock requests |
| marketplace | Shopee and TikTok Shop sync, live-capable. Lazada connect, order pull, webhooks, stock and catalog sync are complete but proven only against fixtures |
| notification | NATS JetStream consumer, 14 durable consumers across 5 streams. Email is real, SMS ships stubbed |
| reporting | Sales analytics, CSV and PDF exports |
| support | Tickets, categories, message threads |
| agent | Sales-agent commissions for a reseller network. Legacy, behind a flag, off by default |
Checkout, in one transaction
Placing an order is one database transaction: the order row, the stock reservation, the stock movement, the status history, the cleared cart and the outbox row commit together or not at all. Stock is reserved only if the warehouse still has enough — a conditional update, so two customers cannot both buy the last unit.
The confirmation email is not sent inside the transaction. A relay publishes the outbox row to NATS within about five seconds, and the notification service sends the email from that event. If the service stops between the commit and the send, the event is still waiting in the outbox.
Three ways to pay
Card and FPX payments go through Curlec. A payment is marked complete only after the gateway signature checks out, and the gateway’s own webhook reaches the same result if the customer never returns to the site. A failed payment cancels the order and releases its stock.
Bank transfers are checked by a person: the customer uploads the receipt, the payment waits for verification, and an admin verifies or rejects it. Cash-on-delivery orders start confirmed. Each outcome publishes its own payment event, and the customer receives the matching email.
Stock: held at checkout, taken at shipping
Paying does not move stock. Checkout holds it (reserved goes up), shipping takes it (quantity and reserved both go down, and a sale movement is written), and a cancellation, a failed payment or an order left unpaid for 24 hours gives it back. An hourly job cancels those unpaid orders.
Every change is a row in the stock-movement ledger, so the number on the shelf can always be explained: receive, reserve, sale, marketplace sale, adjustment, transfer in, transfer out, return and damage.
Order statuses
The statuses an order moves through, and the moves the validator allows. Pre-orders take a production detour; a printed shipping label moves an order straight to ready to ship; delivered and cancelled are final.
Marketplace orders become ordinary orders
A shop is connected once through the platform’s OAuth consent, and its tokens are refreshed half an hour before they expire. Orders then arrive three ways: a platform webhook, a scheduled pull every 15 minutes (off by default), or a manual sync from the admin.
Each order is stored as the platform sent it, its status is mapped to ours, and it is written through the order service as an ordinary order row — the anti-corruption layer that lets the rest of the platform ignore where a sale came from. Stock is deducted in the inventory service and, when automatic sync is switched on, the stock-changed event pushes the new level back to Shopee and Lazada.
| Platform status | Our status |
|---|---|
| pending_payment | pending |
| pending_shipment | ready_to_ship |
| pending_confirmation · completed | delivered |
| cancellation_requested · cancelled | cancelled |
| return_requested | refunded |
Returns
A customer can ask for a return within 14 days of delivery. An admin approves or rejects it, the customer ships the item back, receiving it restocks the warehouse, and the return ends as a refund or an exchange. A refund goes back through Curlec when the order was paid there, and is recorded as a manual refund otherwise.
Sales agents (off by default)
Built for a reseller network and switched off for phase one. When it is on, an order placed by a signed-in agent earns a commission once payment succeeds: base price × quantity × the agent’s rate for that product category, summed over the order. The commission is approved automatically when the courier reports the parcel delivered, and marked paid when a payout is created.
Events and emails
Services never call the notification service directly. Each one writes its events to an outbox table, a relay publishes them to NATS JetStream, and 26 durable consumers across four services pick them up. A consumer records every event it has processed, so a redelivered event is not handled twice.
| Event | Email the customer receives |
|---|---|
| events.order.created | Order confirmation |
| events.order.payment.received | Payment confirmation |
| events.order.payment.verified | Payment verified |
| events.order.payment.rejected | Payment rejected |
| events.order.payment.refunded | Refund confirmation |
| events.customer.back_in_stock | Back in stock |
| events.user.password_reset_requested | Password reset |
| events.support.ticket.created · replied | Support ticket updates |
Engineering decisions
The choices that shaped the system, with the trade-off each one made.
- Multi-repo with a workspace root. Nineteen independently versioned repos plus a root that holds only the repo map, the dev guide and cross-cutting docs, so each service and front end keeps its own history and PR flow.
- Schema-per-service in one Postgres instance. Seventeen schemas in one database instead of one database per service. Service isolation was traded for single-VPS operability.
- Event-driven sync with a transactional outbox, hardened after a real incident. The order and notification services each defined the order-status event payload independently and drifted in five fields at once, silently: no error, no retry, no dead letter, and a shipped order sent the customer nothing. The fix moved the payload shape into one shared type in lib-common.
- Shared inventory across channels, with a single-warehouse default for phase one and the multi-warehouse transfer API kept but treated as legacy.
- An honest rebrand. Renaming the brand was nearly finished, but the domain model underneath still carried 8 apparel-specific tables, 22 columns and roughly 300 files touching tailoring, fabric and measurement concepts. Whether to generalise further was left open, with the real cost written down first.
- An AI operating layer for the workspace itself. Per-repo Claude Code memory, rules and guard hooks, including one that blocks bulk edits to migration files, so an autonomous coding session can pick up bounded units safely across 19 repos.
- A Bruno smoke suite as the gate. One collection per HTTP service, run before every demo, and the thing that tells a dead process apart from a genuinely broken endpoint.
What is deliberately not claimed
Wholesale tiered pricing, multi-outlet stock and push notifications are not built. The shipped and delivered emails have templates but are not yet triggered by the admin or courier updates. SMS is stubbed. The warehouse PWA is a mock. Lazada is complete in code but has never spoken to a live seller account. The sales-agent system is legacy and off by default. The production VPS is retired.
Phase one prioritises a single fulfilment warehouse. Automated marketplace synchronisation is off by default, and the first operating model uses our storefront, TikTok Shop and Shopee with manual listing first. Selling the platform as a white-label service is parked until the store sells.
By the numbers
As published in the architecture write-up on 10 September 2026.
| Repositories | 21, of which 18 private |
| Backend services | 10 |
| Front-end apps | 3 |
| Shared libraries | 2, both public |
| Marketplaces | Shopee and TikTok Shop live-capable, Lazada fixture-proved |
| Database | 17 schemas · 132 tables · 3 views · 73 foreign keys |
| Production topology | 21 Docker containers, now retired |
| Last full Bruno smoke run | 123 requests passed, 345 of 345 assertions |
| Tracked source | about 288,000 lines across 1,204 Go and TypeScript files |
Stack
| Layer | Technology |
|---|---|
| Backend | Go 1.24, Gin, GORM |
| Front end | Next.js 14 App Router, TypeScript, Tailwind CSS, shadcn/ui, Zustand, Framer Motion |
| Database | PostgreSQL 16, Redis 7 |
| Events | NATS JetStream, transactional outbox, durable consumers |
| Search and storage | Meilisearch, MinIO |
| Payments and shipping | Curlec FPX, Parcel Daily (16 couriers), Pos Laju, SF Express |
| Observability | OpenTelemetry to Jaeger, Sentry, Zap |
| Infra | Docker Compose, nginx |