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>
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)
ApplicationDbContextis never injected into a controller. Ever.All data access in controllers goes through
IUnitOfWork. Complex queries that the genericRepository<T>cannot express live in typed repositories or read services — both accessible throughIUnitOfWork.
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 summaryGetMonthlyRevenueAsync(int companyId, int months)→ monthly invoiced vs collectedGetTopOutstandingCustomersAsync(int companyId, int count)→ largest open balancesGetCashFlowProjectionAsync(int companyId, int days)→ forward-looking cash positionGetAnomaliesAsync(int companyId, int lookbackDays)→ bill/expense anomaly detectionGetRecentPaymentsAsync(int companyId, int count)→ recent payment activity
IOperationalReportService
GetJobCycleTimeAsync(int companyId, DateTime start, DateTime end)→ avg days per stageGetPowderUsageAsync(int companyId, DateTime start, DateTime end)→ usage by color/vendorGetWorkerProductivityAsync(int companyId, DateTime start, DateTime end)→ jobs per workerGetOvenUtilizationAsync(int companyId, DateTime start, DateTime end)→ oven throughputGetReworkRateAsync(int companyId, DateTime start, DateTime end)→ defect/rework trendsGetStatusFlowAsync(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,IOperationalReportServiceinCore/Interfaces/Services/ - Create
Infrastructure/Repositories/directory - Implement all typed repositories (move include chains from controllers)
- Implement
FinancialReportService(move aggregate queries fromReportsController) - Implement
OperationalReportService - Extend
IUnitOfWorkwith 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)
InvoicesController→IInvoiceRepositoryJobsController→IJobRepositoryQuotesController→IQuoteRepositoryCustomersController→ICustomerRepositoryBillsController→IBillRepositoryPurchaseOrdersController→IPurchaseOrderRepositoryReportsController→IFinancialReportService+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.
AnnouncementsControllerAiQuickQuoteControllerAiUsageReportControllerAuditLogController→ permanent exception (Identity/platform infra)BannedIpsControllerBugReportControllerCompaniesControllerCompanySettingsControllerCompanyUsersControllerDashboardControllerDashboardTipsControllerDepositsControllerEmailBroadcastController→ permanent exception (Identity fan-out)ExpensesControllerInAppNotificationsControllerInventoryControllerJobsPriorityControllerJobTemplatesControllerNotificationLogsControllerPasskeyController→ permanent exception (WebAuthn/FIDO2 identity infra)PlatformNotificationsControllerQuoteApprovalControllerReleaseNotesControllerRevenueController→ permanent exception (cross-tenant MRR/ARR)SetupWizardControllerSmsConsentAuditControllerStripeEventsController→ permanent exception (platform infra table)SubscriptionManagementController→ permanent exception (platform-level cross-tenant)UnsubscribeControllerUsageQuotaController→ permanent exception (bulk GROUP BY)UserActivityController→ permanent exception (Identity entities)VendorsController
Phase 4 — Enforcement ✓ COMPLETE (2026-04-28)
EnforceDataAccessArchitecture()added toProgram.cs— scans all Controller subclasses at startup via reflection and throwsInvalidOperationExceptionif any non-exempt controller hasApplicationDbContextin 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.mdto 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:
- Does the controller inject
ApplicationDbContext? If yes and it's not in the permanent exceptions list → request changes. - Is a complex include chain written inline in a controller action? → move to typed repository.
- Is a GROUP BY / aggregate query inline in a controller action? → move to report service.
- Does a new typed repository method duplicate logic already in another repository? → consolidate.
- Are all DbContext calls in report services using
.AsNoTracking()? → required for read services.