navree
Documentation

Microservices

Split an app into services that call each other and publish events over NATS, Kafka, RabbitMQ, Redis Streams, MQTT or TCP, with the same modules, guards, validation and unit of work as your HTTP routes.

On this page

Introduction

Featuremicro A Navree app starts as one binary and splits into services when it needs to. navree-micro carries the messages between them. A service answers messages (request-response: "give me deal 42") and reacts to events (fire-and-forget facts: "deal 42 was won"), over the transport you choose: NATS, Kafka, RabbitMQ, Redis Streams, MQTT or plain TCP.

A message handler is written the way you write an HTTP route. It lives in a module, gets its dependencies injected, declares who may call it, validates its input with garde and runs in a unit of work that commits when it succeeds. Guards, access rules and the JSON error shape work the same way, so moving a feature from a route into a service changes how it is called, not how it is written.

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

What you get:

  • Request-response. #[message_pattern("deals.get")] handlers reply to ServiceClient::send, with a deadline that travels with the message.
  • Events. #[event_pattern("deal.won")] handlers receive what ServiceClient::emit publishes, with fan-out to every interested service, retries, dead letters and a transactional outbox.
  • Six transports, one API. The transport is configuration: the handlers and the calls stay the same.
  • Hybrid apps. One binary serves HTTP and handles messages, or runs as a message consumer only.
  • Context that follows the work. The request id, the caller's credentials, the current workspace and the OpenTelemetry trace context travel with every message.
  • Tests without a broker. An in-memory transport, and MicroFake to stub other services and assert on what you sent and emitted.

gRPC and GraphQL have packages of their own that share the same modules, guards and access rules: see gRPC and GraphQL.

Installation

navree-micro is the micro feature of the navree facade. Turn it on together with the feature of your transport:

Cargo.toml
FeatureAdds
microMicroModule, ServiceClient, the macros, the TCP and in-memory transports
micro-natsNATS, with JetStream for durable events
micro-kafkaKafka
micro-rabbitmqRabbitMQ (AMQP 0-9-1)
micro-redisRedis Streams
micro-mqttMQTT 5

The micro features are not part of the default full set, because each one brings a broker client. Every client is pure Rust and uses rustls for TLS, so a service still builds into one binary with no OpenSSL or C toolchain. Kafka goes through a small client of Navree's own on the Kafka wire protocol, and needs Kafka 4.0 or later (see Kafka).

With micro on, the prelude brings MicroModule, ServiceClient, Payload, the #[message_handlers] macro and the m.message_handlers::<T>() registration method.

To start a new service from a template, use the micro starter kit:

Shell

It writes a service with one module, a message handler, an event handler, a test on the in-memory transport and NATS configured for development.

A first service

This page and the rest of the section use the CRM split into services. crm-api keeps serving HTTP to the web app; the deals live in a deals service; a notifications service mails and notifies owners; an activities service keeps the timeline. The payloads they exchange live in a small shared crate:

The CRM as services
  • Cargo.tomlthe workspace
  • crates/
    • crm-contracts/payloads and patterns every service shares
      • src/
        • lib.rs
        • deals.rs
  • services/
    • crm-api/HTTP for the web app; calls the services
    • deals/answers deals.*; emits deal.won and deal.assigned
    • notifications/handles deal.won and deal.assigned
    • activities/handles deal.won
  1. Share the contract

    Payloads are plain serde types. Put them in a crate both sides depend on, so a change to a payload is a compile error on both sides rather than a surprise at run time:

    crates/crm-contracts/src/deals.rs

    DealWon is an ordinary Navree event: its NAME is the pattern it travels under, so the same type works on the in-process bus and between services.

  2. Answer messages in the deals service

    A message handler is an #[injectable] struct with a #[message_handlers] impl block. Each #[message_pattern] method answers one pattern:

    services/deals/src/handlers.rs

    #[can(Read, Deal, from = "id")] reads the id field of the payload, loads that deal in the handler's unit of work, and refuses with a 404 or a 403 before the handler runs, exactly as it does for a route's {id} path parameter.

    Register the handlers in the module's register hook:

    services/deals/src/lib.rs
  3. Start the service

    Add MicroModule to the app root, next to the platform modules:

    services/deals/src/main.rs

    and name the service and its transport:

    services/deals/config/app.toml
  4. Call it from the API

    In crm-api, inject ServiceClient and send the message. The reply type comes from the binding:

    services/crm-api/src/deals/controller.rs

    crm-api adds MicroModule::from_config() to its root too, with micro.service = "crm-api" and the same transport settings.

  5. Run it

    navree dock up starts NATS (with PostgreSQL and the other development services, see Dock). Then run each service in its own terminal:

    Shell

A GET /api/v1/deals/0199… now goes to crm-api, which sends deals.get over NATS. The message carries the user's bearer token, the request id and the trace context. The deals service authenticates the token, checks that the user may read that deal, loads it in a unit of work and replies. If the deal doesn't exist, the 404 comes back to crm-api, and ? hands it to the browser unchanged:

404 Not Found

Every service verifies forwarded tokens with its own AuthModule, so they share the signing secret: set the same APP_AUTH__JWT__SECRET in each. See credentials.

Handlers are module providers

A message goes through the same pipeline as an HTTP request. The transport hands the listener an envelope (a pattern, headers and a payload); Navree turns it into request parts whose headers are the message headers, then:

  1. sets the request id from the message's x-request-id, and continues the caller's trace;
  2. authenticates the forwarded credentials with the app's dyn Authenticator;
  3. runs the handler's guards, then its access check (roles, permissions or #[can]);
  4. extracts the handler's arguments: Payload<T>, Valid<Payload<T>>, Uow, CurrentPrincipal, Authorized<E, A> and the rest;
  5. runs the handler inside a unit of work, which commits when it returns Ok and rolls back when it returns an error;
  6. replies (request-response) or acknowledges the message (events).

Side by side:

HTTP routeMessage handler
Declared with#[controller], #[get], #[post], ...#[message_handlers], #[message_pattern], #[event_pattern]
Registered withcontrollers = [..] in #[module]m.message_handlers::<T>()
InputJson<T>, Query<T>, Path<T>Payload<T>
ValidationValid<Json<T>>Valid<Payload<T>>
Access#[public], #[authenticated], #[roles], #[permissions], #[can]The same on message patterns; events are facts and take none
Guards#[guard(..)]#[guard(..)]
Unit of workUow, commits below status 400Uow, commits when the handler returns Ok
ErrorsAppError, rendered as the JSON errorAppError, sent back as an error reply with the same JSON
Time limit#[timeout("2s")], http.timeout_secs#[timeout("2s")], the caller's deadline

A handler struct can hold both kinds of pattern, and a module can register any number of handler structs next to its controllers. The services they use are the same providers your routes use: DealService doesn't know whether a route or a message called it.

Hybrid applications

One binary can serve HTTP and handle messages. When MicroModule and HttpModule are both in the app, serve starts the HTTP server and the message listener together, and stops both on Ctrl-C or SIGTERM: the listener stops taking messages, finishes the ones in flight, and the app shuts down. That's how the deals service above answers deals.get and serves /health/ready from one process.

micro:listen runs the listener alone, without binding an HTTP port:

Shell

Use it for services with no HTTP routes, or to scale message consumers separately from the web tier: run serve with micro.listen_with_serve = false on the web servers and micro:listen on the workers, from the same build.

CommandHTTPMessages
serve (app with HttpModule and MicroModule)yesyes, unless micro.listen_with_serve = false
serve (app with HttpModule only)yesno
micro:listennoyes

A listener reports to readiness: with ObserveModule in the app, /health/ready includes a micro check that fails while the transport is disconnected, so an orchestrator stops sending traffic to an instance that can't reach the broker.

In tests and custom commands, MicroModule::start_listener(ctx) starts the listener (and the outbox relay) of a booted app the way serve does; it returns false when they already run. Apps on the in-memory transport don't need it: their listener starts at boot.

Configuration

Add MicroModule once, in the app root. It is global: modules register handlers and inject ServiceClient without importing it. from_config(), and a plain .module(MicroModule), read the micro section:

config/app.toml
KeyDefaultMeaning
micro.serviceapp.name, else the binary's nameThe service's name: its consumer group, its client id and the navree-service header of what it sends. Letters, digits, _ and -.
micro.transportnone (required)tcp, nats, kafka, rabbitmq, redis, mqtt, or memory (in-process, for tests). A missing or unknown value fails the boot, and so does a transport whose feature is off, naming the feature.
micro.urlthe transport's local defaultWhere the broker is (nats://127.0.0.1:4222, amqp://127.0.0.1:5672, ...).
micro.codecjsonHow payloads are encoded: json or msgpack. See serialization.
micro.request_timeout_ms5000The deadline of a send that sets none.
micro.connect_timeout_ms5000How long one connection attempt to the broker may take.
micro.concurrency64Messages one process handles at once, over every pattern.
micro.listen_with_servetrueWhether serve starts the listener too.
micro.max_message_bytes1048576The largest encoded payload, sent or received.
micro.forward_authtrueForward the current request's Authorization header on send.
micro.outboxtrue with DatabaseModulePublish events emitted inside a unit of work through the outbox.
micro.inboxtrue with DatabaseModuleSkip messages already handled, by message id. See idempotency.
micro.inbox_retention_hours72How long handled message ids (and dedupe replies) are kept.
micro.outbox_poll_ms1000How often the outbox relay looks for events a crashed process left behind.
micro.auto_setuptrue in debug buildsDeclare the streams, topics and queues the app needs at boot (micro:setup does it on demand).

Each transport has a section of its own (micro.nats, micro.kafka, ...), and events have retry and dead-letter settings under micro.events; see transports and events. Traces follow the observe.tracing settings: enabled = false sends messages without a traceparent, and sample_ratio decides whether a trace a message starts is recorded (see tracing).

As environment variables: APP_MICRO__SERVICE=deals, APP_MICRO__TRANSPORT=kafka, APP_MICRO__URL=nats://nats.internal:4222. An invalid value fails the boot with a message that names the key.

To configure the module in code, pass a transport to for_root, or MicroOptions for everything else:

Rust

MicroModule contributes a migration set named micro (tables micro_outbox, micro_inbox and micro_dead_letters) when the app has DatabaseModule; migrate runs it with your other migrations.

Commands

CommandWhat it does
micro:listenHandles messages without serving HTTP, until stopped.
micro:patterns [--json]Lists every message and event pattern: kind, handler, module and access.
micro:send <pattern> <json>Sends a request and prints the reply, for debugging.
micro:emit <pattern> <json>Publishes an event.
micro:setupDeclares the streams, topics, exchanges and queues the app's patterns need. Safe to run again.
micro:failedLists dead-lettered events.
micro:retry <id|all>Puts dead-lettered events back on their pattern.
micro:flushDeletes every dead-lettered event.

micro:patterns is the message-side twin of routes:

Text

company.renamed is an event the deals service handles to keep the company names it stores next to its deals up to date. Event patterns have no access column: see why.

Messages, gRPC or GraphQL

All three share your modules, so you can mix them in one app:

  • Messages (this section) for traffic between your own services, especially events: they go through a broker, so a service can be down for a minute without losing work, and one event reaches every service that cares.
  • gRPC for typed request-response APIs other teams or other languages call, with a .proto contract, streaming and Connect for browsers.
  • GraphQL for front ends that pick the fields they need across many entities.

From modules to services

Navree's modules already draw the lines between features: a module exports the services others may use and keeps the rest private. Splitting a module into its own service follows those lines:

  1. Move the module into its own binary, with MicroModule in the root.
  2. Add a #[message_handlers] struct whose handlers call the services the module used to export.
  3. In the modules that imported it, replace direct calls (self.deals.show(&uow, id)) with self.client.send("deals.get", &GetDeal { id }).
  4. Forward the in-process events other modules listened to with m.forward_event::<DealWon>(), and turn their listeners into #[event_pattern] handlers.

Nothing else changes: the guards, the validation rules, the unit of work and the tests of the module come along. Microservices in the architecture patterns walks through the whole split, and the modular monolith shows how to keep the option open from day one.

Testing

MicroFake gives the app an in-memory transport. Send messages into the app's own handlers, stub the services it calls, and assert on what it sent and emitted:

services/deals/tests/get.rs

Request-response and events cover the fake in full.