Skip to content
Engineering cases
In development2026FullstackSaaS

SIGEDReino

A multi-tenant SaaS ecosystem replacing eight disconnected tools with a single system

A management platform for organisations with distributed branches: people, memberships, treasury, events, and communication in one domain. I designed the full architecture — hexagonal backend, polyglot persistence, three-layer access control — and the three client applications that consume it.

My role
Software architect and lead developer
Client
Own project
20
Domain modules
3
Client applications

web admin, public portal, and mobile

3
Languages in lockstep

es · en · pt, verified by test

6
Automated boundary rules

they fail the build, not the code review

Context#

Organisations with several branches end up running on a collection of tools that do not talk to each other: one spreadsheet for people, another for accounts, a WhatsApp group to coordinate, and the memory of whoever has been there longest for everything else.

The problem is not a lack of software. It is that every piece of data lives somewhere different and nobody knows which one is right. When someone asks how many people are active, or how much came in last month, the answer depends on who you ask.

SIGEDReino exists so there is a single source of truth, with this domain's particularities: several independent organisations on the same platform, internal hierarchies that determine who sees what, and money involved, which does not tolerate approximations.

Constraints#

  • Multi-tenant from day one. Not a future feature: each organisation must be incapable of seeing another's data, and that shapes both the data model and the security model from the first line.
  • Three clients, one domain. Admin panel, public portal, and mobile app. Maintaining three definitions of the same concept guaranteed they would diverge.
  • Small team. Decisions requiring continuous operations — databases to administer, infrastructure to watch — are paid for in time not spent on product.
  • A domain still being understood. Several modules changed shape during construction, so the architecture had to tolerate the business changing its mind.

Architecture#

A monorepo with pnpm and Turborepo. A NestJS backend with hexagonal architecture per module, three client applications, and shared packages carrying the contract and the domain primitives.

Every backend module has the same structure, held together by a single rule: domain/ imports nothing, application/ imports domain/, infrastructure/ imports both. Never the other way round.

Execution#

Twenty domain modules — people, memberships, treasury, governance, events, notifications, billing, among others — each with its own entities, use cases, and error codes.

Use cases return a Result rather than throwing. A broken business rule is not a program error, it is a possible outcome; treating it as an exception forces you to wrap half the codebase in try/catch just to tell "the expense was not pending" apart from "the database went down".

All three clients consume the same api-contracts, so renaming a field breaks their builds in the same commit. With one trap that took a while to find: the backend externalises that package when bundling, so it can import its types but not its values at runtime. The enums it needs live duplicated in its domain, with a test that fails if the two copies drift.

The interface comes from an in-house design system: one for web on Radix and Tailwind, another native on NativeWind. They share no components — they cannot — but they do share tokens, type scale, and variant names.

Outcome#

The system is in active development, with the identity, people, membership, and treasury modules running in a test environment.

What can be stated already is structural: twenty modules with tool-enforced boundaries, three applications sharing a domain without duplicating it, and three languages whose parity is validated by a test on every run. Usage figures will come when the system reaches production, and this case will be updated then with real data rather than projections.

System architecture

Architecture decisions

  1. Polyglot persistence instead of a single database

    What I chose
    Firestore for identity and people, PostgreSQL for treasury
    What I discarded
    • PostgreSQL only
    • Firestore only
    Why
    The two modules have opposite access patterns. A person's profile is a variable-shaped document read by identifier whose schema changes every few months; a treasury movement demands ACID transactions, referential integrity, and exact aggregation. Forcing treasury into documents means reimplementing by hand what a relational engine has spent forty years solving, and every bug there is money counted wrong. Firestore also brings security rules at the edge, which in multi-tenant is the difference between trusting that no endpoint forgets to filter and making it impossible to read another organisation's data.
    What I gave up
    Two mental models, two backup strategies, and no transaction spanning both: synchronisation goes through domain events with eventual consistency, and you must decide explicitly where that is tolerable.
  2. Three-layer access control instead of flat roles

    What I chose
    Tenant capability + rank + functional profile, composable and evaluated in that order
    What I discarded
    • Flat per-user roles
    • Granular per-resource permissions
    Why
    Flat roles cannot separate three questions that are distinct in this domain: whether the organisation has the module, whether the person has enough authority, and whether they hold that specific function. Merging them into one field forces you to invent compound roles that multiply with every new combination. Granular per-resource permissions handled the case but produced a matrix nobody on the business side could audit.
    What I gave up
    Three checks per endpoint instead of one, and an access-control document that must be kept current: adding a module means explicitly deciding which layers apply, rather than falling back to a default.
  3. Cross-module communication only through domain events

    What I chose
    No module imports another's internals; they communicate via events and handlers
    What I discarded
    • Direct service injection between modules
    • A shared module holding common logic
    Why
    With twenty modules, allowing direct imports turns the backend into a graph nobody can reason about six months later: changing treasury means reading memberships. Events leave the emitter unaware of its consumers, and dependency-cruiser rejects direct imports with error severity, so the boundary does not depend on anyone remembering it.
    What I gave up
    Following a full flow means jumping between emitter and handler in separate files, and eventual consistency appears where a synchronous call used to be. Debugging costs more; understanding the whole system, far less.

Stack by layer

Backend
NestJSTypeScriptHexagonal architectureDDDDomain eventsJSend
Data
PostgreSQLPrismaFirestoreCloudflare R2
Frontend
React 19Next.jsViteTanStack QueryZustandTailwindRadix UI
Mobile
React NativeExpoNativeWind
Infrastructure
Firebase AuthDockerGitHub Actions
Tooling
pnpmTurborepodependency-cruiserVitestJestStorybook

What I would do differently today

I would start with access control, not with the business modules. I built it once several modules were already running, and retrofitting them cost more than designing it first: every existing endpoint had to be reviewed one by one to decide which layers applied. I would also automate the boundary rules sooner — for the first few weeks they were an agreement, and in that window cross-imports slipped in that later had to be undone. The lesson repeats: a rule that does not break the build does not exist.

Got a system to build?

Tell me what you need to solve. You get an honest assessment — if I am not the right person, I will say so.

Let's talk about your system