01 — Backend Architecture
How the .NET backend is organized, wired together, and how a request flows through it. Module-level details (entities, business rules) live in 02; column-level schema in 03; endpoint catalog in 07.
Layered solution
┌────────────────────────────────────────────────────────────────────┐
│ AssetTracking.API │
│ Program.cs · Controllers · Middleware · RequirePermission filter │
└──────┬─────────────────────────┬────────────────────────────────┬──┘
│ depends on │ depends on │
▼ ▼ ▼
┌────────────────┐ ┌────────────────────────┐ ┌────────────────────┐
│ AT.Application │ │ AT.Infrastructure │ │ AT.Persistence │
│ MediatR │ │ JWT, password hash, │ │ EF Core context, │
│ DTOs, mapping │ │ permission resolver, │ │ configurations, │
│ FluentValid'n │ │ code generator, │ │ interceptors, │
│ Pipeline beh. │ │ file storage, email, │ │ migrations, │
│ Exceptions │ │ cache, JWT bearer │ │ seeders, │
│ │ │ │ │ repositories │
└──────┬─────────┘ └─────────┬──────────────┘ └────────┬───────────┘
│ depends on │ depends on │ depends on
└───────────────────────┴──────────────────────────┘
▼
┌──────────────────────────┐
│ AT.Domain │
│ BaseEntity · Entities · │
│ Enums · Interfaces │
│ (zero deps) │
└──────────────────────────┘
Project file AssetTracking.slnx lists all five src/ projects + three tests/ projects.
| Project | Reference target | Notes |
|---|---|---|
AssetTracking.Domain |
None | Entities, enums, base classes, interfaces. Pure C#. |
AssetTracking.Application |
Domain | MediatR commands/queries, FluentValidation validators, DTOs, manual mapping (no AutoMapper) |
AssetTracking.Persistence |
Domain, Application | AppDbContext, EF configurations, interceptors, migrations, seeders, repository |
AssetTracking.Infrastructure |
Domain, Application, Persistence | JWT, hashing, permission resolution, code generation, file storage, email, in-memory cache, JWT bearer setup |
AssetTracking.API |
All four above | Controllers, middleware, Program.cs |
Program.cs walkthrough
src/AssetTracking.API/Program.cs:
QuestPDF.Settings.License = LicenseType.Community; // line 13 — required before any PDF
builder.Host.UseSerilog(...); // structured logs to console
builder.Services.AddApplication(); // line 26 — MediatR + behaviors + FluentValidation + INotificationService + IAuditScopeResolver
builder.Services.AddPersistence(configuration); // line 27 — DbContext + interceptors + repository + IUnitOfWork + IHierarchicalCodeService
builder.Services.AddInfrastructure(configuration); // line 28 — JWT bearer, IPermissionResolver, ITokenService, IPasswordHasher, ICodeGenerator, IFileStorage, email, hosted NotificationDeliveryWorker
builder.Services.AddControllers().AddJsonOptions(...); // enum-as-string serialization
builder.Services.AddSwaggerGen(...); // dev-only Swagger UI w/ Bearer scheme
builder.Services.AddCors(default policy from Cors:AllowedOrigins config);
app.UseSwagger() / UseSwaggerUI() // dev only
app.UseMiddleware<CorrelationIdMiddleware>(); // attaches X-Correlation-Id, pushes to Serilog LogContext
app.UseMiddleware<ExceptionHandlingMiddleware>(); // RFC 7807 problem+json
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication(); // JWT bearer
app.UseAuthorization();
app.UseMiddleware<RequestLoggingMiddleware>(); // writes a RequestLog row per non-/health request
app.MapControllers();
// Startup migration + seeding
await context.Database.MigrateAsync();
await DatabaseSeeder.SeedAsync(context, seedingOptions); // permissions, roles, demo users, notification templates
sampleDataGenerator.WriteIfMissing(...); // drops importable .xlsx into AppData/SampleData on first run
The middleware order matters: Correlation → Exception → CORS → Auth → Authorization → RequestLogging. Correlation has to wrap exception handling so problem+json includes the right correlation id; request logging is last so it sees the final status code.
Dependency injection composition
Every layer publishes a DependencyInjection.cs extension that the API project calls.
AddApplication() — Application/DependencyInjection.cs
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IAuditScopeResolver, AuditScopeResolver>();
AddPersistence(config) — Persistence/DependencyInjection.cs
services.AddScoped<AuditInterceptor>();
services.AddScoped<AuditLogInterceptor>();
services.AddDbContext<AppDbContext>((sp, options) =>
{
var logInterceptor = sp.GetRequiredService<AuditLogInterceptor>();
var auditInterceptor = sp.GetRequiredService<AuditInterceptor>();
options.UseSqlServer(config.GetConnectionString("SqlServer"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName));
options.AddInterceptors(logInterceptor, auditInterceptor); // ORDER MATTERS — see Interceptors below
});
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IAuditLogReader, AuditLogReader>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IHierarchicalCodeService, HierarchicalCodeService>();
AddInfrastructure(config) — Infrastructure/DependencyInjection.cs
services.Configure<JwtSettings>(config.GetSection("JwtSettings"));
// Auth services
services.AddScoped<ITokenService, JwtTokenService>();
services.AddScoped<IPasswordHasher, PasswordHasherService>();
services.AddScoped<IPermissionResolver, PermissionResolver>();
// Context
services.AddHttpContextAccessor();
services.AddScoped<IUserContext, HttpUserContext>();
services.AddSingleton<IClock, SystemClock>();
// Code generation
services.AddScoped<ICodeGenerator, CodeGeneratorService>();
// Excel I/O & report exports
services.AddSingleton<IExcelService, ExcelService>();
services.AddSingleton<SampleDataGenerator>();
services.AddSingleton<IReportExporter, ReportExporter>();
// Email pipeline
services.AddDataProtection(); // SMTP password encryption
services.AddScoped<IEmailSettingsProvider, DbEmailSettingsProvider>();
services.AddScoped<IEmailPasswordProtector, EmailPasswordProtector>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
services.AddHostedService<NotificationDeliveryWorker>(); // background pump for Notification queue
// Cache (in-memory)
services.AddSingleton<ICacheService, InMemoryCacheService>();
// File storage
services.Configure<FileStorageSettings>(config.GetSection("FileStorage"));
services.AddSingleton<IFileStorage, LocalFileStorage>();
// JWT bearer
services.AddAuthentication(...).AddJwtBearer(...);
services.AddAuthorization();
Lifetimes summary
| Service | Lifetime | Reason |
|---|---|---|
AppDbContext |
Scoped | EF Core default, per-request |
Repository<T>, IUnitOfWork, IUserContext, ITokenService, IPermissionResolver, ICodeGenerator, INotificationService, IPasswordHasher |
Scoped | Per-request; share the same AppDbContext |
IClock, ICacheService, IFileStorage, IExcelService, IReportExporter, SampleDataGenerator |
Singleton | Stateless or thread-safe |
AuditInterceptor, AuditLogInterceptor |
Scoped | Need scoped IUserContext and IClock |
NotificationDeliveryWorker |
Singleton (IHostedService) |
Long-running pump |
Domain interfaces
Pure abstractions in Domain/Interfaces/. No EF, no ASP.NET. Implemented by Infrastructure or Persistence.
| Interface | Implementation | Purpose |
|---|---|---|
IUserContext |
HttpUserContext (Infra) |
Reads UserId, Email, IpAddress, CorrelationId from JWT claims via IHttpContextAccessor |
IClock |
SystemClock (Infra) |
UtcNow indirection for testability |
ICodeGenerator |
CodeGeneratorService (Infra) |
Per-entity code prefixes + UPDLOCK, ROWLOCK CodeSequences table |
IPermissionResolver |
PermissionResolver (Infra) |
Effective permission set with caching |
IPasswordHasher |
PasswordHasherService (Infra) |
PBKDF2 via Microsoft.AspNetCore.Identity.PasswordHasher<User> |
ITokenService |
JwtTokenService (Infra) |
Generate JWT + rotating refresh, reuse detection |
IFileStorage |
LocalFileStorage (Infra) |
AppData/Documents filesystem |
INotificationService |
NotificationService (App) |
Render templates, queue email, write Notification rows |
Application layer adds two more abstractions in Application/Interfaces/:
| Interface | Implementation | Purpose |
|---|---|---|
IRepository<T> |
Repository<T> (Persistence) |
GetByIdAsync, GetByCodeAsync, Query, AddAsync, Update, Remove, ExistsAsync. Thin wrapper around DbSet<T>. |
IUnitOfWork |
UnitOfWork (Persistence) |
Single method: SaveChangesAsync(ct). Handlers compose multiple repository operations and commit at the end. |
Repository<T> source (Repositories/Repository.cs):
public IQueryable<T> Query() => DbSet.AsQueryable();
public async Task AddAsync(T e, ct) => await DbSet.AddAsync(e, ct);
public void Update(T e) => DbSet.Update(e);
public void Remove(T e) => DbSet.Remove(e); // → AuditInterceptor flips this to soft-delete
MediatR pipeline
Every command and query goes through MediatR with two pipeline behaviors registered in this order:
Request ─▶ ValidationBehavior ─▶ LoggingBehavior ─▶ Handler ─▶ Response
Order is registration order in AddApplication(): Validation first, then Logging.
ValidationBehavior<TRequest, TResponse>
Application/Behaviors/ValidationBehavior.cs:
- Resolves all
IValidator<TRequest>from DI. - Runs them in parallel via
Task.WhenAll. - Groups failures by property name.
- Throws
ValidationException(IDictionary<string, string[]> errors)if any failed.
ExceptionHandlingMiddleware translates that to HTTP 400 with errors extension on the problem-details payload. Handlers never see invalid input.
LoggingBehavior<TRequest, TResponse>
Application/Behaviors/LoggingBehavior.cs:
Handling {RequestName} ← before
Handled {RequestName} in {ElapsedMs}ms ← after
Both written at Information level. Combined with CorrelationIdMiddleware, every log line carries the request's correlation id.
Exceptions
All thrown in Application/Exceptions/:
| Type | HTTP | Title | Carries |
|---|---|---|---|
ValidationException |
400 | Validation Error | Errors: IDictionary<string, string[]> |
NotFoundException |
404 | Not Found | EntityName, Key |
ForbiddenException |
403 | Forbidden | Permission? (surfaced as required_permission extension) |
BusinessException |
422 | Business Rule Violation | ErrorCode (surfaced as errorCode extension) |
ConflictException |
409 | Conflict | message only |
Plus EF-driven mappings from Microsoft.EntityFrameworkCore:
| EF exception | HTTP | Notes |
|---|---|---|
DbUpdateConcurrencyException |
409 | "The record was modified by another user. Please refresh and try again." |
DbUpdateException |
409 | Generic "The request conflicts with existing data". Inner driver text is NEVER returned to the client — logged at Warning server-side only. |
| anything else | 500 | "An unexpected error occurred." Logged at Error with full stack. |
ExceptionHandlingMiddleware returns application/problem+json (RFC 7807) with camelCase property names. See API/Middleware/ExceptionHandlingMiddleware.cs:33-113.
Middleware
Three custom middlewares in API/Middleware/:
CorrelationIdMiddleware
const string HeaderName = "X-Correlation-Id";
correlationId = req.Headers[HeaderName] ?? Guid.NewGuid();
res.Headers[HeaderName] = correlationId;
context.Items["CorrelationId"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId)) { await _next(context); }
The Serilog LogContext push means every log line emitted while handling the request includes the id.
ExceptionHandlingMiddleware
See exception table above. Wraps the entire pipeline so even validation failures and authorization rejections come out as problem+json. Always serializes camelCase, ignores nulls.
RequestLoggingMiddleware
After the request runs, writes a RequestLog row:
| Column | Source |
|---|---|
Id |
new Guid |
CorrelationId |
from context.Items["CorrelationId"] |
UserId |
IUserContext.UserId (null when anonymous) |
Method, Path, QueryString |
from HttpContext.Request |
StatusCode |
final response status |
DurationMs |
stopwatch around _next(context) |
IpAddress, UserAgent |
request connection / headers |
OccurredAt |
DateTime.UtcNow |
Skips /health and /swagger. Failures are caught and logged at Warning so a logging failure can never break a request.
Retention: 90 days.
Authorization — RequirePermission
API/Authorization/RequirePermissionAttribute.cs:
[RequirePermission("asset.read")] // single permission, requireAll = true
[RequirePermission("asset.update", "asset.delete")] // both required
[RequirePermission(new[] { "x", "y" }, requireAny: true)] // OR semantics
Backed by RequirePermissionFilter : IAsyncAuthorizationFilter:
- If
IUserContext.UserId == Guid.Empty→ returnsUnauthorizedResult(401). - Calls
IPermissionResolver.GetEffectivePermissionsAsync(userId)→ cachedHashSet<string>. - Checks
RequireAll(AND) or any (OR) against the requirement. - On failure: writes a 403 problem-details with
required: string[]+errorCode: "authz.permission-missing"extensions.
Every controller action MUST carry [RequirePermission(...)] or [AllowAnonymous] — enforced at build time by a reflection coverage test (see 02 §System tests and 06 §CI).
Permission resolution — PermissionResolver
Infrastructure/Auth/PermissionResolver.cs. Implements:
effective(user) = (∪ over user's active roles: rolePermissions(role))
∪ (direct grants where validFrom ≤ now < validUntil)
− (direct denies where validFrom ≤ now < validUntil)
- All queries
IgnoreQueryFilters()and explicit!IsDeleted— runs during login before auth context is set. - Time-bounded
UserRole.ValidFrom/ValidUntilandUserPermission.ValidFrom/ValidUntilare honored. - Cache key:
permissions:{userId}:{user.SecurityStamp}. TTL 30 minutes. InvalidateCacheAsync(userId)removes by prefixpermissions:{userId}:. Called whenever a user's roles or direct grants change.- Changing a role's permissions invalidates every user holding that role (
AssignPermissionsToRoleCommandHandlerenumerates affected users and clears each).
The cache is in-memory (InMemoryCacheService).
JWT — JwtTokenService
Infrastructure/Auth/JwtTokenService.cs. Configuration in appsettings.json → JwtSettings:
{
"Secret": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!!",
"Issuer": "AssetTracking",
"Audience": "AssetTrackingClients",
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 30
}
Access token claims
| Claim | Value |
|---|---|
sub |
user.Id.ToString() |
email |
user.Email |
jti |
Guid.NewGuid() |
security_stamp |
user.SecurityStamp (regenerated on permission changes) |
permissions_hash |
first 16 chars of base64 SHA-256 of sorted permission keys |
Signing: HmacSha256 over the configured Secret (UTF-8). ClockSkew = TimeSpan.Zero — strict expiration.
Refresh tokens
Stored as SHA-256 hashes, never plaintext. Each token belongs to a FamilyId. Lifecycle:
Login ──┬─▶ family A: token1 (active)
│
Refresh │ token1 → ReplacedByTokenHash = hash(token2), Revoked
├─▶ family A: token2 (active)
│
Refresh │ token2 → ReplacedByTokenHash = hash(token3), Revoked
└─▶ family A: token3 (active)
Reuse detection — caller presents an already-replaced token →
▶ revoke entire family A (every token marked RevokedAt + RevokedReason="reuseDetected")
▶ throw BusinessException("auth.token-reuse-detected")
Other revocation paths:
auth.token-revoked— caller presents a manually-revoked token.auth.token-expired—ExpiresAt < now.auth.invalid-refresh-token— hash not in DB.
Persistence
AppDbContext
Persistence/AppDbContext.cs exposes ~50 DbSet<T> properties grouped by module (Identity, Settings, Master Data, Assets, Audits, Transfers & Custody, Maintenance, Notifications, Documents, Reporting, Infrastructure).
OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly)— picks up everyIEntityTypeConfiguration<T>inPersistence/Configurations/.- Walks every
BaseEntity-derived type and applies a global query filter:e => !e.IsDeleted. Any query against any DbSet implicitly filters out soft-deleted rows; opt out withIgnoreQueryFilters().
BaseEntityConfiguration<T>
Persistence/Configurations/BaseEntityConfiguration.cs. Every other configuration inherits from it and calls base.Configure(builder) first:
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedNever(); // we always set Id explicitly
builder.Property(e => e.Code).HasMaxLength(50).IsRequired();
builder.HasIndex(e => e.Code).IsUnique().HasFilter("[IsDeleted] = 0"); // soft-delete-aware unique
builder.Property(e => e.CreatedBy).HasMaxLength(100).IsRequired();
builder.Property(e => e.ModifiedBy).HasMaxLength(100);
builder.Property(e => e.DeletedBy).HasMaxLength(100);
builder.Property(e => e.RowVersion).IsRowVersion(); // SQL rowversion
Two consequences worth knowing:
- Unique-on-Code is filtered: a soft-deleted row's code can be reused. Queries that need to find a soft-deleted record must
IgnoreQueryFilters(). Idis never DB-generated: handlers must setId = Guid.NewGuid()for every new entity, especially when adding multiple children in a graph. TheAuditInterceptordoes set Ids as a fallback, but only at SaveChanges time — change-tracking starts before that, so multiple unset Ids collide asGuid.Empty.
Interceptors
Two SaveChangesInterceptor implementations, registered in this order:
options.AddInterceptors(logInterceptor, auditInterceptor);
Order matters: AuditLogInterceptor runs first to capture the original EntityState.Deleted before AuditInterceptor rewrites it to EntityState.Modified for soft-delete.
AuditInterceptor — Persistence/Interceptors/AuditInterceptor.cs
For every BaseEntity entry on save:
| State | Mutation |
|---|---|
Added |
If Id == Guid.Empty → assign new Guid. If CreatedAt == default → now. If CreatedBy empty → user id (or "anonymous"). If Code blank → fallback {TYPE}-{guid:N} truncated to 50 chars. |
Modified |
ModifiedAt = now, ModifiedBy = userId |
Deleted |
Flips state to Modified (so EF emits an UPDATE, not a DELETE) and sets IsDeleted = true, DeletedAt = now, DeletedBy = userId |
Effect: calling repo.Remove(entity) → _uow.SaveChangesAsync() performs a soft-delete. The row stays; the global filter hides it.
AuditLogInterceptor
Writes structured before/after snapshots into AuditLogs for entities marked auditable. (Detailed in 02 §System.)
Code generation — CodeGeneratorService
Infrastructure/Services/CodeGeneratorService.cs. Algorithm:
var prefix = Prefixes.GetValueOrDefault(entityType, entityType[..3].ToUpper());
var period = _clock.UtcNow.Year.ToString();
var seq = await NextSequenceAsync(entityType, period, ct);
return $"{prefix}-{seq:D6}"; // e.g. ASSET-000123
NextSequenceAsync runs raw SQL with WITH (UPDLOCK, ROWLOCK) to make sequence increment race-safe across workers:
SELECT * FROM CodeSequences WITH (UPDLOCK, ROWLOCK)
WHERE EntityType = @entity AND Period = @period
Inserts a new row if none, otherwise increments LastValue. Saves immediately so the lock is released before the caller's transaction.
Prefix table (full)
| EntityType | Prefix | EntityType | Prefix |
|---|---|---|---|
| User | USR |
AssetTransfer | XFER |
| Role | ROLE |
AssetTransferLine | XFL |
| Permission | PERM |
CheckOut | CO |
| UserRole | UR |
MaintenancePlan | MP |
| RolePermission | RP |
MaintenancePlanAsset | MPA |
| UserPermission | UP |
MaintenanceRequest | MR |
| RefreshToken | RT |
WorkOrder | WO |
| LoginAudit | LA |
NotificationTemplate | NT |
| Organization | ORG |
Notification | NOTIF |
| Location | LOC |
NotificationDelivery | ND |
| Classification | CLS |
NotificationPreference | NP |
| Vendor | VND |
Document | DOC |
| Manufacturer | MFR |
DocumentLink | DL |
| Asset | ASSET |
Translation | TR |
| AssetDetails | ADET |
||
| AssetStatusHistory | ASH |
||
| AssetLocationHistory | ALH |
||
| AssetOrganizationHistory | AOH |
||
| AssetCustodyHistory | ACH |
AuditPlan | AP |
| AuditPlanScope | APS |
AuditAssignment | AA |
| AuditAssignmentAsset | AAA |
AuditResult | AR |
| AuditResultLine | ARL |
AuditReviewAction | ARA |
Specialized methods
NextEntitySequenceAsync<T>(entityType)— scans all existing codes (including soft-deleted viaIgnoreQueryFilters) for the prefix, returnsMAX + 1. Used where theCodeSequencestable can't be trusted (legacy data).NextAssetSequenceAsync(classificationId, classificationCode)— Asset codes are shaped{6-digit-seq}{classificationCode}, scanning per-classification across both active and soft-deleted assets so a freed code is never re-issued.
File storage
IFileStorage (Domain) → LocalFileStorage (Infrastructure). Settings:
"FileStorage": { "RootPath": "AppData/Documents" }
Stores files at {RootPath}/{owner-bucket}/{guid}.{ext} keyed by the Document.Id. Used by photos in audit results and asset images. See 02 §Documents.
End-to-end pipeline:
Handler ─▶ INotificationService.PublishAsync(...)
│
├── INSERT Notification (in-app) ◀─ rendered InApp template
│
└── INSERT NotificationDelivery (queued) ◀─ rendered Email template
│
│ background loop
▼
NotificationDeliveryWorker
│
▼
IEmailSender (SmtpEmailSender)
│
reads IEmailSettingsProvider (DbEmailSettingsProvider)
decrypts password via IEmailPasswordProtector (DataProtection)
│
▼
SMTP server
Configuration lives in the EmailProviderSettings table (one row), edited via /settings/email. SMTP password is encrypted at rest using ASP.NET Core Data Protection. Bilingual templates (subject + body for primary/secondary) live in NotificationTemplates. Merge-field substitution + normalization detailed in 02 §Notifications.
Idempotency
The mobile audit submission is idempotent via SubmitResultCommand.ClientSubmissionId:
var existing = await _resultRepo.Query()
.Include(...)
.FirstOrDefaultAsync(res => res.ClientSubmissionId == r.ClientSubmissionId, ct);
if (existing is not null) return existing.ToDto();
The mobile app generates a fresh GUID per submit and replays it on retry. The handler returns the existing result rather than creating a duplicate.
No other endpoints currently use idempotency keys.
Concurrency
- Optimistic everywhere: every entity has a
RowVersion(rowversionSQL column, EF concurrency token). Concurrent modifies →DbUpdateConcurrencyException→ 409 with the standardized "refresh and try again" message. - Pessimistic only for code sequences:
CodeSequencestable usesWITH (UPDLOCK, ROWLOCK)to serialize increments. - Audit write-back uses optimistic too — the review handler stamps the asset's history rows; concurrent reviews of the same line conflict and the second one re-fetches.
Testing infrastructure
tests/:
| Project | Coverage |
|---|---|
AssetTracking.Domain.Tests |
BaseEntity, BilingualExtensions, enum invariants |
AssetTracking.Application.Tests |
ValidationBehavior, key handlers (Login, Refresh, CreateUser, …) |
AssetTracking.API.Tests |
RequirePermission coverage test (reflection scan), ExceptionHandlingMiddleware mappings |
The coverage test reflects over every concrete Controller and every public Action, asserting each carries [RequirePermission] or [AllowAnonymous]. CI fails if a new endpoint sneaks in unguarded.
Where to go next
| To learn… | See |
|---|---|
| One module's commands, queries, business rules | 02-backend-modules |
| Every column, index, FK, constraint | 03-database |
| Endpoint signatures by controller | 07-api-reference |
| Build/run/seed/deploy | 06-operations |