| name | backend-architecture |
|---|---|
| description | Use this skill when working on the ASP.NET Core backend — adding controllers, services, repositories, validators, authorization, WebSocket endpoints, jobs, Foundatio infrastructure, configuration, or Aspire orchestration. Prefer this as the backend entrypoint for project layering, C# conventions, logging, ProblemDetails, security-sensitive config, and OpenAPI baseline updates. |
Run Exceptionless.AppHost from your IDE, or start everything from the repo root:
aspire runExceptionless.Core → Domain logic, services, repositories, validation
Exceptionless.Insulation → Infrastructure implementations (Redis, GeoIP, Mail, HealthChecks)
Exceptionless.Web → ASP.NET Core host, Minimal API endpoints, Mediator handlers, WebSocket hubs
Exceptionless.Job → Background job workers
Dependency Direction: Web → Core ← Insulation / Job → Core ← Insulation
UsageService, EventPostService, StackService, OrganizationService, MessageService, SlackService
Repositories derive from the local repository base classes over ElasticRepositoryBase<T> and use MiniValidationValidator plus AppOptions. They use Foundatio Parsers for query parsing. See foundatio-repositories for query, pagination, patch, and aggregation patterns.
Use MiniValidator with DataAnnotations on API and domain models:
public record Login
{
[Required]
public required string Email { get; init; }
[Required, StringLength(100, MinimumLength = 6)]
public required string Password { get; init; }
}AutoValidationActionFilter handles API model validation automatically. MiniValidationValidator wraps MiniValidator.TryValidateAsync and throws MiniValidatorException on failure.
Infrastructure only — Configuration/ (YAML), Geo/ (MaxMind), HealthChecks/, Mail/ (MailKit), Redis/.
- Follow
.editorconfig, use file-scoped namespaces, and keep diffs minimal. - Always use braces for control flow and never add
#region/#endregion. - Async methods use the
Asyncsuffix and passCancellationTokenthrough call chains when available. - Prefer constructor injection with
readonlyfields. - Use
ValueTask<T>only for hot paths that often complete synchronously. ConfigureAwait(false)is not required in ASP.NET Core code.
Use structured message templates with named placeholders. Do not use string interpolation in log messages.
_logger.LogInformation("Saving org ({OrganizationId}-{OrganizationName}) event usage",
organizationId, organization.Name);For cross-cutting context, use ExceptionlessState scopes:
using var _ = _logger.BeginScope(new ExceptionlessState()
.Organization(organizationId)
.Project(projectId));Never log passwords, API keys, full tokens, or sensitive user data. Log identifiers and safe prefixes only.
Use Foundatio abstractions rather than provider-specific clients:
| Need | Use |
|---|---|
| Distributed cache | ICacheClient |
| Queues | IQueue<T> |
| Pub/sub | IMessageBus |
| File storage | IFileStorage |
| Distributed locks | ILockProvider |
| Retry/circuit breaker | IResiliencePolicyProvider |
Queue jobs usually derive from QueueJobBase<T>. Scheduled jobs generally derive Foundatio job base classes such as JobWithLockBase and use [Job] attributes for InitialDelay, Interval, and related scheduling options. Queue entries should be completed only after durable processing succeeds; abandon transient failures and do not retry validation failures.
Use foundatio-repositories for Elasticsearch repository querying, patching, aggregations, and pagination rules.
Use AuthorizationRoles constants (NOT string literals):
public static class AuthorizationRoles
{
public const string ClientPolicy = nameof(ClientPolicy);
public const string Client = "client";
public const string UserPolicy = nameof(UserPolicy);
public const string User = "user";
public const string GlobalAdminPolicy = nameof(GlobalAdminPolicy);
public const string GlobalAdmin = "global";
}
// Minimal API endpoint groups (NEW pattern)
var group = endpoints.MapGroup("api/v2")
.RequireAuthorization(AuthorizationRoles.UserPolicy) // Group default
.WithTags("Tokens");
// Override on specific endpoints
group.MapGet("tokens/me", ...).RequireAuthorization(AuthorizationRoles.ClientPolicy);
group.MapPost("auth/login", ...).AllowAnonymous();DEPRECATED: Controllers are being migrated to Minimal API endpoints + Mediator handlers (see below). Do NOT add new controllers. Use the Endpoint + Handler pattern instead.
Legacy controllers extend RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel>. Auth/special-case controllers extend ExceptionlessApiController directly.
All new API work uses Minimal API endpoints with Foundatio.Mediator for command/query dispatch.
src/Exceptionless.Web/Api/
├── Endpoints/ ← Thin HTTP adapters (routing, auth, response mapping)
├── Messages/ ← Command/query records (mediator messages)
├── Handlers/ ← Use-case logic (transport-agnostic, return Result<T>)
├── Middleware/ ← Mediator pipeline middleware (validation, logging)
├── Filters/ ← Endpoint filters (HTTP-specific cross-cutting)
├── Results/ ← Result→IResult mapping, pagination, response types
├── Infrastructure/ ← Shared utilities (validation, pagination, links)
└── OpenApi/ ← OpenAPI conventions and transformers
public static class TokenEndpoints
{
public static IEndpointRouteBuilder MapTokenEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("api/v2")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
.WithTags("Tokens");
group.MapGet("tokens/{id}", async (string id, IMediator mediator)
=> (await mediator.InvokeAsync<Result<ViewToken>>(new GetTokenById(id))).ToHttpResult())
.WithName("GetTokenById")
.Produces<ViewToken>()
.ProducesProblem(StatusCodes.Status404NotFound);
return endpoints;
}
}Handlers MUST return Result<T> or Result — NEVER IResult or HTTP types.
public class TokenHandler(ITokenRepository repository, ...) : HandlerBase
{
public async Task<Result<ViewToken>> Handle(GetTokenById message)
{
var model = await repository.GetByIdAsync(message.Id);
if (model is null)
return Result.NotFound("Token not found.");
return mapper.MapToViewToken(model);
}
}| Result Type | HTTP Status | Notes |
|---|---|---|
Result<T> success |
200 OK | Default success |
Result<T>.Created(val, loc) |
201 Created | With Location header |
Result.NotFound(msg) |
404 | Message in title field |
Result.Forbidden(msg) |
403 | Message in title field |
Result.BadRequest(msg) |
400 | Message in title field |
Result.Invalid(ValidationError) |
422 | Errors in errors dict |
Result.Invalid("plan_limit", msg) |
426 | Upgrade Required |
Result.Invalid("not_implemented", msg) |
501 | Not Implemented |
Result.Invalid("rate_limit", msg) |
429 | Too Many Requests |
WorkInProgressResult |
202 Accepted | Bulk operations |
ModelActionResults (has failures) |
400 | Per-ID failure details |
PagedResult<T> |
200 + Link headers | Auto-pagination |
NotModifiedResponse |
304 | No body |
- Handlers MUST NOT import
Microsoft.AspNetCore.Http - Handlers CAN accept
HttpContextas a method parameter (auto-resolved by mediator) for auth - Pagination link URLs MUST be built in the endpoint/mapper layer
ProblemDetailsshape MUST be preserved:instance,reference-id,errors,lower_underscorekeys- Messages go in
titlefield (NOTdetail) — matches original controller behavior - Use
ApiValidation.ValidateAsync(model, serviceProvider)at endpoint level (returns 422 by default) - Keep v1 legacy route aliases in the same endpoint file as canonical v2 routes
Use ApiValidation.ValidateAsync(model, serviceProvider) — returns ValidationProblemDetails at 422 for DataAnnotation failures. For MVC-model-binding-compatible 400 responses, pass explicit status code or add endpoint-level checks.
Return Result.NotFound(), Result.Forbidden(), Result.BadRequest(), or Result.Invalid(ValidationError). The ResultExtensions.ToHttpResult() method converts these to proper IResult with ProblemDetails shape.
Exceptions auto-convert via ExceptionToProblemDetailsHandler: MiniValidatorException/ValidationException → 422, UnauthorizedAccessException → 401, VersionConflictDocumentException → 409, others → 500.
After any API change (new endpoint, changed status codes, modified request/response models), always regenerate the OpenAPI baseline:
# Requires the API to be running (aspire run --project src/Exceptionless.AppHost)
curl -s http://localhost:7110/docs/v2/openapi.json | jq . > tests/Exceptionless.Tests/Controllers/Data/openapi.jsonThen include the updated openapi.json in the same commit as the API change. The OpenApiSnapshotTests.GetOpenApiJson_Default_MatchesSnapshot test will fail if the baseline is stale.
If local TLS tooling is preferred, use the Aspire HTTPS endpoint: https://api-ex.dev.localhost:7111/docs/v2/openapi.json.
The endpoint manifest test (EndpointManifestTests) verifies all registered routes haven't changed — update tests/Exceptionless.Tests/Controllers/Data/endpoint-manifest.txt when adding/removing routes.
Custom WebSocket implementation using Foundatio IMessageBus. MessageBusBroker subscribes to EntityChanged, PlanChanged, UserMembershipChanged and broadcasts to connected WebSocket clients via WebSocketConnectionManager.
Uses YAML files (appsettings.yml) + AddCustomEnvironmentVariables(). All config binds to AppOptions with nested options (EmailOptions, AuthOptions, IntercomOptions, SlackOptions, StripeOptions). Inject AppOptions directly — not IOptions<T>.
Secrets come from environment variables or deployment secrets, never committed config. Non-secret configuration belongs in appsettings.yml; environment overrides use the EX_ prefix.