Phases 3 & 4: Complete data access architecture migration

Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
+66
View File
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
@@ -209,6 +210,11 @@ builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
builder.Services.AddScoped<IDashboardReadService, DashboardReadService>();
builder.Services.AddScoped<ICompanyListService, CompanyListService>();
builder.Services.AddScoped<ICompanyDataPurgeService, CompanyDataPurgeService>();
builder.Services.AddScoped<IPdfService, PdfService>();
builder.Services.AddScoped<ISeedDataService, SeedDataService>();
builder.Services.AddScoped<ICompanyConfigHealthService, CompanyConfigHealthService>();
@@ -835,6 +841,11 @@ using (var scope = app.Services.CreateScope())
// Fail fast with a clear message rather than a cryptic runtime error later.
ValidateRequiredConfiguration(app.Configuration, app.Environment);
// ── Data access architecture enforcement ─────────────────────────────────────
// Throws at startup if any non-exempt controller injects ApplicationDbContext directly.
// This is the Phase 4 gate: the app cannot start with a violation.
EnforceDataAccessArchitecture();
try
{
Log.Information("Starting web application");
@@ -894,6 +905,61 @@ static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnviron
}
}
// ── Data access architecture enforcement ─────────────────────────────────────────
/// <summary>
/// Scans every Controller subclass in the Web assembly at startup and throws if any
/// non-exempt controller declares ApplicationDbContext as a constructor parameter.
/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer
/// adds a new controller that injects ApplicationDbContext directly, the app will refuse
/// to start with a clear message naming the violator.
/// </summary>
static void EnforceDataAccessArchitecture()
{
// Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md.
var permanentExceptions = new HashSet<string>
{
"StripeWebhookController",
"WebhooksController",
"PaymentController",
"RegistrationController",
"DataExportController",
"AccountDataExportController",
"DataPurgeController",
"SystemInfoController",
"SystemLogsController",
"CompanyHealthController",
"PasskeyController",
"AuditLogController",
"UserActivityController",
"EmailBroadcastController",
"RevenueController",
"StripeEventsController",
"SubscriptionManagementController",
"UsageQuotaController",
};
var violators = typeof(Program).Assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract
&& typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t)
&& !permanentExceptions.Contains(t.Name))
.Where(t => t.GetConstructors()
.Any(ctor => ctor.GetParameters()
.Any(p => p.ParameterType == typeof(ApplicationDbContext))))
.Select(t => t.Name)
.OrderBy(n => n)
.ToList();
if (violators.Count == 0) return;
var names = string.Join(", ", violators);
throw new InvalidOperationException(
$"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " +
$"and are not in the permanent exceptions list.\n" +
$"Violators: {names}\n" +
$"Fix: route data access through IUnitOfWork. " +
$"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md.");
}
// ── Serilog DB sink column configuration ─────────────────────────────────────────
static ColumnOptions BuildLogColumnOptions()
{