navree

Architecture

One framework,every architecture.

Navree builds every shape of backend from the same unit: the module. Arrange modules one way and you have a modular monolith; another way, and you have services, a multi-tenant SaaS or an event-driven system. Start as one binary and change shape later without rewriting a module.

01 Modular monolith

One binary, hard boundaries.

Every feature is a module that declares what it imports, provides and exports. The app ships as one binary with one database, calls between features are function calls, and the kernel refuses to boot when a module reaches for something it wasn't given.

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
src/modules/deals/mod.rs

Choose it when

  • You're starting. Every Navree app begins here.
  • One team, or several teams on one release.
  • A request touches three features and should commit once.

How Navree supports it

  • #[module] with imports, providers, controllers and exports
  • A type-keyed DI container, checked at boot and by check in CI
  • navree make:module writes a module and registers it
  • A crate per module when teams want Cargo to hold the lines

02 Microservices

Split out a service. Keep the module.

A module that needs its own deploys, scaling or on-call moves into a binary of its own. Services answer messages and react to events over NATS, Kafka, RabbitMQ, Redis Streams, MQTT, TCP or gRPC, each owns its database, and a message handler is written like a route.

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
services/deals/src/handlers.rs

Choose it when

  • One feature scales or ships unlike the rest.
  • A feature's failures or data must be kept apart.
  • A team owns a feature end to end, on-call included.

How Navree supports it

  • MicroModule, with the transport in configuration
  • #[message_pattern] and #[event_pattern] handlers with guards, #[can] and a unit of work
  • ServiceClient::send and emit, with an outbox for events
  • Request id, caller, workspace and trace context on every message

03 Multi-tenant SaaS

Every customer gets a workspace.

Workspaces, members, roles and invitations are part of the framework and on by default. Each request resolves its workspace, checks membership and scopes its unit of work, so every query sees one customer's data, with PostgreSQL row-level security underneath.

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
config/app.toml

Choose it when

  • Many organizations share one deployment.
  • Roles differ per customer: an owner here, a viewer there.
  • Some customers need their own schema or database.

How Navree supports it

  • WorkspaceModule, in the default features
  • The CurrentWorkspace extractor and WorkspaceRepo
  • Isolation by row, schema or database, set in configuration
  • Workspace roles feed #[permissions] and #[can]

04 Event-driven

React to changes without coupling modules.

Modules emit typed events and listeners react, in the request or on a worker. Jobs and outgoing events are written in the same transaction as the change and leave only after it commits, so nothing hears about a change that rolled back.

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
crates/crm-deals/src/lib.rs

Choose it when

  • Side effects must not slow or fail the request.
  • Screens should update live.
  • Other services must hear about every committed change.

How Navree supports it

  • Typed events (Event) and m.listen::<E, L>()
  • Queued listeners and jobs on queue:work, with retries and backoff
  • A transactional outbox: jobs and micro_outbox rows commit with the change
  • Broadcasting over the built-in Pusher-protocol WebSocket server

05 API-first

The contract comes from the code.

Routes declare their version and access rule, inputs are validated DTOs, outputs are typed resources and every error has one JSON shape. Navree builds an OpenAPI 3.1 document from all of it at boot, so the document, the validation and the generated clients change together.

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
crates/crm-deals-v2/src/lib.rs

Choose it when

  • Mobile apps, partners, other teams or AI agents use your API.
  • A breaking change should fail CI, not a client.
  • The docs must never drift from the code.

How Navree supports it

  • #[controller("/deals", version = "1")] and #[api(...)]
  • Valid<Json<T>> with garde rules: field errors before the handler runs
  • ApiModule serves /api/openapi.json and /api/docs
  • openapi:export for typed clients in any language

06 Backend for frontend

Keep every token out of the browser.

Your web app talks only to its own server, on the same origin. That server keeps the access and refresh tokens in httpOnly cookies, rotates them and calls the Navree API with a bearer token. The API stays the plain bearer-token API every other client uses.

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
src/app/api/[...path]/route.ts

Choose it when

  • You ship your own web app.
  • One cross-site scripting bug must not leak every session.
  • Mobile apps and partners call the same API with tokens.

How Navree supports it

  • Refresh tokens that rotate on every use, with reuse detection
  • A Next.js BFF in the saas starter kit and navree new --ui next
  • Real time straight from the WebSocket server, channels authorized through the BFF
  • A browser client generated from the OpenAPI document

Grow without rewriting

Start as one binary. Grow into the shape the product needs.

A module keeps its providers, rules, migrations and tests whether it runs in the monolith, inside a workspace or in a service of its own. What changes is the wiring around it.

  1. Step 1

    Start as a modular monolith

    navree new writes one binary, and make:module adds a module per feature. Calls between features are function calls, and one transaction covers a request.

    Terminal
  2. Step 2

    Add workspaces

    Add WorkspaceModule, give tenant tables a workspace_id and take CurrentWorkspace where a handler needs it. Routes, services and access rules stay as they are.

    src/app.rs
  3. Step 3

    Move a module into a service

    When deals need their own deploys, DealsModule moves into its own binary with MicroModule. Its service, rules and tests come along; callers send a message instead of calling a method.

    src/modules/reports/service.rs
What changes
The wiring: which binary a module runs in, its transport, the isolation of its tables, how callers reach it.
What stays
Every line of module code: its services, routes and handlers, its access rules, its migrations and its tests.

Start

Start from the shape you need.

Each starter kit is a working app in one of these shapes, built from the same modules as everything on this page.

  • Multi-tenant SaaS

    Workspaces, billing and an admin panel, with a Next.js BFF that keeps tokens in httpOnly cookies.

    $ navree new acme --kit saas
  • API-first monolith

    Versioned routes, an OpenAPI 3.1 document and a module per feature, ready for any client.

    $ navree new acme --kit api
  • Microservice

    One module with a message handler, an event handler and NATS configured for development.

    $ navree new deals --kit micro