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:
- HTTP API Request Pipeline — the standard request/response flow
- SignalR Real-Time Pipeline — push notifications to connected clients
- 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:
| Level | Mechanism | What It Prevents |
|---|---|---|
| EF Core Global Query Filter | HasQueryFilter(t => t.TenantId == _tenantId) on every entity | A tenant seeing another tenant's EF Core query results |
| Dapper explicit parameter | Every Dapper query includes WHERE TenantId = @TenantId | A 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 Condition | Where Caught | HTTP Response |
|---|---|---|
| Missing / invalid JWT | Authentication Middleware | 401 Unauthorized |
| Valid JWT, wrong role | Authorization Middleware | 403 Forbidden |
| Validation failure | ValidationBehavior | 422 Unprocessable Entity |
| Domain invariant violation | Handler → DomainException | 400 Bad Request |
| Aggregate not found | Handler → Result.NotFound() | 404 Not Found |
| Unhandled exception | Global exception middleware | 500 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():
| Job | Schedule | Purpose |
|---|---|---|
MarkOverdueInvoicesJob | Daily at 06:00 UTC | Marks 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 configuredappears in Hangfire failure logs — the job class constructor throws beforeExecuteAsyncis 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
| Operation | Typical Duration | Notes |
|---|---|---|
| Simple Dapper query | 5–20ms | Single table, indexed, tenant-scoped |
| Complex Dapper query (dispatch board) | 200–2000ms | Multiple joins, large result sets |
| EF Core write (single aggregate) | 20–80ms | Includes transaction overhead |
| SignalR push notification | <50ms | After handler completes |
| Hangfire job enqueue | <10ms | Just a SQL INSERT |
| Hangfire job execution (email) | 500–3000ms | SendGrid API call |
| App Service cold start | 60–90 seconds | After stop/start — warm start is instant |
Related Pages
- Deployment Architecture — Azure resource inventory and environment configuration
- Code Management — how code gets from a branch to production
- Infrastructure Reference — resource names, Key Vault mappings, runbooks
- Backend Coding — handler patterns and coding conventions
- Cyrus Mediator — deep dive on the mediator pipeline