backend architecture
npx skills add https://github.com/exceptionless/exceptionless --skill Backend Architecture
Skill 文档
Backend Architecture
Quick Start
Run Exceptionless.AppHost from your IDE. Aspire automatically starts all services (Elasticsearch, Redis) with proper ordering. The dashboard opens at the assigned localhost port.
dotnet run --project src/Exceptionless.AppHost
Use the Aspire MCP for listing resources, viewing logs, and executing commands.
Project Layering
Exceptionless.Core â Domain logic, services, repositories, validation
Exceptionless.Insulation â Infrastructure implementations (Redis, GeoIP, Mail, HealthChecks)
Exceptionless.Web â ASP.NET Core host, controllers, WebSocket hubs
Exceptionless.Job â Background job workers
Dependency Direction
Web â Core â Insulation
Job â Core â Insulation
Exceptionless.Core
Contains all domain logic, services, and repositories.
Services
Real services in the codebase (see src/Exceptionless.Core/Services/):
UsageServiceâ Tracks event usage per organization/projectEventPostServiceâ Handles event post storage and retrievalStackServiceâ Stack management and status updatesOrganizationServiceâ Organization lifecycle managementMessageServiceâ WebSocket message coordinationSlackServiceâ Slack integration
Repositories
Repositories extend Foundatio.Repositories.Elasticsearch and use validation:
// From src/Exceptionless.Core/Repositories/Base/RepositoryBase.cs
public abstract class RepositoryBase<T> : ElasticRepositoryBase<T> where T : class, IIdentity, new()
{
protected readonly IValidator<T>? _validator;
protected readonly AppOptions _options;
public RepositoryBase(IIndex index, IValidator<T>? validator, AppOptions options) : base(index)
{
_validator = validator;
_options = options;
NotificationsEnabled = options.EnableRepositoryNotifications;
}
protected override Task ValidateAndThrowAsync(T document)
{
if (_validator is null)
return Task.CompletedTask;
return _validator.ValidateAndThrowAsync(document);
}
}
Repositories use Foundatio Parsers for query parsing against Elasticsearch.
Validation
Two validation patterns are used (transitioning to MiniValidator for new code):
FluentValidation for Domain Models
Used by repositories (see src/Exceptionless.Core/Validation/):
// From src/Exceptionless.Core/Validation/OrganizationValidator.cs
public class OrganizationValidator : AbstractValidator<Organization>
{
public OrganizationValidator(BillingPlans plans)
{
RuleFor(o => o.Name).NotEmpty().WithMessage("Please specify a valid name.");
RuleFor(o => o.PlanId).NotEmpty().WithMessage("Please specify a valid plan id.");
RuleFor(o => o.SuspensionCode).NotEmpty().When(o => o.IsSuspended);
}
}
MiniValidator for API Request Models
Uses DataAnnotations with MiniValidator (preferred for new code â repositories are migrating to this):
// From src/Exceptionless.Web/Models/Login.cs
public record Login
{
[Required]
public required string Email { get; init; }
[Required, StringLength(100, MinimumLength = 6)]
public required string Password { get; init; }
}
MiniValidator integration (see src/Exceptionless.Core/Validation/MiniValidationValidator.cs):
public class MiniValidationValidator(IServiceProvider serviceProvider)
{
public async Task ValidateAndThrowAsync<T>(T instance)
{
(bool isValid, var errors) = await MiniValidator.TryValidateAsync(instance, serviceProvider, recurse: true);
if (!isValid)
throw new MiniValidatorException("Please correct the specified errors and try again", errors);
}
}
public class MiniValidatorException(string message, IDictionary<string, string[]> errors) : Exception(message)
{
public IDictionary<string, string[]> Errors { get; } = errors;
}
Auto-validation via AutoValidationActionFilter handles API model validation automatically.
Exceptionless.Insulation
Infrastructure implementations only â NOT services or repositories:
Configuration/â YAML configuration extensionsGeo/â MaxMind GeoIP serviceHealthChecks/â Elasticsearch, Cache, Queue, Storage health checksMail/â MailKit mail senderRedis/â Redis connection mapping
Authorization with Policy Constants
Use AuthorizationRoles constants (NOT string literals):
// From src/Exceptionless.Core/Authorization/AuthorizationRoles.cs
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";
}
Apply to controllers:
// From src/Exceptionless.Web/Controllers/AuthController.cs
[Route(API_PREFIX + "/auth")]
[Authorize(Policy = AuthorizationRoles.UserPolicy)]
public class AuthController : ExceptionlessApiController
{
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<TokenResult>> LoginAsync(Login model) { }
}
// From src/Exceptionless.Web/Controllers/AdminController.cs
[Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)]
public class AdminController : ExceptionlessApiController { }
Controller Patterns
CRUD via RepositoryApiController
Most controllers extend RepositoryApiController<TRepository, TModel, TViewModel, TNewModel, TUpdateModel>:
// From src/Exceptionless.Web/Controllers/OrganizationController.cs
[Route(API_PREFIX + "/organizations")]
[Authorize(Policy = AuthorizationRoles.UserPolicy)]
public class OrganizationController : RepositoryApiController<IOrganizationRepository, Organization, ViewOrganization, NewOrganization, NewOrganization>
{
[HttpGet]
public async Task<ActionResult<IReadOnlyCollection<ViewOrganization>>> GetAllAsync(string? mode = null)
{
var organizations = await GetModelsAsync(GetAssociatedOrganizationIds().ToArray());
var viewOrganizations = await MapCollectionAsync<ViewOrganization>(organizations, true);
return Ok(viewOrganizations);
}
}
Thin Controllers for Auth/Special Cases
// From src/Exceptionless.Web/Controllers/AuthController.cs
public class AuthController : ExceptionlessApiController
{
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<TokenResult>> LoginAsync(Login model)
{
string email = model.Email.Trim().ToLowerInvariant();
using var _ = _logger.BeginScope(new ExceptionlessState()
.Tag("Login")
.Identity(email)
.SetHttpContext(HttpContext));
var user = await _userRepository.GetByEmailAddressAsync(email);
if (user is null || !user.IsActive)
return Unauthorized();
return Ok(new TokenResult { Token = await GetOrCreateAuthenticationTokenAsync(user) });
}
}
ProblemDetails and Error Handling
Return Helpers
// Success responses
return Ok(data);
return Created(uri, await MapAsync<TViewModel>(model, true));
return NoContent();
// Error responses from ExceptionlessApiController
return Unauthorized(); // 401
return Forbidden(); // 403 - custom helper
return NotFound(); // 404
return ValidationProblem(ModelState); // 422 with validation errors
Exception to ProblemDetails Mapping
Exceptions are automatically converted via ExceptionToProblemDetailsHandler:
// From src/Exceptionless.Web/Startup.cs
MiniValidatorException => StatusCodes.Status422UnprocessableEntity,
ValidationException => StatusCodes.Status422UnprocessableEntity,
// Other exceptions map to 500
WebSocket Hubs (NOT SignalR)
Uses custom WebSocket implementation with Foundatio message bus:
// From src/Exceptionless.Web/Hubs/MessageBusBroker.cs
public sealed class MessageBusBroker : IStartupAction
{
private readonly WebSocketConnectionManager _connectionManager;
private readonly IMessageSubscriber _subscriber;
public async Task RunAsync(CancellationToken shutdownToken = default)
{
await Task.WhenAll(
_subscriber.SubscribeAsync<EntityChanged>(OnEntityChangedAsync, shutdownToken),
_subscriber.SubscribeAsync<PlanChanged>(OnPlanChangedAsync, shutdownToken),
_subscriber.SubscribeAsync<UserMembershipChanged>(OnUserMembershipChangedAsync, shutdownToken)
);
}
}
Key files:
Hubs/MessageBusBroker.csâ Subscribes to message bus, broadcasts to WebSocket clientsHubs/WebSocketConnectionManager.csâ Manages WebSocket connections
Configuration Pattern
Uses YAML files with custom environment variable binding:
// From src/Exceptionless.Web/Program.cs
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true)
.AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true)
.AddCustomEnvironmentVariables()
.AddCommandLine(args)
.Build();
AppOptions
All configuration binds to AppOptions class with nested options:
AppOptions.EmailOptionsAppOptions.AuthOptionsAppOptions.IntercomOptionsAppOptions.SlackOptionsAppOptions.StripeOptions
Access via direct injection (not IOptions<T>):
public class UsageService
{
public UsageService(AppOptions options, ILoggerFactory loggerFactory)
{
_options = options;
}
}
Service Discovery
Services reference each other by name in Aspire:
// AppHost topology
var elasticsearch = builder.AddElasticsearch("elasticsearch");
var api = builder.AddProject<Projects.Exceptionless_Web>("api")
.WithReference(elasticsearch);
// In service, get connection by resource name
var esConnection = builder.Configuration.GetConnectionString("elasticsearch");
Dependencies
- NuGet feeds configured in NuGet.Config
- Version alignment in
src/Directory.Build.props - Avoid deprecated APIs â check for alternatives before using legacy methods
Route Patterns
[Route(API_PREFIX + "/organizations")] // Collection
[HttpGet("{id}")] // Single resource
[Route("~/" + API_PREFIX + "/admin/organizations")] // Admin override