shoc-backend/ARCHITECTURE_AND_CODE_QUALITY.md
Adam Moussa b7b22a8893
chore(repo): add PR template and README, retire stale root files
The org PR template pre-filled Summary / Validation / Tests / Notes here while
REVIEW_AND_PR_FRAMEWORK.md section 8 prescribes Summary / Changes and value /
Ticket. A repo template now overrides the org one, and the framework notes the
divergence from the org pr-policy workflow, which is not wired in.

README.md orients a reader: environments, architecture in one line, project
map, local commands, the governance gate, deployment, and a documentation map.

Cleanup: TODO.md is removed because Jira owns work status and its items are
stale or done. BACKEND_ARCHITECTURE.md is removed as superseded; the two
references now point at git history. .env.example loses its BOM and mojibake
dashes. .gitattributes keeps its one active rule.
2026-09-18 19:05:30 -04:00

14 KiB

Architecture and Code Quality (canonical)

Status: canonical. This document owns the meaning of the backend's architecture and code-quality rules. It replaced the earlier BACKEND_ARCHITECTURE.md, which now lives only in git history.

Companion documents:

  • QUALITY_GATES.md — how each rule is enforced (commands + CI mapping).
  • REVIEW_AND_PR_FRAMEWORK.md — who reviews, in what order, with what evidence, and how exceptions are recorded.
  • AGENTS.md — precedence and repo-specific agent delta.

1. Layering model

The backend is a feature-oriented service boundary over Entity Framework Core:

HTTP controller  ->  I{Feature}Service  ->  I{Feature}DataService  ->  ApplicationDbContext

The interfaces are architectural seams, not a mandate to wrap every class or every EF method. Add an interface when it separates HTTP, business, persistence, or external I/O responsibility and enables behavior-focused testing through the public contract.

Layer May depend on May not depend on
API controller I{Feature}Service, ILogger, framework types DbContext, concrete services, concrete data services, raw SQL, business workflow code
Business service (SeaHaven.Services) I{Feature}DataService, IOptions<T>, ILogger, pure ports (IServicePorts, IDispatchEmailPort, …) DbContext, IConfiguration, concrete data services, a generic repository
Data service (SeaHaven.DataServices) ApplicationDbContext, EF Core Business decisions, HTTP concerns
Composition / infrastructure Program.cs, *Module.cs, migrations, Identity Being depended on by controllers or business services

1.1 Controllers

  • Parse HTTP input, authorize the caller, invoke a business-service interface, and map the result to the established HTTP contract.
  • Depend only on I{Feature}Service interfaces — never on DbContext, EF, concrete service implementations, or concrete data-service implementations.
  • Contain no persistence queries and no business workflows.
  • Map known failures explicitly; let the centralized exception boundary handle unexpected failures (see §5).

1.2 Business services

  • Own validation, business decisions, orchestration, and transaction intent (the what of semantic atomicity — see §4).
  • Depend on feature-specific data/query/command interfaces, never on a generic repository and never on DbContext.
  • Use IOptions<T> for typed configuration. Never inject IConfiguration.
  • Keep feature responsibilities cohesive. Split a service when it has independently changing business reasons — not because it crossed an arbitrary line count (see §9, "no godfile threshold theater").

1.3 Data services

  • Own EF Core query shape, persistence, batching, and transaction mechanics (the how of commits — see §4).
  • Accept CancellationToken on new I/O methods and propagate it (see §6).
  • Use AsNoTracking() for read-only queries.
  • Project only required columns for list/read models; include full graphs only when the caller needs them.
  • Page unbounded collections at the database.
  • Avoid query-in-loop / save-in-loop patterns; prefer set-based reads and batched writes while preserving the workflow's failure and transaction semantics.
  • Do not introduce a generic repository over EF Core. Feature data services expose intent-revealing operations.

1.4 Infrastructure ownership and the EF allowlist

EF Core / DbContext is owned by approved infrastructure only. The explicit allowlist of places that may touch EF directly:

  • SeaHaven.DataServices.Implementation.* (feature data services).
  • Data.SeaHavenIndustries — the ApplicationDbContext, entity configuration, and migrations (see §7).
  • SeaHaven.DataServices.DependencyInjection.DataServicesModule — context registration and lifetime.
  • Identity / authentication composition in Program.cs and the DI modules (AddDbContext, AddIdentity, token/options wiring).
  • Transaction mechanics inside data services when semantic atomicity requires it (see §4).

Anything outside this allowlist that references EF / DbContext is an architecture violation. New allowlist entries require an ADR (see §10).

2. Tenant scope and authorization

  • Tenant scope is server-derived. Filtering by tenant/customer/owner is applied in the data access layer from authenticated principal claims, never from client-supplied request bodies or query strings.
  • Authorization remains at service entry. A business service's public method is the trust boundary: it authorizes the caller and resolves the effective tenant scope before doing work. Controllers express HTTP-level authorization ([Authorize], route/policy) and then delegate; they do not re-implement service-level authorization.
  • Data services receive an already-resolved, server-derived scope; they must not invent authorization rules of their own.

3. Dependency-direction enforcement (mechanical)

ArchitectureTests (in Api.SeaHavenIndustries.Tests) enforces mechanical dependency direction by reflection over constructor parameters:

  1. Controllers do not inject DbContext, concrete data services, or concrete service implementations.
  2. Business services do not inject DbContext or IConfiguration.
  3. Business services depend on interfaces for their data/persistence dependencies, not on concrete data-service or service implementations.
  4. Discovery must not silently pass on an empty set. Each invariant asserts the discovered type set is non-empty, so the gate fails if reflection ever returns zero controllers/services (e.g., after a namespace move). A passing run therefore means "real types were checked," not "nothing was found."

These tests cover dependency direction. Behavior lives in behavior tests through public interfaces. Do not add tests merely to prove a linter or analyzer itself works — test the product behavior the rule protects. Do not claim full runtime properties from constructor reflection; it proves wiring shape, not execution behavior.

4. Transaction semantics and the one commit convention

  • Services own semantic atomicity. A business service decides which set of operations constitutes a unit of work and expresses that intent.
  • Scoped infrastructure owns transaction mechanics. The actual commit boundary is DbContext.SaveChangesAsync(...) and lives inside a data service. A service must not sprinkle independent SaveChanges calls across data-service boundaries in a way that breaks the semantic unit.
  • The repo documents one commit convention: a single SaveChangesAsync(CancellationToken) inside the owning data service is the atomic commit boundary. This is the current convention observed across the data-service layer.
  • When a unit of work genuinely requires multiple statements to be all-or- nothing, use EF Core's transaction (IDbContextTransaction) inside the data service. Explicit transactions are infrastructure (EF allowlist), owned by data services, never by controllers or business services.
  • Do not "optimize" atomicity away: collapsing several semantically independent saves into one final save changes partial-success behavior and is a business decision, not mechanical cleanup.

5. Error disclosure and structured logging (security)

  • No HTTP response may expose arbitrary exception messages, stack traces, or SQL/provider/path/credential/crypto detail. Known failures are mapped to explicit public messages; unexpected failures are funneled through the centralized boundary (SanitizedErrors), which returns a stable message plus a correlation reference and never echoes internal exception text.
  • Correlated structured internal logs record the original exception at Error level with a correlation id, so incidents are traceable without leaking detail to the client.
  • Secrets are never logged. Configuration values, tokens, connection strings, and credentials are treated as redacted; structured log templates must not interpolate secret-bearing fields.
  • The SanitizedErrorsTests guard the public-facing contract (stable message, correlation id present, original exception text absent, original logged at Error).

6. Cancellation

  • New async I/O methods accept a CancellationToken where the public contract permits and propagate it to EF Core / downstream I/O.
  • Do not falsely claim propagation. A CancellationToken parameter that is ignored, swallowed, or replaced with default/CancellationToken.None at the call site is a defect, not a courtesy. The signature is a contract.
  • Enforcement combines behavior tests (assert the token reaches the data service / EF call for changed I/O paths) and analyzers where available. Either is acceptable evidence; silence is not. For changed paths, review must confirm forwarding, not merely the presence of the parameter.

7. Database migrations

Migration gates require actual-provider evidence, not reflection or assumptions:

  • Actual-provider discovery — the migration provider is the configured database provider (e.g., SQL Server / Npgsql), discovered from the runtime configuration, not inferred from package references.
  • Snapshot consistency — ModelSnapshot must be consistent with the model after the migration set (dotnet ef ... --output / script diff with no phantom operations).
  • Empty-db apply — applying the full migration history to an empty database must reproduce the current model with no errors.
  • Supported-prior upgrade — upgrading from each still-supported prior state applies cleanly.
  • No untested rollback claims. Do not assert "down/rollback is supported" unless a downgrade is actually exercised against the real provider; otherwise state rollback as not guaranteed. EF "EnsureDeleted"/re-create is not a migration rollback.

8. Query performance and complexity

For each changed hot path, document and test:

  • input-size variables;
  • CPU and memory complexity (Big-O) for any in-memory reconstruction;
  • database round trips;
  • whether filtering, ordering, and paging execute in SQL vs. in memory;
  • whether tracking is necessary;
  • whether repeated work can be hoisted out of loops;
  • the partial-failure and transaction behavior.

Distinguish two classes of finding:

  • Analyzable anti-patterns — query-in-loop / save-in-loop, unbounded load followed by in-memory Where, repeated First/Single scans over a result (prefer dictionary/hash-set reconstruction, normally O(n) vs O(n²)). These can be flagged by inspection.
  • SQL-backed evidence — actual paging/filtering/sort location, query count, and plan shape must be backed by provider query logs, ToQueryString(), store-generated SQL, or a plan/count measurement. Do not assert "executes in SQL" or "single round trip" from LINQ shape alone.

Do not optimize by weakening correctness: batching all writes into one final save can change partial-success behavior; adding a transaction can change locking/retry semantics. Both are business decisions.

9. Size, complexity, and "no godfile theater"

  • We do not enforce arbitrary line-count or file-size thresholds as quality gates. A number is not an architecture.
  • A file is too large when it has independently changing responsibilities (low cohesion) or untestable coupling, not when it crosses a count. Split on seams, not on a counter; describe the seam in the PR.
  • Gates that operate on size must name the consequence (e.g., "two unrelated features in one service") and be backed by review, not by a magic threshold.

10. Exceptions and ADRs

Some rules here are hard (EF allowlist, no client exception leakage, server- derived tenant scope). A deviation from a hard rule requires an Architecture Decision Record under docs/adr/ (create the path if absent):

  • A short NNNN-title.md stating context, decision, consequences, and the specific rule being excepted.
  • The excepted rule and an expiry/review date.
  • Reference from the PR that relies on it.

Without an ADR, the rule stands as written. Explicit, documented exceptions only — no wildcard suppressions (no global [SuppressMessage], no .editorconfig dotnet_diagnostic.*.severity = none sweeps, no #pragma swaths). A suppression must name the single diagnostic and cite the ADR/justification.

11. Configuration

Configuration is bound once during composition in SeaHaven.Services/DependencyInjection/ServicesModule.cs. Business services receive typed options (FrontendOptions, JwtOptions, ApprovalsOptions, VendorPortalOptions, …). Defaults must preserve current behavior. Startup validation is introduced only when every deployed environment is known to provide the required value.

12. Review checklist (quick reference)

  • Controller depends only on I{Feature}Service abstractions.
  • Business logic is in a cohesive feature service.
  • EF operations are behind a feature data-service abstraction.
  • No raw IConfiguration in a business service.
  • No unbounded load + in-memory filtering.
  • No query/save loop where a set-based operation preserves semantics.
  • Read-only EF queries use no tracking.
  • New async I/O accepts and propagates cancellation where the contract permits (verified, not just signed).
  • Errors do not leak internal exception details; secrets are not logged.
  • Tenant scope is server-derived; authorization enforced at service entry.
  • Commit convention respected (single SaveChangesAsync in the owning data service; transactions only in data services when required).
  • Tests cover behavior, authorization, data contracts, ordering, duplicates, missing records, and relevant failure boundaries.
  • Changed behavior checked against the authoritative ticket board (see REVIEW_AND_PR_FRAMEWORK.md). Missing board access blocks readiness/merge.