Skip to main content

Backend Coding Standards

Last updated: 2026-04-27 Stack: .NET 10, FastEndpoints, MediatR (Cyrus source generator), FluentValidation, EF Core, Dapper, Ardalis.GuardClauses

This document is the authoritative reference for backend coding standards, patterns, and gotchas in the RoundTripAPI repository. All new code — and all AI-assisted code — must follow these patterns.


1. Project Structure

src/
├── RoundTrip.API.Core/
│ ├── Aggregates/ # Domain aggregates — one folder per bounded context
│ │ ├── BillingContext/ # Invoice, InvoiceLineItem, InvoiceDelivery, Quote
│ │ └── ServiceTicketContext/ # ServiceTicket, TicketNote, TicketAttachment, PartUsage
│ ├── Enums/ # Shared enums (LineItemType, InvoiceStatus, etc.)
│ ├── Interfaces/ # Repository interfaces, IUnitOfWork, ITenantContext
│ └── ValueObjects/ # Strongly typed IDs, Money, Address, etc.
├── RoundTrip.API.UseCases/
│ └── Features/
│ ├── Billing/
│ │ ├── Commands/ # One folder per command: GenerateInvoice, AddLineItem, etc.
│ │ └── Queries/
│ └── Tickets/
│ ├── Commands/
│ ├── Queries/
│ └── EventHandlers/ # Domain event handlers (SignalR, etc.)
├── RoundTrip.API.Infrastructure/
│ ├── Data/
│ │ ├── Config/ # EF Core entity configurations
│ │ └── Repositories/ # Repository implementations
│ └── Migrations/
└── RoundTrip.API.Web/
└── Endpoints/ # FastEndpoints — one file per endpoint
├── Billing/
└── Tickets/

One class per file — strictly enforced

Never put multiple classes in one file except for closely related request/response records defined alongside their endpoint.


2. FastEndpoints Patterns

Request record style

Always use explicit properties with { get; init; } — never positional records for endpoints that have both route parameters and body properties:

// ✅ Correct — explicit properties
public sealed record MyRequest
{
[RouteParam]
public Guid Id { get; init; }

public string Name { get; init; } = string.Empty;
}

// ❌ Never — positional record with mixed route + body
public sealed record MyRequest(Guid Id, string Name);

Positional records with mixed route + body parameters cause silent binding failures where body properties arrive as null/default.

User identity from JWT claims — never from the client

Never accept AuthorUserId, RecordedBy, or any user identity field from the request body. These must always be derived from the authenticated user's JWT claims in HandleAsync.

Claim names at runtime — IMPORTANT

AddMicrosoftIdentityWebApiAuthentication maps JWT claim names to XML namespace form regardless of JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(). The Clear() call has no effect when using this middleware.

Always use the namespaced claim names:

// ✅ Correct — namespaced claim names as they appear at runtime
var userIdClaim = HttpContext.User.FindFirst(
"http://schemas.microsoft.com/identity/claims/objectidentifier"); // oid in JWT

var authorName = HttpContext.User.FindFirst("name")?.Value // name stays short
?? HttpContext.User.FindFirst("preferred_username")?.Value
?? "Unknown";

// ❌ Wrong — short claim names from JWT don't work at runtime
var userIdClaim = HttpContext.User.FindFirst("oid");

Verified claim names in production (Entra External ID CIAM):

JWT claimRuntime claim typeNotes
oidhttp://schemas.microsoft.com/identity/claims/objectidentifierUser object ID — always a GUID
namenameDisplay name — stays short
preferred_usernamepreferred_usernameEmail — stays short
roleshttp://schemas.microsoft.com/ws/2008/06/identity/claims/roleRole claim
tidhttp://schemas.microsoft.com/identity/claims/tenantidEntra directory GUID — NOT RoundTrip TenantId
subhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifierNot a GUID — don't use for identity

⚠️ Critical: The JWT tid claim is the Entra directory tenant ID — it is NOT the RoundTrip TenantId. Never use tid to resolve which tenant the user belongs to. Use the TenantUsers table lookup pattern instead (see section 4).

Standard pattern for deriving user identity in an endpoint:

var userIdClaim = HttpContext.User.FindFirst(
"http://schemas.microsoft.com/identity/claims/objectidentifier");

if (userIdClaim is null || !Guid.TryParse(userIdClaim.Value, out var userId))
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
await HttpContext.Response.WriteAsJsonAsync(
new { Error = "Unable to identify the authenticated user." }, ct);
return;
}

var userName = HttpContext.User.FindFirst("name")?.Value
?? HttpContext.User.FindFirst("preferred_username")?.Value
?? "Unknown";

This pattern is used in: AddNoteEndpoint, RecordPartUsageEndpoint

Debugging claim names at runtime

If a claim lookup fails, dump all claims temporarily:

// TEMP DIAGNOSTIC — remove after debugging
var claims = HttpContext.User.Claims
.Select(c => new { c.Type, c.Value })
.ToList();
await HttpContext.Response.WriteAsJsonAsync(new { Claims = claims }, ct);
return;

Deploy, hit the endpoint, and the response shows exact runtime claim type strings.

Enum binding in endpoints

FastEndpoints cannot deserialize JSON string enum names into C# enum types by default:

  1. Accept the enum as a string in the request record
  2. Use Enum.TryParse<T>() in HandleAsync to convert with a clear error message
  3. Pass the converted enum value to the MediatR command
  4. Keep IsInEnum() in the validator — it validates the command (enum type)
if (!Enum.TryParse<LineItemType>(req.LineItemType, out var lineItemType))
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await HttpContext.Response.WriteAsJsonAsync(
new { Error = $"Invalid value '{req.LineItemType}'. Valid values are: Labour, Part, Discount, Other." }, ct);
return;
}

This pattern is used in: PutTicketOnHoldEndpoint, AddLineItemEndpoint, CreateTicketEndpoint

DB CHECK constraints must match frontend enum values

⚠️ Critical — root cause of TRA-176. The DB has CHECK constraints on certain columns. The values sent from the frontend must exactly match what the constraint allows.

CK_ServiceTickets_OnHoldReason — allowed values:

AwaitingParts | AwaitingApproval | Rescheduled | CustomerNotHome

Whenever a DB CHECK constraint restricts an enum column, document the allowed values here AND in the database design doc. The frontend <select> option values must use these exact strings.

Rule: If you add or change a CHECK constraint, update this document and notify the frontend.

Endpoint result handling pattern

// ✅ Correct — use result.Type, not result.ResultType
HttpContext.Response.StatusCode = result.Type switch
{
ResultType.NotFound => StatusCodes.Status404NotFound,
ResultType.Validation => StatusCodes.Status422UnprocessableEntity,
ResultType.Conflict => StatusCodes.Status409Conflict,
ResultType.Unauthorized => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
await HttpContext.Response.WriteAsJsonAsync(new { Error = result.Error }, ct);

// ❌ Wrong — result.ResultType does not exist
result.ResultType switch { ... }
// ✅ Correct — WriteAsJsonAsync for responses
HttpContext.Response.StatusCode = StatusCodes.Status200OK;
await HttpContext.Response.WriteAsJsonAsync(result.Value, ct);

// ❌ Wrong — SendOkAsync, SendCreatedAtAsync etc. are not used in this codebase
await SendOkAsync(result.Value, ct);

Namespace must match the command

The using statement must exactly match the command's namespace. A mismatch causes Mediator to silently fail — the command is never handled and properties arrive as null.

// ✅ Correct
using RoundTrip.API.UseCases.Features.Tickets.PutOnHold;

// ❌ Wrong — compiles but Mediator can't find the handler
using RoundTrip.API.UseCases.Features.Tickets.Commands.PutOnHold;

3. MediatR (Cyrus Source Generator)

All commands and queries implement \IRequest<> directly. Handlers implement IRequestHandler<TRequest, TResponse>.

IRequest<T> vs IQuery<T> — pipeline behavior interception

⚠️ Critical: Pipeline behaviors (TransactionBehavior, ValidationBehavior, etc.) only intercept IRequest<T> — they do NOT intercept IQuery<T>. Every command and query record must implement IRequest<T> for the pipeline to function.

// ✅ Correct — pipeline behaviors intercept this
public sealed record GetTicketQuery(Guid TicketId) : IRequest<Result<TicketDetailResponse>>;

// ❌ Wrong — pipeline behaviors will NOT run for this
public sealed record GetTicketQuery(Guid TicketId) : IQuery<Result<TicketDetailResponse>>;

Domain event handlers implement INotificationHandler<TEvent> and return ValueTask. The Mediator source generator auto-discovers all handlers in assemblies registered in MediatorConfig.cs.

Handler PR checklist

Before merging any handler, verify:

  • Domain method calls wrapped in try/catch returning Result.Validation() on guard failures
  • All dispatchHubService.Push* calls wrapped in Task.Run(CancellationToken.None) — never awaited directly
  • UpdateAsync / SaveChangesAsync use CancellationToken.None for the DB write (see section 9)
  • No null-forgiving ! operators on nullable domain properties — use ?? fallback instead
  • Integration test covers happy path + not found

4. Tenant Resolution

Never use ITenantContext outside of the Mediator pipeline. ITenantContext is populated by TenantScopingBehavior which only runs for Mediator requests. SignalR hubs, EndpointWithoutRequest endpoints, and any code outside the Mediator pipeline must resolve the tenant directly from claims.

TenantScopingBehavior dev fallback

TenantScopingBehavior falls back to dev tenant 11111111-1111-1111-1111-111111111111 when the traxs_tenant_id claim is missing. This fallback is unconditional — it is NOT wrapped in #if DEBUG.

Tenant resolution pattern (outside Mediator pipeline)

private const string OidClaimType =
"http://schemas.microsoft.com/identity/claims/objectidentifier";

// Read oid from claims
var oid = User.FindFirst(OidClaimType)?.Value; // or Context.User for SignalR

// Look up TenantUsers table
const string sql = """
SELECT TenantId FROM TenantUsers
WHERE EntraObjectId = @Oid AND IsActive = 1
""";

using var connection = await connectionFactory.CreateConnectionAsync(ct);
var tenantId = await connection.QuerySingleOrDefaultAsync<Guid>(
new CommandDefinition(sql, new { Oid = Guid.Parse(oid) }));

Used in: GetTechnicianListEndpoint, GetCurrentTechnicianEndpoint, DispatchHub

TenantUsers table

Maps EntraObjectId → TenantId + Role. This is the authoritative source of tenant membership and user role. The JWT tid claim is the Entra directory GUID — it is NOT the RoundTrip TenantId.

Role is resolved by calling GET /v1/me after login — this queries TenantUsers by OID and returns { role, tenantId, ... }. The frontend stores this in the auth store. Never derive role or tenantId from JWT claims.

INSERT INTO TenantUsers (TenantId, EntraObjectId, Role, FirstName, LastName, Email, IsActive)
VALUES (@TenantId, @EntraObjectId, @Role, @FirstName, @LastName, @Email, 1)

5. SignalR Patterns

SignalR push must be fire-and-forget — never await directly

⚠️ Critical — root cause of TRA-176. PushTicketStatusChangedAsync and all other dispatchHubService.Push* calls must be wrapped in Task.Run(CancellationToken.None). Awaiting them directly inside the HTTP request pipeline causes the hub's latency to block the response. If the hub is slow or the connection is being established, ASP.NET Core's request timeout fires, cancels the request token, propagates into the SQL connection, and causes TaskCanceledException → 500.

// ✅ Correct — fire-and-forget, runs outside the HTTP request pipeline
_ = Task.Run(async () =>
{
try
{
await dispatchHubService.PushTicketStatusChangedAsync(
tenantId: ticket.TenantId.Value,
ticketId: ticket.Id.Value,
ticketNumber: ticket.Number.Value,
status: ticket.Status.ToString(),
technicianId: ticket.TechnicianId?.Value,
cancellationToken: CancellationToken.None);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"SignalR push failed for ticket {TicketId}. " +
"Clients may not receive real-time update.", ticketId);
}
}, CancellationToken.None);

// ❌ Wrong — blocks the HTTP pipeline, causes TaskCanceledException on hub latency
await dispatchHubService.PushTicketStatusChangedAsync(..., cancellationToken);

All handlers that call dispatchHubService must use the Task.Run pattern:

  • PutTicketOnHoldHandler

JWT token from query string (required for WebSocket connections)

SignalR passes the JWT as ?access_token=... in the query string for WebSocket connections. ASP.NET Core's JWT middleware only reads from the Authorization header by default. Add this in ServiceConfigs.cs after AddMicrosoftIdentityWebApiAuthentication:

services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
var originalOnMessageReceived = options.Events?.OnMessageReceived;
options.Events ??= new JwtBearerEvents();
options.Events.OnMessageReceived = async context =>
{
if (originalOnMessageReceived != null)
await originalOnMessageReceived(context);

var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;

if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
};
});

Hub tenant resolution

SignalR hubs run outside the Mediator pipeline — ITenantContext is NOT available. Resolve the tenant from TenantUsers using IDbConnectionFactory directly (see section 4 pattern).


6. Domain Layer Patterns

  • Aggregate roots inherit EntityBase<TId> and implement IAggregateRoot
  • All mutations go through aggregate methods — never set properties directly
  • Guard clauses enforce invariants at the domain boundary
  • Domain events registered via RegisterDomainEvent()

Result pattern

return Result<MyResponse>.Success(new MyResponse(...));
return Result<MyResponse>.NotFound();
return Result<MyResponse>.Validation("Error message here.");
return Result<MyResponse>.Conflict("Conflict reason.");
return Result<MyResponse>.Failure("Error message.", ResultType.Validation);

Try/catch in handlers — domain exceptions must return 422

Domain guard clauses (Guard.Against.InvalidInput) throw ArgumentException. These must be caught and returned as validation results — never let them bubble as 500s:

try
{
ticket.PutOnHold(command.Reason);
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
{
return Result<PutTicketOnHoldResponse>.Validation(ex.Message);
}

No null-forgiving operators on nullable domain properties

// ✅ Correct — explicit fallback
ticket.OnHoldReason ?? command.Reason

// ❌ Wrong — throws NullReferenceException if property is unexpectedly null
ticket.OnHoldReason!

7. EF Core & Migrations

Never use dotnet ef database update against production. Use DataGrip SQL directly. See Deployment Architecture doc for full workflow including __EFMigrationsHistory registration.

Enums stored with .HasConversion<string>(). Check constraints are not reliably diffed — always verify and manage manually in DataGrip.


8. FluentValidation

Validators live alongside their command in the same folder and namespace. Validators target the command (enum types), not the request record (string types).

RuleFor(x => x.Id).NotEmpty().WithMessage("ID is required.");
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Amount).GreaterThanOrEqualTo(0);
RuleFor(x => x.EnumField).IsInEnum().WithMessage("Valid values are: ...");

9. CancellationToken Rules

TransactionBehavior — always CancellationToken.None

TransactionBehavior uses CancellationToken.None for BeginTransactionAsync, CommitTransactionAsync, and RollbackTransactionAsync. Transaction lifecycle must always complete regardless of whether the HTTP client is still connected.

Why: If the client times out, ASP.NET Core sets context.RequestAborted. The cancellation propagates into the SQL connection at the ADO.NET level. A cancelled token on CommitAsync means the transaction never commits even though the business operation succeeded — leaving the DB in an inconsistent state and the client getting a 500.

SqlTicketNumberGenerator creates sequences named seq_tickets_{tenantId:N}_{year}.

  • :N format = GUID without hyphens, lowercase
  • tenantId.Value.ToString() (dashed format) is used for SQL UNIQUEIDENTIFIER column comparisons
  • Example: seq_tickets_fecf5ee099c74677913351948adee836_2026

The generator has a two-step reseed guard (TRA-171):

  1. IF NOT EXISTS / CREATE SEQUENCE — creates the sequence if missing
  2. Reads current_value from sys.sequences vs MAX(RIGHT(Number, 5)) from existing tickets — if behind, uses sp_executesql to ALTER SEQUENCE RESTART WITH max+1

Note: ALTER SEQUENCE does not accept a variable for RESTART WITH — it requires a literal. sp_executesql is the correct approach.

If seed data already exists and you need to create the sequence manually:

CREATE SEQUENCE dbo.seq_tickets_fecf5ee099c74677913351948adee836_2026
AS INT START WITH 8 INCREMENT BY 1 MINVALUE 1 MAXVALUE 9999 NO CYCLE NO CACHE

10. Known Issues & TODOs

LocationIssueTicket
GetInvoicePdfEndpointReads PDF from local filesystem — breaks on Azure App ServiceTRA-143
useBilling.tsShould be split into separate hook files matching ticket patternTRA-148
Multiple handlersSignalR push pattern audit — confirm all use Task.RunSession C
axiosInstance.tsMultiple console.log statements — remove before betaSession C