The shapes of system Navree is built for, how to pick one by team size, deploy cadence, isolation and scale, and how an app moves between them without a rewrite.
Navree builds every shape of backend from the same unit: the module. A module owns one feature's services, routes, tables, jobs and events, declares what it imports and exports, and is checked by the kernel when the app boots. Arrange modules differently and you get a different architecture:
one binary with many modules is a modular monolith;
modules in separate binaries, talking over a broker, are microservices;
modules whose data is scoped to the current workspace make a multi-tenant SaaS;
modules that announce changes and react to each other's events are event-driven;
modules whose routes, inputs, outputs and errors form a documented contract are API-first;
a web server that holds the tokens and calls those routes is a backend for frontend.
These shapes are not exclusive. A typical Navree product is all six at once: a modular monolith, API-first, multi-tenant by workspace, event-driven inside, with a BFF for its web app and one or two services split out where the load demands it. The reference CRM in apps/crm-api and apps/crm-web is a modular monolith, API-first and event-driven, with a BFF.
One deployable binary, divided into modules with explicit boundaries. Calls between features are function calls, a request that touches three features commits in one transaction, and the kernel refuses to boot when a module uses something another module didn't export. It is where every Navree app starts.
A modular monolith
request
imports
Modular monolith: boundaries, layout, one database with tables per module, enforcing boundaries in tests.
Some modules run as services of their own, deployed and scaled independently, talking through request-response messages and events over NATS, Kafka, RabbitMQ, Redis Streams, MQTT, TCP or gRPC. A message handler is written like a route, so a module moves into a service without being rewritten.
Microservices
request-response
event
Microservices: when to split, contracts, request-response or events, data and consistency, tracing.
Every customer gets a workspace with members and roles, and every request and query is scoped to the current one. Isolate workspaces by row, by PostgreSQL schema or by database, chosen in configuration.
Multi-tenancy by workspace
request
Multi-tenant SaaS: resolving the workspace, scoping queries, isolation strategies, per-workspace roles and billing.
Modules announce what happened; listeners react, in the request or on a worker. Work that must follow a change is stored in the same transaction as the change and leaves only after the commit, then reaches workers, browsers and other services.
Event-driven, with an outbox
in the request
after the commit
Event-driven: events, sync and queued listeners, the outbox, broadcasting, idempotency.
The API is the product: versioned routes, validated inputs, typed resources, one JSON error shape and an OpenAPI 3.1 document generated from the code, with typed clients generated from the document.
API-first
generated from
API-first: resources, DTOs, errors, versioning, pagination, OpenAPI and contract tests.
The browser never sees a token. A web server on the same origin keeps the access and refresh tokens in httpOnly cookies, rotates them and calls the API with a bearer token.
Backend for frontend
request
real-time event
Backend for frontend: sign-in, refresh rotation, CSRF, client addresses, real time through the BFF.
One team, up to about fifteen engineers: a modular monolith with a directory per module.
Several teams, each owning features: a modular monolith with a crate per module. Cargo enforces the boundaries, and each team compiles and tests its crates on its own.
Independent teams with their own on-call and release process: split the modules those teams own into services.
Load grows evenly across features: more copies of the same binary. Web processes behind a load balancer, more queue:work workers.
One feature's load is unlike the rest (bulk imports, fan-out, heavy computation): queue that work first, and split it into a service if it still needs to scale on its own.
Side effects (mail, notifications, calls to other systems): queued listeners, so they run after the commit and retry.
Screens that must update live: broadcasting after the commit.
Other services that must hear about it: events forwarded to the broker through the outbox.
Two defaults hold for almost every app:
Scale with processes before services. One Navree binary runs as serve, queue:work and schedule:work, each scaled on its own. That covers most growth without a network between your features.
Events before services. A module that reacts to another module's events instead of calling it is already decoupled. When it becomes a service, its listeners become event handlers and little else changes.
An architecture is a decision you can revisit, because the unit doesn't change: a module stays a module whether it runs in the monolith, in a service, scoped to a workspace or behind a BFF. Its providers, its rules, its migrations and its tests come along. What changes is the wiring around it.
From
To
What changes
What stays
Directory per module
Crate per module
Each module's directory becomes a crate; the root depends on them
Every line of module code; the module graph
A call to an exported service
A message to a service
self.deals.show(..) becomes self.client.send("deals.get", ..); the module gets a #[message_handlers] struct
The service, its rules, its access checks
An in-process event
An event between services
The emitting module adds m.forward_event::<DealWon>(); listeners become #[event_pattern] handlers
The event type, its name, the emitting code
A sync listener
A queued listener
Its queued method returns true
Its code; the emitter
Single-tenant
Multi-tenant
WorkspaceModule in the root, a workspace_id column on tenant tables, the InWorkspace guard on tenant controllers, WorkspaceRepo in place of Repo
Routes, access rules, the rest of the service code
Row isolation
Schema or database isolation
workspace.isolation, tenant tables registered with m.workspace_migrations, and a data migration
Handlers written with WorkspaceUow and WorkspaceRepo
A web app holding tokens
A BFF
The front end's server and its cookies
The API: it serves bearer tokens to every client
A service
Back into the monolith
Its module is added to the monolith's root; messages become calls again
Its module code
Keep the option open from the first day:
Talk across modules through exports and events, never through another module's private types or tables.
Keep payloads and event types small and serializable, so the same type can travel on the bus, the queue and the broker.
Run check in CI, so a crossed boundary fails before review.
Test each module on its own, booting only what it imports.
A codebase that follows these four rules can take any of the shapes on this page with a change of wiring, and can change its mind later.