Microservices And Messaging · How it works

6 min read
Mid-level11 min read
Rapid overview

How it works

The services and their boundaries

Each service owns one bounded concern: its own data, its own deployment, its own lifecycle. No service reaches into another service's database — they collaborate through APIs and events instead.

ServiceOwns / powersNotes
Tenant / IdentityTenants; wraps KeycloakThe authority on "who is a tenant"
QuestionerErevna (surveys/forms)Survey definitions, responses
OnlineMenuKatalogos (online menus)Menus, categories, items
ContentFile/image storage over S3Upload/serve assets
NotificationTransactional + marketing email, push, the daily reportThe "outbox" for comms
PaymentSubscriptions / billingSource of truth for subscription status
KefiEventsEvent pages

The guiding rule: a service is the single source of truth for its concern. If OnlineMenu needs to know whether a tenant is subscribed, it does not store that itself — it asks Payment. If Questioner needs an email sent, it does not own an SMTP client — it asks Notification.

                          ┌──────────────┐
                          │   Traefik    │  TLS, host routing, middleware
                          └──────┬───────┘
                                 │
                   ┌─────────────┼─────────────┐
                   │             │             │
              ┌────▼───┐    ┌────▼───┐    ┌────▼───┐
              │ BFF    │    │ BFF    │    │ BFF    │   per-product BFFs
              │(Erevna)│    │(Katal.)│    │(Kefi)  │
              └────┬───┘    └────┬───┘    └────┬───┘
                   │             │             │
   ┌───────────────┼─────────────┼─────────────┼────────────────┐
   │               │             │             │                │
┌──▼─────┐  ┌──────▼───┐  ┌──────▼────┐  ┌─────▼──┐  ┌────────┐ ┌▼────────┐
│ Tenant │  │Questioner│  │OnlineMenu │  │ Kefi   │  │Payment │ │ Content │
│/Identity│ │ (Erevna) │  │(Katalogos)│  │(events)│  │(billing)│ │ (S3)   │
└──┬─────┘  └────┬─────┘  └─────┬─────┘  └───┬────┘  └───┬────┘ └────┬────┘
   │             │              │            │           │           │
   │ each owns its own PostgreSQL database   │           │       ┌───▼────┐
   └─────────────┴──────────────┴────────────┴───────────┘       │  S3    │
                                 │                                └────────┘
                          ┌──────▼───────┐
                          │   RabbitMQ    │  events (MassTransit)
                          │ (message bus) │
                          └──────────────┘

Per-service stack & Clean Architecture

Every service is layered the same way, so a developer who knows one service can navigate any of them. Clean Architecture keeps the dependency arrow pointing inward: the Domain knows nothing about the database or the web framework.

LayerResponsibilityDepends on
DomainEntities, value objects, domain rulesnothing
ApplicationUse cases, handlers, interfaces (ports)Domain
InfrastructureEF Core, external services, implementationsApplication, Domain
APIFastEndpoints, DI wiring, the hostall of the above

The HTTP surface is FastEndpoints: each endpoint is a small class with a Request DTO, a Response DTO, and an optional validator. Many services mount everything under a global route prefix api/v1, so a route declared as menus/{id} is actually served at api/v1/menus/{id}.

// A FastEndpoints endpoint: one class, one route, typed in/out.
public sealed class GetMenuEndpoint : Endpoint<GetMenuRequest, MenuResponse>
{
    private readonly IMenuRepository _menus;

    public GetMenuEndpoint(IMenuRepository menus) => _menus = menus;

    public override void Configure()
    {
        Get("menus/{id}");        // effective path: api/v1/menus/{id}
        // requires a valid token from THIS product's realm by default
    }

    public override async Task HandleAsync(GetMenuRequest req, CancellationToken ct)
    {
        var menu = await _menus.GetAsync(req.Id, ct);   // already tenant-scoped
        if (menu is null) { await SendNotFoundAsync(ct); return; }
        await SendOkAsync(menu.ToResponse(), ct);
    }
}

// Validation lives next to the endpoint, not scattered through handlers.
public sealed class GetMenuValidator : Validator<GetMenuRequest>
{
    public GetMenuValidator() => RuleFor(x => x.Id).NotEmpty();
}

Central Package Management means a single Directory.Packages.props at the solution root pins the version of every NuGet package; the individual .csproj files reference packages without versions. Upgrading a dependency is a one-line change in one file, and every project in the service stays in lockstep.

Identity & SSO with Keycloak realms

Authentication is delegated to Keycloak. The key design choice: each product gets its own realm. Erevna's users live in the questioner realm, Katalogos's users in the onlinemenu realm, and so on. A realm is its own isolated user directory, set of clients, and token issuer.

Because the realms are separate, a token minted for one product is simply not valid for another. A shared library, Identity.Keycloak.Authorization, enforces this "realm wall": it validates that the token presented to a service was issued by that service's realm and rejects tokens from any other realm. So even if an attacker obtained a valid Erevna token, it cannot be replayed against the Katalogos API.

  Erevna user ──login──► Keycloak realm "questioner" ──token(questioner)──┐
                                                                           ▼
                                                              Questioner API ✓ accepts
                                                              OnlineMenu API ✗ rejects (wrong realm)

Some routes must be reachable without a login — the public survey page and the public menu page that anonymous visitors view. Those endpoints opt out of the realm wall with AllowAnonymous, which deliberately bypasses the authentication check for that route only.

public override void Configure()
{
    Get("public/menu/{id}");
    AllowAnonymous();      // public viewer — no realm token required
}

Multi-tenancy & data isolation

The services share running infrastructure (one cluster, one RabbitMQ, one PostgreSQL server) but must never leak one tenant's data to another. This is done with two cooperating pieces:

It contributes a TenantId column to the row, so each record carries the tenant it belongs to.

automatically appends WHERE TenantId = @currentTenant to every query for tenant-owned entities. Application code therefore never has to remember to filter by tenant — and can't accidentally forget.

  1. BaseTenantEntity — a base class that every tenant-owned entity inherits.
  2. A global EF Core query filter — registered once on the DbContext, it

The current tenant is resolved from the authenticated token / request context (the tenant claim in the validated Keycloak token), and that resolved value is what the filter compares against.

// Every tenant-owned row inherits this.
public abstract class BaseTenantEntity
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }   // who owns this row
}

public sealed class Menu : BaseTenantEntity
{
    public string Name { get; set; } = string.Empty;
    // ... menu-specific fields
}

// Registered once; applies to ALL queries automatically.
public sealed class OnlineMenuDbContext : DbContext
{
    private readonly ITenantContext _tenant;   // reads the current token's tenant

    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<Menu>()
         .HasQueryFilter(m => m.TenantId == _tenant.CurrentTenantId);
        // => application code can't read another tenant's menus, ever.
    }
}

This is row-level isolation in a shared database, as opposed to a separate database per tenant. It scales to many tenants cheaply, but the trade-off is that the safety guarantee lives in the filter — so the filter (and the tenant-resolution it depends on) is security-critical code.

Asynchronous messaging with RabbitMQ

The default way services collaborate is asynchronously, over RabbitMQ using MassTransit as the abstraction layer. Two shared packages back this:

PackageRole
Messaging.RabbitMqConfigures the bus, connection, retry/transport details
Messaging.ContractsThe shared event contracts (the message types) both publisher and consumer reference

The pattern is publish/subscribe of domain events: a service announces that something happened ("a notification was requested", "a campaign was sent"), and any interested service consumes it. The publisher does not know or care who listens — that is the whole point. This keeps services decoupled: instead of Questioner calling Notification directly (and being coupled to its address, availability, and API shape), Questioner just publishes an event, and Notification — which subscribes to it — does the work whenever it is ready.

  Questioner               RabbitMQ                 Notification
  ──────────               ────────                 ────────────
  publish ──► NotificationRequested ──► (queue) ──► consume ──► send email
   (returns immediately,                              (works at its own pace;
    doesn't wait for the email)                        retries on failure)
// A shared contract (lives in Messaging.Contracts, referenced by both sides).
public sealed record NotificationRequested(
    Guid TenantId,
    string ToEmail,
    string TemplateKey,
    IReadOnlyDictionary<string, string> Data);

// Publisher: any service that wants an email sent.
public sealed class InviteRespondentHandler
{
    private readonly IPublishEndpoint _bus;   // MassTransit

    public async Task Handle(InviteCommand cmd, CancellationToken ct)
    {
        // ... do the local work ...
        await _bus.Publish(new NotificationRequested(
            cmd.TenantId, cmd.Email, "survey-invite", cmd.Data), ct);
        // no direct call to the Notification service
    }
}

// Consumer: lives in the Notification service.
public sealed class NotificationRequestedConsumer
    : IConsumer<NotificationRequested>
{
    private readonly IEmailSender _email;

    public async Task Consume(ConsumeContext<NotificationRequested> ctx)
    {
        var m = ctx.Message;
        await _email.SendAsync(m.ToEmail, m.TemplateKey, m.Data);
    }
}

Benefits of going async here: the publisher returns immediately, the consumer can retry on transient failure, and load buffers in the queue instead of overwhelming a downstream service. The cost is eventual consistency — the email is sent "soon", not synchronously — which is exactly the right trade for notifications.

When synchronous is the right call

A few interactions genuinely need an answer now, before the request can proceed. The canonical example: OnlineMenu must know whether a tenant is currently subscribed before serving a premium feature or removing a watermark. For that, OnlineMenu makes a synchronous internal HTTP call to Payment:

GET /api/v1/internal/subscriptions/status/{tenantId}

This call is:

every request, and there is a defined fallback behaviour if Payment is briefly unavailable, so a billing-service blip doesn't take down menu rendering.

  • Internal — not exposed to the public; service-to-service only.
  • Protected by a shared secret — the caller proves it is a trusted service.
  • Cached with a fail-safe — the answer is cached so Payment isn't hit on
public sealed class SubscriptionStatusClient
{
    private readonly HttpClient _http;     // base address = Payment service
    private readonly IMemoryCache _cache;

    public async Task<bool> IsSubscribed(Guid tenantId, CancellationToken ct)
    {
        if (_cache.TryGetValue(tenantId, out bool cached)) return cached;
        try
        {
            // shared secret added as a header by a DelegatingHandler
            var resp = await _http.GetFromJsonAsync<StatusDto>(
                $"api/v1/internal/subscriptions/status/{tenantId}", ct);
            var subscribed = resp?.IsActive ?? false;
            _cache.Set(tenantId, subscribed, TimeSpan.FromMinutes(5));
            return subscribed;
        }
        catch
        {
            return FailSafeDefault;   // don't break menus if Payment hiccups
        }
    }
}

Rule of thumb: publish an event when you're telling the system something happened; make a synchronous call when you need to ask a question whose answer gates the current request.

Data & storage

EF Core. The database-per-service boundary is what makes a service truly independent — no other service queries it directly.

object storage. Application code does not talk to S3 directly; it goes through the Content service and the shared Storage.S3** package, which centralizes bucket configuration, key naming, and signed-URL handling.

  • Relational data: each service owns a PostgreSQL database, accessed via
  • Object/file storage: uploaded files and images go to **S3-compatible
Kind of dataWhere it livesHow it's reached
Structured/relationalPer-service PostgreSQLEF Core
Files / imagesS3-compatible object storeContent service + Storage.S3

Ingress, BFF & request flow

Frontends do not call the microservices directly. Each product has a thin BFF (backend-for-frontend) that:

service(s),

own domain to the correct tenant/product).

  • proxies / forwards requests from that product's frontend to the right
  • handles host-based routing for custom domains (mapping a customer's

In front of everything sits Traefik, the ingress controller, which does TLS termination, host routing (sending a request to the right BFF based on its hostname), and applies middleware.

Browser
   │  https://menus.example.com/api/v1/...
   ▼
Traefik  (TLS termination, host routing, middleware)
   │
   ▼
BFF (Katalogos)  — forwards, adds host-based routing for custom domains
   │
   ▼
OnlineMenu service (api/v1/...)
   │  ├─ EF Core ──► PostgreSQL
   │  ├─ sync ──► Payment /api/v1/internal/subscriptions/status/{tenantId}
   │  └─ publish ──► RabbitMQ ──► (consumers, e.g. Notification)

See also