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

272 lines
14 KiB
Markdown

# 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:
```text
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.