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:

  1. If IUserContext.UserId == Guid.Empty → returns UnauthorizedResult (401).
  2. Calls IPermissionResolver.GetEffectivePermissionsAsync(userId) → cached HashSet<string>.
  3. Checks RequireAll (AND) or any (OR) against the requirement.
  4. 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/ValidUntil and UserPermission.ValidFrom/ValidUntil are honored.
  • Cache key: permissions:{userId}:{user.SecurityStamp}. TTL 30 minutes.
  • InvalidateCacheAsync(userId) removes by prefix permissions:{userId}:. Called whenever a user's roles or direct grants change.
  • Changing a role's permissions invalidates every user holding that role (AssignPermissionsToRoleCommandHandler enumerates affected users and clears each).

The cache is in-memory (InMemoryCacheService).

JWT — JwtTokenService

Infrastructure/Auth/JwtTokenService.cs. Configuration in appsettings.jsonJwtSettings:

{
  "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-expiredExpiresAt < 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:

  1. modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly) — picks up every IEntityTypeConfiguration<T> in Persistence/Configurations/.
  2. 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 with IgnoreQueryFilters().

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().
  • Id is never DB-generated: handlers must set Id = Guid.NewGuid() for every new entity, especially when adding multiple children in a graph. The AuditInterceptor does set Ids as a fallback, but only at SaveChanges time — change-tracking starts before that, so multiple unset Ids collide as Guid.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.

AuditInterceptorPersistence/Interceptors/AuditInterceptor.cs

For every BaseEntity entry on save:

State Mutation
Added If Id == Guid.Empty → assign new Guid. If CreatedAt == defaultnow. 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 via IgnoreQueryFilters) for the prefix, returns MAX + 1. Used where the CodeSequences table 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.

Email

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 + &nbsp; 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 (rowversion SQL column, EF concurrency token). Concurrent modifies → DbUpdateConcurrencyException → 409 with the standardized "refresh and try again" message.
  • Pessimistic only for code sequences: CodeSequences table uses WITH (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