Back to portfolio

Multi-gateway logistics & payments platform

Backend systems work on a NestJS/MongoDB e-commerce and logistics platform serving domestic orders in one market and cross-border express orders into another — with a registry-pattern integration layer for six payment gateways and multiple logistics providers.

Backend / systems design NestJS MongoDB Payments infrastructure Logistics integrations
The production codebase is private (client work). This case study covers architecture, design decisions, and problem-solving — described generically, without proprietary business logic, credentials, or company-identifying details.
Overview

What the platform does

The system connects products, currencies, logistics providers, and payment gateways through a network of regional "hubs." Orders are routed as either domestic or cross-border express based on the destination hub's location, and each path resolves independently to a set of pluggable providers rather than hardcoded integrations. My work centered on the backend: order orchestration, the payment and logistics integration layers, and the async workers (webhooks and cron jobs) that keep everything consistent under real-world failure conditions — retried webhooks, abandoned checkouts, and provider outages.

NestJSMongoDB / MongoosePayment gateway registry (6 providers) Logistics provider registryCron-based reconciliationWebhook idempotency
System design

Architecture overview

Client requests flow through an order orchestration layer that branches by order kind, resolves to a regional hub, and fans out to two independently pluggable registries — one for logistics providers, one for payment gateways — before settling asynchronously through webhooks and scheduled jobs.

Diagram of the platform architecture: client apps to order orchestration, routed through a hub to logistics and payment gateway registries, settling via webhooks and cron jobs.
Problem

New payment and logistics providers were being added regularly, and hardcoding each one against the order flow made every addition riskier than the last.

Decision

Both payments and logistics sit behind a shared interface (a "registry" pattern), so a new provider is an implementation, not a rewrite of the order flow.

Trade-off

Providers that don't cleanly fit the shared interface (a cross-border courier with a different shipment model) are kept deliberately outside the registry rather than forced into a leaky abstraction.

Problem solved

Voucher reservation lifecycle

Vouchers were originally redeemed synchronously at order creation, before payment was confirmed — meaning an abandoned or failed checkout still burned the discount. I redesigned it as a reserve-then-confirm-or-release lifecycle, with a scheduled job to reclaim anything left stuck in a pending state.

Diagram of the voucher reservation lifecycle: order creation reserves a voucher as pending, a payment webhook confirms or releases it, and an hourly cron releases reservations that expired unconfirmed.
Problem

A voucher was marked fully redeemed the moment an order was created — before payment ever confirmed — so failed or abandoned checkouts still consumed it.

Decision

Introduced a reservation state machine: reserve on order creation, confirm on successful payment webhook, release on failure — plus an hourly cron releasing anything abandoned past a 24-hour window.

Trade-off

Adds a cleanup job and an extra state to reason about, in exchange for vouchers never being lost to checkouts that were never completed.

Problem solved

Webhook idempotency

Payment providers retry webhook delivery, and a naive read-then-write handler can process the same successful payment twice. The fix is a single atomic database operation rather than a read followed by a write.

Diagram of the webhook idempotency pattern: a webhook triggers a single atomic conditional update filtered on pending status, which either fulfills the order once or is skipped as a duplicate delivery.
Problem

A read-then-write webhook handler has a race window — two retried deliveries can both read "not yet processed" and both act.

Decision

Replaced the read-then-write with one atomic conditional update (find a record with status pending, set it to confirmed) — the database itself guarantees only one caller wins.

Trade-off

Requires modeling every webhook-driven transition as a conditional state change up front, rather than free-form logic in the handler — more structure, but it's what makes the guarantee hold.