navree
Documentation

Choosing an architecture

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.

On this page

Introduction

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.

The map

Modular monolith

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 monolithClients send HTTP requests to one binary. Inside it the DI container holds every module and checks the graph when the app boots. HttpModule routes each request to a feature module: CompaniesModule, DealsModule or ReportsModule. DealsModule imports CompaniesModule and ReportsModule imports DealsModule, so each uses only what the other exports. The feature modules reach the one PostgreSQL database through DatabaseModule, in one transaction per request.One binaryDI containerClientsweb · mobile · agentsHttpModulerouter · middlewareCompaniesModuleexports CompanyServiceDealsModuleexports DealServiceReportsModuleexports nothingDatabaseModulepool · unit of workPostgreSQLone databaseHTTPone transaction
A modular monolith
  • request
  • imports

Modular monolith: boundaries, layout, one database with tables per module, enforcing boundaries in tests.

Microservices

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.

MicroservicesA web app calls crm-api, the HTTP gateway. crm-api asks the deals service for a deal over gRPC (deals.get). When a deal is won, the deals service emits deal.won to a broker (NATS, Kafka, RabbitMQ or Redis Streams), which delivers it to the notifications and activities services. Each service owns its database. One OpenTelemetry trace follows the request through crm-api, deals, notifications and activities.One trace · OpenTelemetryWeb appbrowsercrm-apiHTTP gatewaydealsown databaseBrokerNATS · Kafka · RabbitMQ · Redisnotificationsown databaseactivitiesown databaseHTTPSgRPC · deals.getemit deal.wondeal.won
Microservices
  • request-response
  • event

Microservices: when to split, contracts, request-response or events, data and consistency, tracing.

Multi-tenant SaaS

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 workspaceA request names its workspace, for example with the header X-Workspace: acme. Navree resolves the workspace from the header, the path, the subdomain or a token claim, and answers 400 or 404 when it can't. It then checks that the user is a member, and answers 403 to anyone else; the member's role adds its permissions. The request's unit of work is scoped to the workspace by setting navree.workspace on its transaction. The configured isolation decides where the data lives: rows with a workspace_id column guarded by PostgreSQL row-level security, a schema per workspace, or a database per workspace.RequestX-Workspace: acmeResolve the workspace400 or 404 if it can'tCheck membership403 for anyone elseScoped unit of worksets navree.workspaceRowrow-level securitySchemaone per workspaceDatabaseone per workspaceworkspace.isolation
Multi-tenancy by workspace
  • request

Multi-tenant SaaS: resolving the workspace, scoping queries, isolation strategies, per-workspace roles and billing.

Event-driven

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 outboxA request moves a deal to won. DealService emits DealWon. A sync listener runs at once, inside the same transaction. Everything else is written into the request's unit of work: the deal row, a jobs row for each queued listener and a micro_outbox row for other services, all committed in one transaction. After the commit, a worker runs the queued listener with retries, the deal.updated broadcast reaches browsers over the WebSocket server, and deal.won is published to other services through the broker. A rollback sends nothing.Requestdeal → wonDealServiceemits DealWonSync listenersame transactionUnit of workone transactiondealschangejobslistenersmicro_outboxeventsQueued listenerqueue:work · retriesBroadcastbrowsers, liveOther servicesdeal.won · brokeremit
Event-driven, with an outbox
  • in the request
  • after the commit

Event-driven: events, sync and queued listeners, the outbox, broadcasting, idempotency.

API-first

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-firstValidated DTOs (garde rules) feed versioned controllers under /api/v1. Navree generates an OpenAPI 3.1 document from both when the app boots and serves it at /api/openapi.json. openapi:export writes the document to a file, and typed clients are generated from it: the web app in TypeScript, mobile apps in Swift or Kotlin, partners and agents in any language. Validation failures (400) and refusals from controllers (401, 403, 404) share one JSON error shape: statusCode, message and error.Validated DTOsgarde rulesControllersversioned: /api/v1OpenAPI 3.1built at bootOne error shapestatusCode, message, errorWeb appTypeScriptMobile appsSwift · KotlinPartners, agentsany languageinput#[api]openapi:export
API-first
  • generated from

API-first: resources, DTOs, errors, versioning, pagination, OpenAPI and contract tests.

Backend for frontend

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 frontendThe browser calls only its own web server, on the same origin, and holds cookies it can't read. The Next.js BFF keeps the access token (navree_at) and the refresh token (navree_rt) in httpOnly cookies and calls the Navree API with Authorization: Bearer. When the access token expires it refreshes once per refresh token, and Navree rotates the refresh token on every use. The Navree API reads and writes PostgreSQL in one transaction per request, and after a change it broadcasts to its WebSocket server, which pushes the event straight to the browser. The socket carries no token; private channels are authorized through the BFF.Browsercookies it can't readNext.js BFFhttpOnly cookiesnavree_ataccessnavree_rtrefreshNavree APIHTTP and WebSocketPostgreSQLusers · token familiessame originBearerrefresh,rotatesone transaction
Backend for frontend
  • request
  • real-time event

Backend for frontend: sign-in, refresh rotation, CSRF, client addresses, real time through the BFF.

A decision guide

Start with a modular monolith that is API-first. Then let these questions decide what to add.

Team size

  • 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.

Deploy cadence

  • Everything ships on one cadence: one binary.
  • One feature ships far more often than the rest, and coordinated releases slow both down: split that feature into a service.

Customers and data isolation

  • One organization: no workspaces.
  • Many organizations sharing the product: workspaces with row isolation.
  • Customers who require separate schemas or databases, or data residency: workspaces with schema or database isolation.
  • A feature whose failures or data must be kept from the rest of the system (payments, untrusted code): a service for that feature.

Scale

  • 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.

Clients

  • Your own web app: a BFF, so no token reaches the browser.
  • Mobile apps, partners, other teams: API-first, with a versioned, exported OpenAPI document and generated clients.
  • Third-party apps acting for your users: the OAuth server.

What follows a change

  • 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.

Moving between shapes

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.

FromToWhat changesWhat stays
Directory per moduleCrate per moduleEach module's directory becomes a crate; the root depends on themEvery line of module code; the module graph
A call to an exported serviceA message to a serviceself.deals.show(..) becomes self.client.send("deals.get", ..); the module gets a #[message_handlers] structThe service, its rules, its access checks
An in-process eventAn event between servicesThe emitting module adds m.forward_event::<DealWon>(); listeners become #[event_pattern] handlersThe event type, its name, the emitting code
A sync listenerA queued listenerIts queued method returns trueIts code; the emitter
Single-tenantMulti-tenantWorkspaceModule in the root, a workspace_id column on tenant tables, the InWorkspace guard on tenant controllers, WorkspaceRepo in place of RepoRoutes, access rules, the rest of the service code
Row isolationSchema or database isolationworkspace.isolation, tenant tables registered with m.workspace_migrations, and a data migrationHandlers written with WorkspaceUow and WorkspaceRepo
A web app holding tokensA BFFThe front end's server and its cookiesThe API: it serves bearer tokens to every client
A serviceBack into the monolithIts module is added to the monolith's root; messages become calls againIts 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.