All work
TAMulti-Tenant SaaS · DistributionEnterprise SaaSCase study

Technaut Aqua Track — Water Distribution SaaS

Runs a water delivery business end to end — a morning refill round becomes one picked list, and every customer still keeps their own price, invoice and balance.

Multi-tenant SaaS for water supply and distribution businesses: orders, deliveries, subscriptions, containers, inventory, invoicing and collections, with one NestJS API serving a Next.js back-office and two Flutter apps.

Source private
Technaut Aqua Track — Water Distribution SaaS interface
Industry
Water Supply & Distribution
Role
Architect & Full-Stack Engineer
Type
SaaS Platform
Year
2026
Status
Delivered
Web + 2 apps
Surfaces
Query-level
Tenant Isolation
One shared schema
Contract

Overview

Aqua Track is a multi-tenant platform for water supply businesses — the kind that run daily refill rounds across households, shops and institutions. Nothing in the product assumes a "20 litre can": products are configurable entities with capacity and unit, sale/rental/returnable/deposit/service behaviour, SKU, barcode, pricing, tax and tenant-defined custom fields.

The design decision that shapes the whole product is bulk ordering. A morning round is picked as a list of customer names, and one request creates a separate order for each — never one merged document. Every customer keeps their own price list, address, delivery, invoice and running balance, and because each order is created independently, one blocked customer does not take the rest of the batch down.

Tenant isolation is enforced in the data layer rather than by convention. A request flows through the auth guard into an AsyncLocalStorage request context, and a Prisma extension injects the tenant filter into every query and refuses to run without a tenant context — so a service that forgets its where clause still cannot read another tenant's rows. An end-to-end suite seeds two tenants and asserts no endpoint can reach across the boundary.

A shared package is the single source of truth for every request and response shape: the API validates with it, the web app infers its types from it, and the Dart models mirror it — with a parity test that fails the build if the database enums and the shared enums ever drift apart.

The problem

Water distributors run daily refill rounds across dozens of customers, but the software available either models a single fixed product or forces a round into one merged order. Per-customer pricing, deposits on returnable containers and running balances then have to be reconstructed by hand, and one problem customer can block an entire morning.

Discovery

Followed a real round end to end and found two assumptions that break every generic tool: the product catalogue is not fixed, and a round is a batch of independent orders that only looks like one document to the person picking it.

The solution

Built a multi-tenant NestJS platform where products are fully configurable entities and a bulk order creates one independent order per customer. Tenant isolation is enforced by a Prisma extension in the data layer, and a shared Zod package is the single contract for the API, the Next.js back-office and both Flutter apps.

Technical stack

Frontend

  • Next.js 16
  • React 19
  • TypeScript
  • Tailwind CSS 4
  • TanStack Query
  • Flutter

Backend

  • NestJS 11

Data

  • MySQL 8

Infrastructure

  • Docker

Also used

  • Prisma 7
  • Drift
  • Zod

System architecture

  1. 1

    NestJS 11 + Prisma 7 over MySQL 8, with Zod-validated config and Swagger docs

  2. 2

    Request → auth guard → AsyncLocalStorage tenant context → TenantPrismaService, whose Prisma extension injects the tenant filter into every query

  3. 3

    Next.js 16 App Router back-office with TanStack Query/Table and a shared design-system package

  4. 4

    Flutter admin and delivery apps over a shared aqua_core package — models, API client, auth, theme, Drift cache and sync outbox

  5. 5

    pnpm + Turborepo for TypeScript, a Dart pub workspace for mobile; enum-parity test fails the build on schema drift

  6. 6

    Cloudflare R2 with presigned uploads for files; Docker Compose and PM2 deployment paths both supported

Engineering challenges

Challenge

Application-level tenant scoping relies on every query remembering its where clause — one forgotten filter is a cross-tenant data leak.

How I solved it

Moved the filter below the services: a Prisma client extension injects tenant scoping into every query and refuses to run without a tenant context, and an e2e suite seeds two tenants and asserts no endpoint can cross the boundary. A separate unscoped client exists only for platform tables and is deliberately not injectable into feature modules.

Challenge

Raising a round as one order would have collapsed per-customer pricing, invoices and balances — but raising them one at a time is unusable at the counter.

How I solved it

Kept the batch in the UI and split it in the API: one bulk endpoint creates a separate, independently-validated order per customer, so a single blocked customer fails alone while the rest of the round goes through.

Challenge

Three clients in two languages meant request and response shapes drifted, and enum mismatches only surfaced at runtime on a device.

How I solved it

Made a shared Zod package the single source of truth — the API validates with it and the web app infers its types from it — and added a parity spec that fails the build if the database enums and the shared enums diverge.

Core features

  • Multi-tenant with tenant isolation enforced by a Prisma extension, not convention
  • Bulk ordering — one picked list creates a separate order per customer
  • Configurable products: capacity, unit, returnable/deposit behaviour, custom fields
  • Per-customer price lists, outstanding balances and deposit tracking
  • Deliveries, routes, trips and vehicle assignment
  • Subscriptions and container/returnable-asset tracking
  • Inventory across warehouses with transfers and adjustments
  • Purchasing — suppliers and purchase orders
  • Invoicing, payments and collections reconciled on delivery
  • Role and permission matrix with branch scoping
  • Two Flutter apps — owner/manager admin and a driver delivery app
  • Swagger-documented API shared unchanged by web and mobile

Results & impact

A distributor runs the whole operation from one system: catalogue, customers, rounds, deliveries, returnable containers, invoices and collections. Because the API is the only contract, the web back-office and both Flutter apps stay in step, and adding a tenant is provisioning rather than a deployment.

Screenshots

Technaut Aqua Track — Water Distribution SaaS screenshot 1
Technaut Aqua Track — Water Distribution SaaS screenshot 2
Technaut Aqua Track — Water Distribution SaaS screenshot 3
Technaut Aqua Track — Water Distribution SaaS screenshot 4
Technaut Aqua Track — Water Distribution SaaS screenshot 5
Technaut Aqua Track — Water Distribution SaaS screenshot 6
Technaut Aqua Track — Water Distribution SaaS screenshot 7
Technaut Aqua Track — Water Distribution SaaS screenshot 8

What I learned

  • Enforcing multi-tenancy in the data layer, not in services, converts a whole class of leaks into an impossible state.
  • A batch action in the UI does not have to be a batch document in the database — and usually should not be.
  • One typed contract with a build-breaking parity check is cheaper than debugging enum drift on a phone.

Related work

All projects
Next step

Have a system like this to build?

I take projects from the first conversation through to production, and stay on for the support that follows.