Skip to content
CASE STUDY

Zurva

Unified commerce OS for African SMEs

Impact: 11 apps, 21 shared packages, one Postgres, zero duplicated domain logic — built solo, pre-launch.

TypeScript · NestJS 11 · Next.js 16 · Expo · Prisma 7 · PostgreSQL · Redis · BullMQ · Turborepo · Paystack · Stripe Connect · WhatsApp Cloud API
Zurva marketing site
Zurva merchant admin dashboard
Zurva Pulse storefront
Zurva system architecture diagram

Problem

African SMEs run their business across two surfaces that do not talk to each other: a POS / inventory app for the counter, and WhatsApp / Instagram DMs for actual sales. Existing tools force a choice — pick a POS and you lose chat-commerce; pick a bot and you lose inventory, restaurant ops, and offline reliability when the network drops. The few platforms that try to do both bundle them into one app, so a restaurateur who will never run a chatbot still gets a chatbot subscription. Payments and escrow on the continent are a separate maze: Paystack for Naira, Stripe for cards, and no shared idempotency story when both fail.

Approach

  • 01

    Two products, one substrate. Built Vault (mobile POS) and Pulse (bot + dashboard) as separately-subscribable products sharing one Postgres, one Prisma schema, and a fleet of @zurva/* packages. App-prefixed boundaries (vault-*, pulse-*, zurva-*) are ESLint-enforced so a Pulse handler cannot reach into Vault’s POS code.

  • 02

    Offline-first mobile as a discipline, not a library. Wrote a custom mutation queue with DAG-aware flush, temp-id remap for offline-created resources, full-jitter backoff, and a dead-letter sink. Paired with class-level @Idempotent() on every mutating controller so replays return the cached response with X-Idempotent-Replay: true. ESLint blocks api/** imports from screens/** as an error: every read goes through useLocalQuery, every write through useOfflineMutation.

  • 03

    Permissions, not roles. Killed every if (role === "ADMIN") branch. The only durable identity is "Owner of this tenant"; everything else is a custom role whose permission codes the merchant grants. Screens hide themselves when the user lacks the API permission the screen would call, so deep links and push notifications cannot deliver users to a denied state.

  • 04

    Spec-kit driven development. Wrote a binding constitution (39 principles) and adopted spec-kit so every non-trivial change is gated by a numbered spec with a tasks file, an analyse pass, and a Verify-Before-Implement check (code grep, spec search, doc match) before code lands.

  • 05

    Env-switched commercial model. One deploy, three revenue modes (SERVICE_FEE_ONLY, SUBSCRIPTION_ONLY, BOTH) toggled by env so per-sale-fee vs subscriptions can be A/B tested without forking the codebase. Free-tier limits, fee percentages, external base URLs, TTLs, and batch sizes all read from typed env — zero hardcoded magic values in production paths.

Outcome

  • 01

    Single-developer monorepo running 11 apps and 21 shared packages with no duplicated domain code: same payments-sdk, same permissions package, same logger, same Prisma client across Vault and Pulse.

  • 02

    Mobile app survives sustained offline operation — writes queue locally, flush respects dependencies (you can create a product offline and reference it in an order before sync), and the server idempotency interceptor makes replay-on-reconnect safe by default.

  • 03

    170+ specs shipped in 4 months with pre-commit (lint-staged) + commit-msg (commitlint) + pre-push (turbo lint + type-check) gates and zero "we’ll fix it later" debt items in the commercial-critical paths.

  • 04

    Onboarding flow correctly handles the most-asked-about edge case in African SMB software (a merchant who runs both a shop and a restaurant) via a Tenant → Branches → Modules model with per-branch hasInventory/hasRestaurant flags and module-gated routes.

Lessons

  • 01

    Role names are a trap. Every "ADMIN" / "CASHIER" / "MANAGER" string in your codebase is a future bug, because a real merchant will invent a role you did not ship with. Gate on capability codes from day one, even when you have exactly two roles. The refactor later is brutal.

  • 02

    Offline-first cannot be a library you add in month six. Either every screen imports through a queries/ boundary and every mutation is idempotent and queueable from day one, or your "offline mode" is a marketing claim. ESLint the boundary into existence; do not rely on culture.

  • 03

    Specs are not bureaucracy if you wrote the constitution that constrains them. A 39-principle constitution plus a 3-layer Verify-Before-Implement gate (code grep, spec search, doc search) catches more duplication and contradiction than any code review — especially when you are the only reviewer.