mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
274 lines
14 KiB
Markdown
274 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. On any conflict with the older
|
||
|
|
> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained
|
||
|
|
> only as historical reference pending removal.
|
||
|
|
>
|
||
|
|
> 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.
|