Skip to main content

Request Pipeline

This page documents the complete lifecycle of a request through the RoundTrip system — from the moment a user action fires in the browser to the moment the response is rendered. Understanding this flow is essential for debugging, performance investigation, and understanding where to add new behaviour.

RoundTrip has three distinct request pipelines:

  1. HTTP API Request Pipeline — the standard request/response flow
  2. SignalR Real-Time Pipeline — push notifications to connected clients
  3. Hangfire Background Job Pipeline — deferred and scheduled work

1. HTTP API Request Pipeline

Full Flow Diagram

┌─────────────────────────────────────────────────────────────────────┐
│ BROWSER / PWA │
│ │
│ User Action → React Component → TanStack Query → apiClient │
│ (useQuery / useMutation) │
└────────────────────────────────┬────────────────────────────────────┘
│ HTTPS Request
│ Authorization: Bearer <JWT>

┌─────────────────────────────────────────────────────────────────────┐
│ CLOUDFLARE │
│ │
│ DNS resolution → CDN edge → Security headers (_headers file) │
│ CSP enforcement → Request forwarded to App Service origin │
└────────────────────────────────┬────────────────────────────────────┘
│ HTTPS

┌─────────────────────────────────────────────────────────────────────┐
│ AZURE APP SERVICE │
│ app-roundtrip-production │
│ .NET 10 · Linux │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ ASP.NET CORE MIDDLEWARE │ │
│ │ │ │
│ │ 1. HTTPS Redirection │ │
│ │ 2. CORS Policy │ │
│ │ └─ Allows roundtrips.app, dev.roundtrips.app │ │
│ │ 3. Authentication Middleware │ │
│ │ └─ Validates JWT against Entra External ID CIAM │ │
│ │ └─ Extracts claims: oid (EntraObjectId), roles │ │
│ │ └─ 401 Unauthorized if token missing or invalid │ │
│ │ 4. Authorization Middleware │ │
│ │ └─ Checks role claims against endpoint policy │ │
│ │ └─ 403 Forbidden if role not permitted │ │
│ │ 5. FastEndpoints Middleware │ │
│ │ └─ Routes request to matching endpoint class │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ FASTENDPOINT │ │
│ │ │ │
│ │ Configure() → defines route, HTTP verb, roles │ │
│ │ HandleAsync() → │ │
│ │ 1. Map HTTP request model → Command or Query │ │
│ │ 2. await _mediator.Send(command, ct) │ │
│ │ 3. Map Result<T> → HTTP response │ │
│ │ │ │
│ │ ⚠ No business logic here — endpoints are thin mappers │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ CYRUS MEDIATOR PIPELINE │ │
│ │ │ │
│ │ Pipeline behaviors execute in registration order: │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ 1. LoggingBehavior │ │ │
│ │ │ Logs: request type, tenant ID, user ID │ │ │
│ │ │ Logs: duration on completion │ │ │
│ │ └──────────────────────┬──────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────▼──────────────────────────────┐ │ │
│ │ │ 2. ValidationBehavior │ │ │
│ │ │ Runs FluentValidation validators │ │ │
│ │ │ Returns 422 if any fail — handler never runs │ │ │
│ │ └──────────────────────┬──────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────▼──────────────────────────────┐ │ │
│ │ │ 3. TenantScopingBehavior │ │ │
│ │ │ Resolves TenantId from JWT oid claim │ │ │
│ │ │ Dapper lookup: oid → TenantUserId + TenantId │ │ │
│ │ │ Sets ITenantContext for all downstream code │ │ │
│ │ │ IsInitialised guard prevents re-init │ │ │
│ │ └──────────────────────┬──────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────▼──────────────────────────────┐ │ │
│ │ │ 4. TransactionBehavior │ │ │
│ │ │ Opens EF Core transaction │ │ │
│ │ │ Calls next() → handler executes │ │ │
│ │ │ On success: commits transaction │ │ │
│ │ │ Post-commit: dispatches domain events │ │ │
│ │ │ On exception: rolls back │ │ │
│ │ └──────────────────────┬──────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────────────────▼──────────────────────────────┐ │ │
│ │ │ 5. Handler │ │ │
│ │ │ │ │ │
│ │ │ COMMAND Handler: │ │ │
│ │ │ Load aggregate via Repository (EF Core) │ │ │
│ │ │ Call aggregate method (domain logic) │ │ │
│ │ │ SaveChanges (EF Core) │ │ │
│ │ │ Return Result<T> │ │ │
│ │ │ │ │ │
│ │ │ QUERY Handler: │ │ │
│ │ │ IDbConnectionFactory → SqlConnection │ │ │
│ │ │ Dapper QueryAsync<DTO>() with tenant scoping │ │ │
│ │ │ Return Result<IReadOnlyList<DTO>> │ │ │
│ │ └──────────────────────┬──────────────────────────────┘ │ │
│ └─────────────────────────┼────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┼──────────────┐ │
│ │ │ │ │
│ ┌─────────▼──┐ ┌───────▼────┐ ┌─────▼──────────┐ │
│ │ AZURE SQL │ │ AZURE SQL │ │ DOMAIN EVENTS │ │
│ │ (EF Core) │ │ (Dapper) │ │ dispatched │ │
│ │ Writes │ │ Reads │ │ post-commit │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

Result<T> bubbles back up


FastEndpoint maps to HTTP response
(200 OK / 201 Created / 404 / 422 / 500)


Response → Cloudflare → Browser
TanStack Query cache updated
React component re-renders

Authentication Detail

Every API request is authenticated via JWT bearer token issued by Entra External ID CIAM.

Browser (MSAL)

│ 1. On app load, MSAL checks for cached token
│ 2. If expired or missing, redirects to Entra CIAM login
│ 3. User authenticates (email + password or OTP)
│ 4. Entra issues JWT access token
│ Claims included:
│ oid → Entra Object ID (user's unique identifier)
│ roles → ["TenantAdmin"] / ["Dispatcher"] / ["Technician"] etc.
│ extension_74ae24d66fa9411095712cfddae0db04_tid → TenantId
│ 5. MSAL caches token, attaches to all API requests

API (Authentication Middleware)
│ Validates JWT signature against Entra CIAM public keys
│ Checks expiry, audience, issuer
│ Populates HttpContext.User with claims

TenantScopingBehavior
│ Reads oid claim from HttpContext.User
│ Dapper lookup: SELECT TenantId, TenantUserId FROM TenantUsers WHERE EntraObjectId = @oid
│ Sets ITenantContext.TenantId and ITenantContext.TenantUserId
│ All downstream EF Core queries use HasQueryFilter(t => t.TenantId == _tenantId)

Handler executes with tenant context fully resolved

Tenant Isolation

Multi-tenant isolation is enforced at two levels:

LevelMechanismWhat It Prevents
EF Core Global Query FilterHasQueryFilter(t => t.TenantId == _tenantId) on every entityA tenant seeing another tenant's EF Core query results
Dapper explicit parameterEvery Dapper query includes WHERE TenantId = @TenantIdA tenant seeing another tenant's Dapper query results

Neither mechanism alone is sufficient. Both must be applied consistently. The architecture test suite verifies that all entities have the global query filter applied.


Error Handling

Error ConditionWhere CaughtHTTP Response
Missing / invalid JWTAuthentication Middleware401 Unauthorized
Valid JWT, wrong roleAuthorization Middleware403 Forbidden
Validation failureValidationBehavior422 Unprocessable Entity
Domain invariant violationHandler → DomainException400 Bad Request
Aggregate not foundHandler → Result.NotFound()404 Not Found
Unhandled exceptionGlobal exception middleware500 Internal Server Error + logged to App Insights

2. SignalR Real-Time Pipeline

SignalR is used for push notifications — dispatchers receive real-time updates when tickets are assigned, completed, or updated without polling.

Handler (command succeeds)

│ Domain event raised: e.g. TicketAssignedEvent


TransactionBehavior (post-commit)

│ IDomainEventPublisher.PublishAsync(event)


Domain Event Handler
│ e.g. TicketAssignedEventHandler

│ Resolves INotificationPushService (interface in Core)
│ Implemented by SignalRNotificationPushService (in Web layer)
│ ↑ This avoids Infrastructure → Web circular dependency


SignalRNotificationPushService

│ _hubContext.Clients.Group(tenantId.ToString())
│ .SendAsync("ReceiveNotification", payload)


SignalR Hub (NotificationHub)

│ Connected browser clients in tenant group receive message


React frontend
│ SignalR connection listener fires
│ TanStack Query cache invalidated for affected query
│ Component re-renders with fresh data

SignalR Connection Setup

Browser connects to: wss://api.roundtrips.app/hubs/notifications

│ MSAL token passed as query param or header
│ Hub validates token on connection
│ Client added to group: tenantId (string)


Hub maintains connection
│ Reconnect logic handled by SignalR client SDK
│ WebSockets required — must be enabled on App Service

:::caution WebSockets Must Be Enabled SignalR falls back to long-polling if WebSockets are disabled, causing significant performance degradation. Always verify WebSockets are enabled after any App Service configuration change:

az webapp config show --name app-roundtrip-production --resource-group rg-roundtrip-production --query webSocketsEnabled

:::


3. Hangfire Background Job Pipeline

Hangfire handles all deferred and scheduled work — invoice generation, email delivery, geocoding, and recurring jobs.

ENQUEUE PATH (triggered by domain event handler):
─────────────────────────────────────────────────
Domain Event Handler

│ _backgroundJobClient.Enqueue<SendInvoiceEmailJob>(
│ job => job.ExecuteAsync(invoiceId, email, token))


Hangfire SQL Server storage
(Jobs table in Azure SQL — same database as application data)


Hangfire Worker (running inside App Service process)
│ Polls SQL Server for pending jobs
│ Dequeues job
│ Resolves job class from DI container
│ Calls ExecuteAsync()


Job Class (e.g. SendInvoiceEmailJob)
│ Has full DI access — can use any registered service
│ Calls infrastructure services:
│ SendGridEmailService → SendGrid API
│ GraphUserService → Entra External ID Graph API
│ AzureBlobStorageService → Azure Blob Storage
│ QuestPDF → PDF generation


On success: Job marked Complete in Hangfire storage
On failure: Hangfire retries with exponential backoff (default 10 attempts)
└─ After all retries exhausted: Job moves to Failed state
└─ Failed jobs must be manually requeued from Hangfire dashboard

Recurring Jobs

Registered in Program.cs via RecurringJob.AddOrUpdate():

JobSchedulePurpose
MarkOverdueInvoicesJobDaily at 06:00 UTCMarks unpaid invoices past due date as Overdue

Key Facts About Hangfire

  • Hangfire runs inside the App Service process — it is not a separate worker service
  • Jobs are stored in Azure SQL — the same database used by the application
  • If the App Service restarts mid-job, Hangfire will retry the job on next startup
  • Job classes are resolved from the DI container — if a dependency is misconfigured, the job fails at construction time (not execution time)
  • This is why GraphApi:ClientSecret is not configured appears in Hangfire failure logs — the job class constructor throws before ExecuteAsync is ever called

Configuration Resolution Order

Understanding how .NET resolves configuration helps diagnose "not configured" errors:

Priority (highest to lowest):
1. Environment Variables (App Service Application Settings)
2. Key Vault references (resolved by App Service at startup via Managed Identity)
3. appsettings.{Environment}.json (e.g. appsettings.Production.json)
4. appsettings.json
5. User Secrets (local development only)

Key naming conventions:
JSON hierarchy: { "GraphApi": { "ClientSecret": "..." } }
Environment var: GraphApi__ClientSecret (double underscore)
Key Vault secret: GraphApi--ClientSecret (double dash)
Configuration key: GraphApi:ClientSecret (colon — used in code)

Performance Characteristics

OperationTypical DurationNotes
Simple Dapper query5–20msSingle table, indexed, tenant-scoped
Complex Dapper query (dispatch board)200–2000msMultiple joins, large result sets
EF Core write (single aggregate)20–80msIncludes transaction overhead
SignalR push notification<50msAfter handler completes
Hangfire job enqueue<10msJust a SQL INSERT
Hangfire job execution (email)500–3000msSendGrid API call
App Service cold start60–90 secondsAfter stop/start — warm start is instant