Skip to content
All work
SaaS2026 Case study

Sprout

An omni-commerce grocery platform: one backend, five portals, two mobile apps.

Sprout
Sprout landing page
Web portals
5
Native mobile apps
2
Delivery promise
15 min

The challenge

Launching a 15-minute grocery delivery business meant coordinating customers, riders, vendors, warehouse ops and admin oversight on one reliable system — not five disconnected tools.

Our solution

We built a modular-monolith NestJS API with a transactional outbox for reliable event delivery, five role-based web portals (customer, rider, seller, warehouse, admin) and two native Flutter apps, with Stripe payments and live order tracking.

Built withNestJSPrismaPostgreSQLReactFlutterStripe

How we built it

The approach.

  1. 01

    Model the domain once, for all five roles

    An order is a single object with one lifecycle. The customer, rider, seller, warehouse operator and admin see different slices of it and can perform different transitions, but none of them has a private copy of what an order is.

  2. 02

    Choose a modular monolith deliberately

    Module boundaries are enforced in the codebase so the seams are real, but everything deploys as one unit. A business that has not launched yet gets the structure without paying the distributed-systems tax.

  3. 03

    Make events reliable with a transactional outbox

    Events are written to an outbox table inside the same database transaction as the state change that produced them, then relayed separately. The state change and its event either both happen or neither does.

  4. 04

    Build the portals as views onto shared permissions

    Five interfaces, one authorisation model. What a role can see and do is defined once in the backend rather than re-asserted in each frontend.

  5. 05

    Go native where the job is physical

    Customers and riders get Flutter apps, because ordering on the move and delivering on the move are phone jobs. The operational roles — seller, warehouse, admin — get web portals, because that work happens at a desk or a station.

Five audiences, one order

A grocery delivery business looks like one product from the outside and five from the inside.

A customer browses, orders and waits. A rider gets assigned, collects and delivers. A seller manages catalogue and stock. Warehouse staff pick and pack against a clock. An admin needs oversight of all of it, including the parts that go wrong. Each of those is a genuinely different job with a different interface — and all five are looking at the same order.

The tempting way to build this is as five products that talk to each other, usually because five teams or five phases built them. It fails in a specific and familiar way: the rider app's idea of "collected" drifts from the warehouse system's idea of "picked", the customer sees a status that stopped being true four minutes ago, and reconciling the two becomes somebody's permanent job.

So the architecture starts from the opposite premise. There is one order, with one lifecycle, in one backend. The five portals and two apps are views onto it — each showing the slice that role needs and permitting the transitions that role is allowed to make. Disagreement is not something to be reconciled, because there is only one place the answer can come from.

Why a modular monolith

Five interfaces, several distinct operational domains, real-time delivery — it reads like a microservices brief, and that is close to the default reflex for a system with this shape.

We built a modular monolith instead: the module boundaries exist and are enforced in the codebase, but the whole thing deploys as a single unit.

The reasoning is about where the difficulty should sit. Microservices trade in-process complexity for distributed complexity, and distributed complexity is not free — network partitions between your own services, transactions that no longer span a single database, deploy sequencing, five sets of logs to correlate before you can answer a question about one order. That price is worth paying when independent scaling or independent team velocity genuinely demands it.

A grocery business that has not launched yet does not have those pressures. What it does have is a need to change the domain model quickly, get a whole order flow correct end to end, and not spend its first months debugging its own infrastructure. A monolith with real internal boundaries gives exactly that: a change that crosses three modules is one commit and one deploy, and an order's entire path is one database transaction away from being consistent.

The boundaries are the part that keeps the option open. Because the modules are separated properly, a module that eventually needs to scale on its own can be extracted — a decision that can be made later with real traffic data, rather than guessed at before launch.

Events that cannot go missing

Every state change in this system needs to reach somewhere else. An order is accepted, so the warehouse needs to know. A rider collects, so the customer's tracking view needs to update. Stock moves, so the seller's catalogue needs to reflect it.

The naive implementation is to update the database and then publish an event. That contains a real bug, not a theoretical one: the two operations are not atomic. If the process dies between them — or the publish fails while the write succeeded — the order has moved on and nothing downstream ever hears about it. The customer's tracking sits still while the order is actually two steps ahead, and there is no error anywhere to alert anyone, because from the database's point of view everything worked.

A transactional outbox closes the gap. The event is written to an outbox table inside the same transaction as the state change that produced it. Either both land or neither does. A separate relay then reads the outbox and publishes, retrying until it succeeds, marking rows as it goes.

The guarantee that buys is worth stating plainly: an order can never change state without the corresponding event eventually being delivered. Delivery might be delayed if something downstream is unhealthy, but it cannot be silently lost. For a system where the customer is watching a live tracking view and the warehouse is working to a fifteen-minute clock, that difference is the difference between a delay everyone can see and a delay nobody knows about.

What the clock demands

Fifteen minutes is a strong constraint on the software, not just an operational promise. It means the gap between a customer placing an order and a picker seeing it has to be negligible. It means rider assignment cannot wait for a polling cycle. It means a status shown to a customer needs to be current rather than nearly current.

Those requirements are what the event flow is for, and they are why reliable delivery was worth building properly rather than adding later. At fifteen minutes, a lost event is not a stale dashboard — it is a missed delivery.

Where it landed

Five role-based web portals, two native Flutter apps and one backend, with Stripe handling payments and live tracking running through the same event flow as everything else. The structure is the deliverable: one domain model that all seven clients agree on, module boundaries that make extraction a later choice rather than an upfront bet, and event delivery that fails loudly instead of quietly.

Frequently asked questions

Why a modular monolith instead of microservices?

Microservices trade in-process complexity for distributed complexity — network failures between your own services, transactions that no longer span one database, deploy sequencing, and several sets of logs to correlate before you can answer a question about a single order. That is worth paying for when independent scaling or team velocity demands it. A business that has not launched yet needs to change its domain model quickly and get the order flow correct end to end, which a monolith with enforced internal boundaries delivers without the tax. The boundaries keep extraction available later, with real traffic data to justify it.

What is a transactional outbox and why use one?

It solves the dual-write problem: updating the database and then publishing an event are two separate operations, so a crash or a failed publish between them leaves the state changed and nothing downstream informed — with no error raised anywhere. An outbox writes the event into a table inside the same transaction as the state change, so both land or neither does, and a separate relay publishes from that table with retries. State changes can therefore never lose their events.

How do five portals stay consistent with each other?

They are not five systems that synchronise — they are five views onto one domain model in one backend. An order has a single lifecycle, and each role sees the slice it needs and can perform the transitions it is permitted. Authorisation is defined once in the backend rather than re-asserted in each frontend, so there is no second copy of the rules to drift.

Why Flutter for the apps and React for the portals?

It follows where the work physically happens. Ordering and delivering are phone jobs done on the move, so customers and riders get native apps from one Flutter codebase. Seller, warehouse and admin work happens at a desk or a station, where a web portal is the better interface and iteration is faster.

Let's build together

Have somethingworth building?

Tell us about it. You'll get an honest read on scope, timeline and cost within one business day — from the people who would actually do the work.

Or email us directly — info@techiesofepi.com