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/project
-
EventPostService — Handles event post storage and retrieval
-
StackService — Stack management and status updates
-
OrganizationService — Organization lifecycle management
-
MessageService — WebSocket message coordination
-
SlackService — 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 extensions
-
Geo/ — MaxMind GeoIP service
-
HealthChecks/ — Elasticsearch, Cache, Queue, Storage health checks
-
Mail/ — MailKit mail sender
-
Redis/ — 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 clients
-
Hubs/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.EmailOptions
-
AppOptions.AuthOptions
-
AppOptions.IntercomOptions
-
AppOptions.SlackOptions
-
AppOptions.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