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.

Key numbers of the commerce platform
Key numbers, as published in the architecture write-up on 10 September 2026.

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.
ServiceResponsibility
authJWT, role-based access, two-factor (TOTP)
catalogProducts, variants, categories, flash sales, CMS pages. Apparel-specific surface behind a feature flag
inventoryPer-warehouse stock, transfers, low-stock alerts, Redis distributed locking. Single-warehouse mode by default
orderCart, orders, payments, multi-courier shipping, invoices, refunds, returns. The largest service
customerCRM, tiers, RFM segmentation, back-in-stock requests
marketplaceShopee and TikTok Shop sync, live-capable. Lazada connect, order pull, webhooks, stock and catalog sync are complete but proven only against fixtures
notificationNATS JetStream consumer, 14 durable consumers across 5 streams. Email is real, SMS ships stubbed
reportingSales analytics, CSV and PDF exports
supportTickets, categories, message threads
agentSales-agent commissions for a reseller network. Legacy, behind a flag, off by default
Architecture: channels, core services, admin, warehouse app, event bus and database
Figure 1. Channels on the left, the Go services in the middle, one database and one event bus. Lazada is built but has only been proven against fixtures.
Lines of code per repository
Figure 2. Where the code is: lines of tracked source per repository.

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.

Checkout sequence: one transaction, then the event and the confirmation email
Figure 3. Checkout. Steps 2 to 6 commit together; the email follows from the event.

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.

Payment flow: Curlec, bank-transfer receipt, cash on delivery
Figure 4. Three ways to pay, and the event each outcome publishes.

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.

Stock reservation states: reserved, fulfilled, released
Figure 5. One reservation, from checkout to shipped or released.

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.

Order status diagram
Figure 6. Order statuses and the moves between them. Cash-on-delivery orders skip pending.

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 statusOur status
pending_paymentpending
pending_shipmentready_to_ship
pending_confirmation · completeddelivered
cancellation_requested · cancelledcancelled
return_requestedrefunded
Marketplace sync: connect, orders in, import, map, deduct, push back
Figure 7. Marketplace sync. Shopee and TikTok Shop are live-capable; Lazada is complete in code and proven only against recorded fixtures.

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.

Return states
Figure 8. The return states and the moves between them.

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.

Agent commission flow
Figure 9. The commission path: pending, approved, paid.

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.

EventEmail the customer receives
events.order.createdOrder confirmation
events.order.payment.receivedPayment confirmation
events.order.payment.verifiedPayment verified
events.order.payment.rejectedPayment rejected
events.order.payment.refundedRefund confirmation
events.customer.back_in_stockBack in stock
events.user.password_reset_requestedPassword reset
events.support.ticket.created · repliedSupport ticket updates
Publishers, seven JetStream streams and the durable consumers
Figure 10. Seven streams and 26 durable consumers. Retention runs from 3 days (marketplace) to 30 days (orders, users, customers).

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.
Enterprise patterns in lib-common
Figure 11. The resilience and messaging patterns every service imports from the public lib-common library.

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.

Repositories21, of which 18 private
Backend services10
Front-end apps3
Shared libraries2, both public
MarketplacesShopee and TikTok Shop live-capable, Lazada fixture-proved
Database17 schemas · 132 tables · 3 views · 73 foreign keys
Production topology21 Docker containers, now retired
Last full Bruno smoke run123 requests passed, 345 of 345 assertions
Tracked sourceabout 288,000 lines across 1,204 Go and TypeScript files

Stack

LayerTechnology
BackendGo 1.24, Gin, GORM
Front endNext.js 14 App Router, TypeScript, Tailwind CSS, shadcn/ui, Zustand, Framer Motion
DatabasePostgreSQL 16, Redis 7
EventsNATS JetStream, transactional outbox, durable consumers
Search and storageMeilisearch, MinIO
Payments and shippingCurlec FPX, Parcel Daily (16 couriers), Pos Laju, SF Express
ObservabilityOpenTelemetry to Jaeger, Sentry, Zap
InfraDocker Compose, nginx