Files
PowderCoatingLogix/docs/DATA_ACCESS_ARCHITECTURE.md
T
spouliot 1cb7a8ca4a 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>
2026-04-28 09:17:29 -04:00

16 KiB

Data Access Architecture

Status: Complete ✓ (2026-04-28)

This document defines the target data access architecture for Powder Coating Logix and tracks the migration from the current mixed pattern to the clean layered pattern.


The Problem

The codebase currently has ~50 controllers injecting ApplicationDbContext directly alongside IUnitOfWork. This happened organically: the generic Repository<T> could not express complex multi-level include queries, so _context became the escape hatch. Once injected for one complex query, it was used for everything else in that controller too. The inconsistency compounds with every new controller a developer writes.

For a solo developer this is manageable. For a team it creates a daily decision tax — "which pattern do I follow?" — with no clear answer. New developers copy the nearest example, which is usually _context, so the problem grows.


The Rule (Short Version)

ApplicationDbContext is never injected into a controller. Ever.

All data access in controllers goes through IUnitOfWork. Complex queries that the generic Repository<T> cannot express live in typed repositories or read services — both accessible through IUnitOfWork.


Target Architecture

Controllers (Presentation Layer)
    │
    ├── IUnitOfWork.EntityName          → IRepository<T>          Simple CRUD
    ├── IUnitOfWork.Jobs                → IJobRepository           Complex domain queries
    ├── IUnitOfWork.Invoices            → IInvoiceRepository       Complex domain queries
    ├── IUnitOfWork.Quotes              → IQuoteRepository         Complex domain queries
    ├── IUnitOfWork.Customers           → ICustomerRepository      Complex domain queries
    ├── IUnitOfWork.Bills               → IBillRepository          Complex domain queries
    │
    ├── IFinancialReportService         Aggregate/reporting reads
    └── IOperationalReportService       Aggregate/reporting reads

Infrastructure Layer (the only layer that knows about ApplicationDbContext)
    │
    ├── Repository<T>                   Generic implementation
    ├── JobRepository : IJobRepository  Typed implementations
    ├── InvoiceRepository              ...
    ├── QuoteRepository                ...
    ├── CustomerRepository             ...
    ├── BillRepository                 ...
    │
    ├── FinancialReportService          DbContext used directly (read-only, no tracking)
    └── OperationalReportService        DbContext used directly (read-only, no tracking)

ApplicationDbContext never crosses into the Presentation layer. It lives in Infrastructure and only Infrastructure.


Three Tiers of Data Access

Tier 1 — Simple CRUD → Generic IRepository<T> via IUnitOfWork

Use for: single-entity lookups, lists, adds, soft deletes, simple filtered queries.

// Good
var items = await _unitOfWork.CatalogItems.GetAllAsync();
var item  = await _unitOfWork.CatalogItems.GetByIdAsync(id);
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();

Entities in this tier (generic repo is sufficient):

  • Announcements, BugReports, CatalogItems, CatalogCategories, CatalogPriceCheckReports
  • CompanyBlastSetups, CompanyOperatingCosts, CompanyPreferences
  • ContactSubmissions, CreditMemos, CreditMemoApplications
  • DashboardTips, Deposits, Equipment
  • GiftCertificates, GiftCertificateRedemptions
  • InventoryItems, InventoryTransactions
  • JobChangeHistories, JobDailyPriorities, JobItemCoats, JobItems, JobNotes, JobPhotos
  • JobStatusHistory, JobTemplates, JobTemplateItems, JobTemplateItemCoats, JobTemplateItemPrepServices
  • JobTimeEntries, MaintenanceRecords, ManufacturerLookupPatterns
  • NotificationLogs, NotificationTemplates
  • OvenBatches, OvenBatchItems, OvenCosts
  • Payments, PrepServices, PricingTiers
  • PowderUsageLogs, PurchaseOrderItems
  • QuoteChangeHistories, QuoteItemCoats, QuoteItems, QuoteItemPrepServices, QuotePhotos
  • Refunds, ReworkRecords
  • ShopWorkers, ShopWorkerRoleCosts, SubscriptionPlanConfigs
  • Vendors

Tier 2 — Complex Domain Queries → Typed Repositories

Use for: multi-level include chains, domain-specific filtered loads, queries that require IgnoreQueryFilters, queries that span multiple related entities in non-trivial ways.

The typed repository interface lives in Core/Interfaces/Repositories/. The implementation lives in Infrastructure/Repositories/. The property is on IUnitOfWork — same access point as Tier 1.

// Good — the complex include chain lives in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);

IJobRepository

Method Purpose
LoadForDetailsAsync(int id) Full include chain for Job Details view
LoadForEditAsync(int id) Includes needed for Job Edit form
LoadForBoardAsync(int companyId, ...) Jobs for the kanban board with status/priority filters
GetByStatusAsync(int companyId, int statusId) Filtered by status with customer include
GetAssignedToWorkerAsync(int workerId) All active jobs for a worker
GetOverdueAsync(int companyId) Jobs past due date

IInvoiceRepository

Method Purpose
LoadForViewAsync(int id) Full 8-table include chain (current LoadInvoiceForViewAsync)
GetOverdueAsync(int companyId) Invoices past due date with customer info
GetByPaymentTokenAsync(string token) Online payment portal lookup
GetForJobAsync(int jobId, bool includeDeleted) Invoice for a given job (1:1 check)

IQuoteRepository

Method Purpose
LoadForViewAsync(int id) Full include chain for Quote Details
LoadForEditAsync(int id) Includes needed for wizard edit
GetByApprovalTokenAsync(string token) Customer approval portal lookup
GetPendingApprovalsAsync(int companyId) Quotes awaiting customer approval

ICustomerRepository

Method Purpose
LoadForDetailsAsync(int id) Customer with jobs, quotes, invoices, notes summary
GetWithOutstandingBalancesAsync(int companyId) AR summary data
FindByEmailAsync(string email, int companyId) Duplicate check on create/edit

IBillRepository

Method Purpose
LoadForViewAsync(int id) Bill with line items, payments, vendor
GetApPayablesAsync(int companyId) Open AP ledger with aging

IPurchaseOrderRepository

Method Purpose
LoadForViewAsync(int id) PO with line items and vendor
GetByStatusAsync(int companyId, string status) Filtered PO list

Tier 3 — Aggregate/Reporting Queries → Read Services

Use for: P&L calculations, AR aging, powder usage aggregates, job cycle time, any query that uses GROUP BY, window functions, or multi-table joins that return shaped result DTOs rather than tracked entities.

These services are injected directly into controllers alongside IUnitOfWork. They use ApplicationDbContext internally (with .AsNoTracking()) — that is correct and intentional, because they live in Infrastructure.

// Controller constructor
public ReportsController(IUnitOfWork unitOfWork, IFinancialReportService financialReports, ...)

// Usage
var aging = await _financialReports.GetArAgingAsync(companyId);
var pl    = await _financialReports.GetProfitLossAsync(companyId, startDate, endDate);

IFinancialReportService

  • GetArAgingAsync(int companyId) → AR aging buckets (current, 30, 60, 90+ days)
  • GetProfitLossAsync(int companyId, DateTime start, DateTime end) → P&L summary
  • GetMonthlyRevenueAsync(int companyId, int months) → monthly invoiced vs collected
  • GetTopOutstandingCustomersAsync(int companyId, int count) → largest open balances
  • GetCashFlowProjectionAsync(int companyId, int days) → forward-looking cash position
  • GetAnomaliesAsync(int companyId, int lookbackDays) → bill/expense anomaly detection
  • GetRecentPaymentsAsync(int companyId, int count) → recent payment activity

IOperationalReportService

  • GetJobCycleTimeAsync(int companyId, DateTime start, DateTime end) → avg days per stage
  • GetPowderUsageAsync(int companyId, DateTime start, DateTime end) → usage by color/vendor
  • GetWorkerProductivityAsync(int companyId, DateTime start, DateTime end) → jobs per worker
  • GetOvenUtilizationAsync(int companyId, DateTime start, DateTime end) → oven throughput
  • GetReworkRateAsync(int companyId, DateTime start, DateTime end) → defect/rework trends
  • GetStatusFlowAsync(int companyId, DateTime start, DateTime end) → job status transitions

Permanent Exceptions

The following controllers are intentionally allowed to inject ApplicationDbContext directly. This is not a smell — it is correct for their use cases. Each file has a comment explaining why.

Controller Reason
StripeWebhookController Idempotency key lookup must bypass soft-delete and tenant filters
WebhooksController Twilio raw event handling; same reasoning as Stripe
PaymentController Stripe Connect embedded payment flow; raw session state needed
RegistrationController PendingRegistrationSession queries bypass normal tenant scoping
DataExportController Bulk streaming export; repository pattern adds unnecessary overhead
AccountDataExportController Same as above
DataPurgeController Destructive bulk operations; needs direct transaction control
SystemInfoController Infrastructure diagnostics; queries metadata, not business data
SystemLogsController Log table queries; not a business entity
CompanyHealthController Cross-tenant health checks for SuperAdmin; ignores all filters
PasskeyController WebAuthn/FIDO2 identity infrastructure; UserPasskeys is an ASP.NET Identity concern outside IUnitOfWork; anonymous login path has no tenant context
AuditLogController Append-only audit log with long PK; platform infrastructure table outside the business entity graph; same reasoning as SystemLogsController
UserActivityController Queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork
EmailBroadcastController Cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork
RevenueController Cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController
StripeEventsController StripeWebhookEvents is a platform infrastructure table, not a business entity; same reasoning as StripeWebhookController
SubscriptionManagementController Cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern
UsageQuotaController Cross-tenant bulk GROUP BY quota queries; routing through IUnitOfWork would require O(n) repository round-trips

If you think you need to add a controller to this list, you almost certainly don't. Ask first.


Migration Roadmap

Phase 1 — Foundation (no behavior change)

  • Create Core/Interfaces/Repositories/ directory
  • Define IJobRepository, IInvoiceRepository, IQuoteRepository, ICustomerRepository, IBillRepository, IPurchaseOrderRepository
  • Define IFinancialReportService, IOperationalReportService in Core/Interfaces/Services/
  • Create Infrastructure/Repositories/ directory
  • Implement all typed repositories (move include chains from controllers)
  • Implement FinancialReportService (move aggregate queries from ReportsController)
  • Implement OperationalReportService
  • Extend IUnitOfWork with typed repository properties
  • Register all new types in Program.cs
  • Build passes, all tests green — no controller has changed yet

Phase 2 — Complex controller migration ✓ COMPLETE (2026-04-27)

  • InvoicesControllerIInvoiceRepository
  • JobsControllerIJobRepository
  • QuotesControllerIQuoteRepository
  • CustomersControllerICustomerRepository
  • BillsControllerIBillRepository
  • PurchaseOrdersControllerIPurchaseOrderRepository
  • ReportsControllerIFinancialReportService + IOperationalReportService

Phase 3 — Simple controller sweep ✓ COMPLETE (2026-04-28)

Remove ApplicationDbContext injection from all controllers not in the permanent exceptions list, replacing with existing IUnitOfWork generic repository calls.

  • AnnouncementsController
  • AiQuickQuoteController
  • AiUsageReportController
  • AuditLogController → permanent exception (Identity/platform infra)
  • BannedIpsController
  • BugReportController
  • CompaniesController
  • CompanySettingsController
  • CompanyUsersController
  • DashboardController
  • DashboardTipsController
  • DepositsController
  • EmailBroadcastController → permanent exception (Identity fan-out)
  • ExpensesController
  • InAppNotificationsController
  • InventoryController
  • JobsPriorityController
  • JobTemplatesController
  • NotificationLogsController
  • PasskeyController → permanent exception (WebAuthn/FIDO2 identity infra)
  • PlatformNotificationsController
  • QuoteApprovalController
  • ReleaseNotesController
  • RevenueController → permanent exception (cross-tenant MRR/ARR)
  • SetupWizardController
  • SmsConsentAuditController
  • StripeEventsController → permanent exception (platform infra table)
  • SubscriptionManagementController → permanent exception (platform-level cross-tenant)
  • UnsubscribeController
  • UsageQuotaController → permanent exception (bulk GROUP BY)
  • UserActivityController → permanent exception (Identity entities)
  • VendorsController

Phase 4 — Enforcement ✓ COMPLETE (2026-04-28)

  • EnforceDataAccessArchitecture() added to Program.cs — scans all Controller subclasses at startup via reflection and throws InvalidOperationException if any non-exempt controller has ApplicationDbContext in its constructor. The app cannot start with a violation.
  • Permanent exceptions list hardcoded in the enforcement function (18 controllers).
  • This document status updated to Complete.
  • Update CLAUDE.md to mark migration complete (optional — CLAUDE.md already reflects the rule)

File Locations Reference

src/
  PowderCoating.Core/
    Interfaces/
      IRepository.cs                        existing
      IUnitOfWork.cs                        existing — extended in Phase 1
      Repositories/                         NEW in Phase 1
        IJobRepository.cs
        IInvoiceRepository.cs
        IQuoteRepository.cs
        ICustomerRepository.cs
        IBillRepository.cs
        IPurchaseOrderRepository.cs
      Services/                             NEW in Phase 1
        IFinancialReportService.cs
        IOperationalReportService.cs

  PowderCoating.Infrastructure/
    Repositories/                           NEW in Phase 1
      UnitOfWork.cs                         existing — extended
      Repository.cs                         existing
      JobRepository.cs
      InvoiceRepository.cs
      QuoteRepository.cs
      CustomerRepository.cs
      BillRepository.cs
      PurchaseOrderRepository.cs
    Services/
      FinancialReportService.cs             NEW in Phase 1
      OperationalReportService.cs           NEW in Phase 1
      NotificationService.cs               existing — correct as-is
      PdfService.cs                        existing — correct as-is

Code Review Checklist

When reviewing a PR that touches data access:

  1. Does the controller inject ApplicationDbContext? If yes and it's not in the permanent exceptions list → request changes.
  2. Is a complex include chain written inline in a controller action? → move to typed repository.
  3. Is a GROUP BY / aggregate query inline in a controller action? → move to report service.
  4. Does a new typed repository method duplicate logic already in another repository? → consolidate.
  5. Are all DbContext calls in report services using .AsNoTracking()? → required for read services.