Multi-Tenant Microservice Platform: tenancy, messaging and workflow foundations (in progress)

Since August 2026 I have been building the foundations of a multi-tenant business platform on .NET 10: HR, customers, catalog, inventory, technicians, and workflow with work orders as separate services, each with its own PostgreSQL database, behind one shell UI. It is at MVP stage: the shared foundations work and are tested, and most business features are still being rebuilt on top of them. I wrote all the commits in the four repositories described here: the hub, the shared building blocks, the HR service and the workflow service.

Context

The platform is designed to serve organizations three ways from the same build artifacts: shared SaaS, where rows carry a tenant id; dedicated per-service databases for one tenant; and on-premise. The MVP is shared-only, and no service reads another service's database.

The engineering problem

  • One missing filter leaks data. In a shared database, one query without a tenant condition shows an organization's records to another. Isolation cannot depend on every developer remembering a WHERE clause.
  • Services still need each other's news. Changes travel as messages, and messages get lost, duplicated, malformed or addressed to a suspended tenant.
  • Approvals pause for days. A waiting workflow has to survive restarts, and a double click must not resume it twice.

What I built

  • A workflow and approval engine, built in August and September 2026. Definitions are data: start, decision, service-action, approval and end nodes, later timed and event-based waits, with conditions in a restricted expression language instead of code. A service registry and a capability catalog keep addresses out of workflows: a definition names a business operation, and the registry knows where to call it. Published versions are immutable. Approvers come from a user or a role, and a panel can require any or all of them. A work-order module (tasks, dispatch, checklists, parts, timers, attachments) sits on top, and the code has about 300 tests.
  • The platform foundation. Seven independent .NET APIs and DevExtreme module UIs, each service with its own database and role, behind an nginx gateway and a shell that loads only the modules a session may use. A Docker Compose stack adds RabbitMQ, an OpenTelemetry collector and Grafana LGTM, and one command checks the whole path end to end.
  • Shared building blocks. NuGet packages for the tenant context, validation of the RS256 access tokens that the identity service issues with OpenIddict, tenant resolution, an EF Core tenant filter and save guard, row-level security, problem+json errors with stable codes, and permission policies.
  • Reliable events. HR writes a record and an outbox row in one transaction, and a publisher sends the row to RabbitMQ with confirms. Event names and payloads carry a version. Workflow stores an inbox row and the event's effect in one transaction before acknowledging. Contract errors are dead-lettered at once; other failures retry through six delay tiers, from 5 seconds to 30 minutes.

Design notes

Two guards for tenant isolation

The tenant comes only from the access token's tenant_id claim. A tenant header sent by the client must match it or the request is refused, an unknown tenant fails closed instead of falling back to a default, and suspended or closed tenants get their own error codes, so the shell can tell the user what to do.

Inside a service, an EF Core global query filter scopes ordinary queries and a save guard rejects writes for another tenant. PostgreSQL row-level security is the second guard: a restrictive policy compares each row's tenant with a session setting that a connection interceptor keeps current. It catches what the filter cannot, such as raw SQL. A service refuses to start if its database role is a superuser or can bypass row-level security.

Outbox and inbox instead of "save, then publish"

Publishing after a save loses the event if the process stops in between; publishing first can announce a change that never commits. The outbox commits the record and its event together, and the broker delivers at least once. The inbox, keyed by message id, turns that into one effect, even when two consumers race. Messages are handled only for active tenants and nothing is stored otherwise, so a dead letter can be replayed once its tenant is active.

The database as the first queue

The workflow engine first used PostgreSQL as its durable execution queue instead of a broker. Workers claim tokens under a lease, guarded by an application-stamped concurrency token, and an expired lease returns the token to the queue, so extra workers are safe. That saved a piece of infrastructure at the cost of polling latency. Once services had to exchange events, RabbitMQ became the transport between them. The engine's code stays in the service's history, and work orders are being rebuilt on the new foundation.

Telemetry without tenant labels

Logs, traces and metrics go through OpenTelemetry to Grafana LGTM, and the correlation id and traceparent travel in message properties, so one trace spans publisher and consumer. Tenant ids never become metric labels, which keeps cardinality in check.

Testing and CI

  • Integration tests use real PostgreSQL 16 and RabbitMQ 4.1 in Testcontainers, as a non-superuser role with test-signed tokens, covering contracts, access, isolation, row-level security, migrations and the consumer.
  • Architecture tests enforce the dependency rule: only infrastructure references EF Core, Npgsql and the RabbitMQ client.
  • At the time of writing there are about 330 test methods in the building blocks, 130 in HR and 200 in Workflow.
  • GitHub Actions in each repository build, check formatting, run the tests, scan for vulnerable packages and build the images and UIs.

Stack

  • .NET 10, ASP.NET Core, EF Core with Npgsql, FluentValidation
  • PostgreSQL 16 with row-level security
  • RabbitMQ 4.1
  • RS256 access tokens from an OpenIddict-based identity service
  • OpenTelemetry, Grafana LGTM
  • React, DevExtreme, Vite and TypeScript for the shell and module UIs
  • xUnit, Testcontainers, Docker Compose, GitHub Actions

Status

  • In progress, at MVP stage; the HR and workflow services already run on the tested foundations.
  • Both services still serve the foundation's sample records while their business features (staff, leave and attendance; work orders) are rebuilt. Dedicated and on-premise tenancy, per-tenant entitlements and token revocation events are planned. Nothing has been deployed to staging or production.

Related

Hamsayeh takes the other route: a modular monolith with one schema per area. The outbox pattern is covered in more depth in RabbitMQ in .NET. If you are planning a multi-tenant system on .NET, see Work with me.

Frequently asked questions

Is the multi-tenant platform finished?

No. It is at MVP stage. The shared foundations for tenancy, access, messaging and observability are built and tested, while the business features of the HR and work-order services are being rebuilt on top of them. Nothing has been deployed to staging or production yet.

How does the platform keep tenants' data apart?

The tenant comes only from the access token's tenant_id claim, and unknown tenants are refused. Inside each service, an EF Core global query filter limits ordinary queries to the current tenant, and PostgreSQL row-level security is a second guard that also covers raw SQL.

How do the services exchange events reliably?

A publishing service writes its data and an outbox row in one transaction, and a background publisher sends the row to RabbitMQ with confirms. The consumer stores an inbox row and its effect in one transaction before acknowledging, using the message id as the idempotency key, with tiered retries and a dead-letter queue.

Which technologies does the platform use?

.NET 10 with ASP.NET Core and EF Core, PostgreSQL 16, RabbitMQ 4.1, RS256 access tokens from an OpenIddict-based identity service, OpenTelemetry with Grafana LGTM, DevExtreme module UIs, xUnit with Testcontainers, Docker Compose and GitHub Actions.

More in this section

Projects