Skip to content
Case study · 02 / 15← All work

Mostrador

A commerce platform for small shops: online store, WhatsApp, point of sale and wholesale price lists on one backend. It is one of the seven products of the Vimo suite.

Role
Architecture and full build
Stack
Next.js 15, Expo / React Native, TypeScript, Go, Postgres, Rust
WhatsApp inbox
WhatsApp inboxPostgres enforces the isolation, not the code
(01) The problem

A small shop in Argentina sells through a website, WhatsApp chats and a counter, and each channel keeps its own stock and its own customer list. Mostrador puts all of them on one order, one stock and one customer. Every shop shares the same database, so one shop must never see another shop's data. The design handoff had 22 panel screens, a store, a site and a mobile app.

My roleI designed the backend so isolation and events hold by construction, and turned the handoff into eight apps in one monorepo.
(02) ResultsCounted in the code
42tables isolated per shop by one Postgres RLS function
248Go tests that run against a real Postgres, not mocks
0units oversold when 500 buyers check out 10 in stock at once
(03) Architecture

Three decisions that shaped it.

  1. 01

    Postgres enforces the isolation, not the code

    Why. Every business table has a tenant_id and forced row-level security. The API's role owns no table and cannot bypass the policies. Every query runs in a transaction that sets the shop first, so a forgotten filter returns nothing instead of another shop's rows. A test walks the Postgres catalog and fails if a new table has no policy.

    Trade-off. RLS alone does not make indexes work, so the tenant still goes in each WHERE. Foreign keys skip the policies, which needed their own test. And COPY does not work on these tables.

  2. 02

    The outbox is the queue

    Why. A domain event is written in the same transaction as the change that caused it. There is no case where an order is saved but its event is lost. A worker takes events in batches and groups them by shop, so a hundred price changes become one storefront revalidation. Editing a product in the panel updates the store within a second.

    Trade-off. Delivery is at least once, so every consumer has to be idempotent. It is also one more process to run: without it, an edit never reaches the store and it looks like a cache bug.

  3. 03

    Public search is a Rust service

    Why. Search with ILIKE took 212 ms p95 on 50,000 products and found nothing after a typo. The Rust service keeps the public catalog in memory and matches by trigrams: 1.7 ms p95, and it finds the product. Search is the one CPU-bound path in the system, and with no garbage collector the slow requests stay close to the median.

    Trade-off. A second language in the backend, and memory that grows with every catalog. If the service is down, Caddy sends the same request to the Go API: the store keeps working, slower and without typo tolerance.

(04) GalleryKeep scrolling →
WhatsApp inbox
WhatsApp inbox · Chats, payment links and internal notes in the panel. The 24-hour window decides whether a reply is free or paid.
Products
Products · One catalog and one stock for the store, WhatsApp and the counter. Price and stock edit in place.
Store editor
Store editor · Home sections are data, not code. Publishing emits an event that revalidates only that shop's pages.
Sign-up
Sign-up · The first step creates the real shop, with the texts and theme of its trade. It is the only path that runs without a tenant.
(05) What I'd do next
01Test the four real adapters (Mercado Pago, WhatsApp Cloud API, Andreani and ARCA) against their sandboxes. They pass the shared contract, but none has talked to its provider yet.
02Move the panel screens that still read prototype data, like marketing and channels, onto the API. Most of them need tables that don't exist yet.
03Build the two Expo apps that are still missing, for the till and for delivery, on the same native token package.
Next project · 03 / 15
Kabina →Press kits, booking and visit stats for DJs
Case study · 02 / 15

Mostrador

A commerce platform for small shops: online store, WhatsApp, point of sale and wholesale price lists on one backend. It is one of the seven products of the Vimo suite.

Role
Architecture and full build
Stack
Next.js 15, Expo / React Native, TypeScript, Go, Postgres, Rust
Mostrador
(01) The problem

A small shop in Argentina sells through a website, WhatsApp chats and a counter, and each channel keeps its own stock and its own customer list. Mostrador puts all of them on one order, one stock and one customer. Every shop shares the same database, so one shop must never see another shop's data. The design handoff had 22 panel screens, a store, a site and a mobile app.

My roleI designed the backend so isolation and events hold by construction, and turned the handoff into eight apps in one monorepo.
(02) Results
42tables isolated per shop by one Postgres RLS function
248Go tests that run against a real Postgres, not mocks
0units oversold when 500 buyers check out 10 in stock at once
(03) Architecture

Three decisions that shaped it.

01

Postgres enforces the isolation, not the code

Why. Every business table has a tenant_id and forced row-level security. The API's role owns no table and cannot bypass the policies. Every query runs in a transaction that sets the shop first, so a forgotten filter returns nothing instead of another shop's rows. A test walks the Postgres catalog and fails if a new table has no policy.

Trade-off. RLS alone does not make indexes work, so the tenant still goes in each WHERE. Foreign keys skip the policies, which needed their own test. And COPY does not work on these tables.

02

The outbox is the queue

Why. A domain event is written in the same transaction as the change that caused it. There is no case where an order is saved but its event is lost. A worker takes events in batches and groups them by shop, so a hundred price changes become one storefront revalidation. Editing a product in the panel updates the store within a second.

Trade-off. Delivery is at least once, so every consumer has to be idempotent. It is also one more process to run: without it, an edit never reaches the store and it looks like a cache bug.

03

Public search is a Rust service

Why. Search with ILIKE took 212 ms p95 on 50,000 products and found nothing after a typo. The Rust service keeps the public catalog in memory and matches by trigrams: 1.7 ms p95, and it finds the product. Search is the one CPU-bound path in the system, and with no garbage collector the slow requests stay close to the median.

Trade-off. A second language in the backend, and memory that grows with every catalog. If the service is down, Caddy sends the same request to the Go API: the store keeps working, slower and without typo tolerance.

(04) Gallery
WhatsApp inbox
WhatsApp inbox · Chats, payment links and internal notes in the panel. The 24-hour window decides whether a reply is free or paid.
Products
Products · One catalog and one stock for the store, WhatsApp and the counter. Price and stock edit in place.
Store editor
Store editor · Home sections are data, not code. Publishing emits an event that revalidates only that shop's pages.
Sign-up
Sign-up · The first step creates the real shop, with the texts and theme of its trade. It is the only path that runs without a tenant.
(05) What I'd do next
01Test the four real adapters (Mercado Pago, WhatsApp Cloud API, Andreani and ARCA) against their sandboxes. They pass the shared contract, but none has talked to its provider yet.
02Move the panel screens that still read prototype data, like marketing and channels, onto the API. Most of them need tables that don't exist yet.
03Build the two Expo apps that are still missing, for the till and for delivery, on the same native token package.
Next projectKabina →
Nahuel SantillánEN