Status: Active Derives from: ADR 0002 — Initial Architecture, ADR 0006 — Events and Outbox, ADR 0023 — Strongly-Typed ID Source Generator, ADR 0031 — PostgreSQL Major Version.
C# / .NET conventions for LearnStack backend code.
- Target framework:
net10.0. - C# language version: latest stable.
Nullableenabled on every project (<Nullable>enable</Nullable>).TreatWarningsAsErrorsset totruein CI.ImplicitUsingsenabled in modern projects.LangVersion=latest.- File-scoped namespaces everywhere.
| Element | Convention |
|---|---|
| Namespaces | LearnStack.Modules.Education.Application |
| Classes / records / structs | PascalCase |
| Interfaces | PascalCase prefixed with I (ICourseRepository) |
| Methods | PascalCase |
| Private fields | _camelCase |
| Parameters / locals | camelCase |
| Constants | PascalCase |
| Enums | PascalCase; members PascalCase, no _ prefix |
| Async methods | PascalCase ending in Async |
| Test classes | <TargetClassName>Tests |
| Test methods | Method_Scenario_ExpectedOutcome |
- Records for immutable value-like data: DTOs, integration events, configuration options.
- Sealed classes by default; open inheritance is the exception.
- Structs only for small, immutable, frequently-allocated values (≤ 16 bytes).
- Strongly-typed ids (
partial record struct CourseId : IStronglyTypedId<Guid>;per the Vogen pattern below) for all entity identifiers. Never expose rawGuidon the public surface. - Value objects for domain concepts with invariants (e.g.
Email,Slug,LocaleCode).
Per ADR-0023, the
shared source generator is Vogen. The
canonical declaration uses Vogen's [ValueObject<Guid>(...)] annotation on a
partial record struct:
[ValueObject<Guid>(LearnStackVogenDefaults.IdMask)]
public readonly partial record struct CourseId : IStronglyTypedId<Guid>;Vogen emits per ID:
- EF Core value converter.
JsonConverter(System.Text.Json).- TypeConverter (carries ASP.NET Core minimal-API + MVC route-parameter binding).
- OpenAPI schema mapping (wired centrally in Packet 4 per ADR-0023 § Implementation Notes).
Construction:
- New IDs in aggregate methods mint via the injected
IGuidFactory:CourseId.From(guidFactory.NewUuidV7()). Never callGuid.CreateVersion7()/Guid.NewGuid()directly inDomain/Applicationcode — Standards 02 § Time bans the symmetricDateTime.UtcNowfor the same reason (deterministic tests). High-volume append-only tables (audit_log,outbox_messages) prefer DB-sidegen_uuid_v7()(per ADR-0031). - ID types do not expose a
New()static — explicitFrom(guidFactory.NewUuidV7())at the call site keeps the dependency surface honest.
The same annotation covers richer value objects (Email, Slug, LocaleCode,
Money) — the emitter shape is identical for IDs and value objects, with the
value-object's invariant captured in a Validate static method.
Nullableis on. Treat warnings as errors.- Reference types are non-nullable unless declared
T?. - Never use
!(null-forgiving operator) without a comment explaining why. - Prefer
ArgumentNullException.ThrowIfNull(param)at public boundaries. - Return
Result<T>orMaybe<T>for expected absences; reserve null for true uninitialized state.
- Public methods that perform I/O end in
Asyncand acceptCancellationToken ct. - Always pass
ctdown. - Never
Task.Wait()or.Resultin production code. Useawaitend to end. ValueTask<T>only when profiling shows allocation pressure.- Avoid
async voidexcept in event handlers framed by frameworks.
Two patterns coexist:
- Exceptions for unexpected failures (bug, transient infra, programming error).
Result<T>for expected outcomes (validation failure, not found, conflict).
public sealed record Result<T> : IResultBase
{
internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null) { ... }
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public Error? Error { get; }
public LocalizedMessage? SuccessMessage { get; }
// Throws when value is null — Standards 09 § Forbidden bans
// IsSuccess = true with Value = null. For payload-less success use
// Result<Unit>.
public static Result<T> Ok(T value, LocalizedMessage? message = null);
public static Result<T> Fail(Error error);
}
public sealed record Error(
LocalizedMessage Message,
IReadOnlyDictionary<string, IReadOnlyList<LocalizedMessage>>? Details = null)
{
// Stable machine-readable identifier — Standards 04 § Problem Details
// "code". Derived from Message.Key by stripping the lockey_ prefix so
// the code never drifts from the localization key by construction.
public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..];
}LocalizedMessage's constructor enforces the lockey_ key prefix; the
constructor of Result<T> is internal so callers cannot bypass the
Ok / Fail factory invariants via positional record syntax. See
09-error-handling.md § Result Type and
Phase 02a Packet 2.
Use cases for Result<T>:
- Validation outcomes.
- Optimistic concurrency conflicts.
- Domain rule violations expected to be common.
Exceptions stay for things like "database is down" or "the program is in a bug state."
Each use case is a command or query:
public sealed record PublishCourseCommand(CourseId CourseId, UserId ActorId) : IRequest<Result<CourseVersionId>>;
public sealed class PublishCourseHandler : IRequestHandler<PublishCourseCommand, Result<CourseVersionId>>
{
public async Task<Result<CourseVersionId>> Handle(PublishCourseCommand command, CancellationToken ct)
{
// ...
}
}Rules:
- Handlers are thin; orchestrate domain methods and persistence.
- One transaction per handler.
- Validation lives in FluentValidation validators; pipeline behavior short-circuits invalid commands.
- Logging, tracing, and metrics live in pipeline behaviors, not in handlers.
FluentValidationfor command and DTO validation.- Domain invariants enforced in domain methods, not duplicated in validators.
- Validation failures return
Result<T>with avalidation_failederror code and field-level details.
- One
DbContextper module (no monolithic context). - Entity configurations in dedicated
*Configuration : IEntityTypeConfiguration<T>classes; never inline inOnModelCreatingbody. - Global query filters configured via a base configuration method for tenant-owned entities.
- Migrations generated per module; CI checks that the migration is included when a config changes.
- No lazy loading. Explicit
.Include()only when needed; prefer projection (Select(...)). - Avoid
Trackingfor read-only queries: useAsNoTracking(). - Avoid
stringinterpolated SQL. Use parameterized queries.
- Aggregates are the only entry points for state changes.
- Aggregate methods enforce invariants; setters are private.
- Domain events raised from aggregate methods; collected by the unit-of-work and dispatched on commit.
- Avoid anemic models (data + getters/setters with logic outside).
- Avoid primitive obsession; use value objects.
Standard MediatR pipeline (in order; outermost first, innermost last). Bound by ADR-0032 § Sub-decision 2 and consistent with ADR-0033, which keeps this order and changes only the durability contract of what step 3 records (ADR-0033 supersedes ADR-0016):
-
ValidationBehavior— FluentValidation. Invalid input → returnsResult.Fail(validation_failed, errors); never throwsValidationException. Short-circuits the request before any DB / audit / business code runs. -
LoggingBehavior— Opens theILogger.BeginScopecarrying the eight correlation fields (10-observability.md § Correlation), starts the manual<module>.<operation>Activity, and measures handler latency for the histogram metric. -
AuditLogBehavior— Wraps the inner pipeline withtry / catch. On exception it records the failure outcome and rethrows viaExceptionDispatchInfoto preserve the original stack.Per ADR-0033 this behavior keeps its position and decides; it does not own the durable write. On the way in it classifies
(module, operation)from the in-process audit catalogue plus the tenant's cachedaudit_configoverrides — it issues no query, because at step 3 no transaction is open,app.tenant_idis unset, andaudit_configis RLS-protected, so a read there would return zero rows silently. For MUST it mints the audit id and parks a pending intent in the scopedIAuditStateCapture, touching noDbContext.On the way out it reconciles: if the intent's state is anything other than
Committed— never written, rolled back, or a commit whose outcome is unknown — it writes the row standalone with the real outcome, in its own short transaction. "Written" is not "committed", and a per-request flag cannot observe a rollback. A MUST-class audit that cannot be written at all fails closed: the caller receives503 audit_unavailable. SHOULD/MAY-class entries stay best-effort — written on the same outbound pass, logged on failure, never blocking the business operation. -
TenantContextBehavior— AssertsITenantContext.IsResolved(theTenantResolverMiddlewarepopulated it from the inbound HTTP request, the HangfireJobActivatorpopulated it from the job payload, or the integration-event handler scope populated it from the event envelope) and carries the resolved tenant + organization forward for the rest of the pipeline. Unresolved context short-circuits withResult.Fail(tenant_mismatch)unless the request carries[AllowsUnresolvedTenantContext].This behavior does not set the PostgreSQL session variables. It runs at step 4; the transaction opens at step 6; and
set_config('app.tenant_id', …, true)/SET LOCALare transaction-local, so a value set here is discarded before the transaction that needs it ever begins. The same objection rules out aDbConnectionInterceptor, which fires at connection open rather than at transaction start. Security Standards § Tenant Context is the single authority for where the session variables are set; the canonical policy template that reads them lives in Database Standards § Tenant-Owned and Organization-Scoped Tables. -
AuthorizationBehavior—IAuthorizationService.AuthorizeAsyncagainst the command's resource. Denial returnsResult.Fail(forbidden); no exception. -
TransactionBehavior— Opens the ambient transaction throughIUnitOfWorkand, as its first statement inside that transaction, issuesSET LOCAL app.tenant_id/app.organization_idfrom theITenantContextstep 4 asserted, so Row Level Security evaluates every subsequent statement — including the MUST-class audit insert — against the right values. Commits on a success-Result; rolls back on a fail-Resultor any exception that bubbles through. No transaction for forbidden or validation-failed requests because those short-circuit upstream.This behavior owns the commit boundary, and therefore owns two further responsibilities per ADR-0033. First, immediately before
COMMITit callsIAuditStore.WritePendingAsync, which inserts the complete MUST-class audit row on this transaction — a no-op when no intent is pending, and a rollback plusaudit_unavailablewhen it fails. Placing the write here rather than in the EF interceptor is deliberate: at pre-commit every flush has happened, so the row's snapshots are complete however many times the handler saved. Second, it records the outcome onIAuditStateCapture—CommittedonceCommitAsyncreturns,RolledBackafter a rollback,IndeterminatewhenCommitAsyncfaults and the server-side result is genuinely unknown. That signal is the only thing step 3's reconcile pass trusts. -
OutboxFlushBehavior— Per 15-event-and-outbox.md, enrolsIOutboxmessages in the current transaction; the dispatcher ships them through the registeredIEventBusafter commit. The registered implementation isInProcessEventBusuntil the Dapr adapter's trigger fires (ADR-0035); handler code is identical either way, which is the point of the port. -
Handler — domain logic; returns
Result<T>. Nothrow new DomainExceptionfor expected business-rule violations — useResult.Fail(business_rule_violation, ...). TheLearnStackException-DomainExceptionThrowRoslyn analyzer (ADR-0032 § Sub-decision 4) flags violations.
The pipeline does not include a separate ExceptionHandlingBehavior.
AuditLogBehavior's catch-and-rethrow + the L1 IExceptionHandler
(ADR-0032 § Sub-decision 1)
together cover every exception path; a third behavior would duplicate the
responsibility.
Architecture test
MediatR_Pipeline_Order_Matches_Canonical_Sequence
asserts the DI registration order at startup; the test fails the build if
any behavior is missing, reordered, or duplicated. The catalogue entry in
21-architecture-tests-catalogue.md is
the canonical reference for this identifier.
- Use
IClock(orTimeProviderfrom .NET 8+) — neverDateTime.Now/DateTimeOffset.UtcNowin domain or application code. - Persist times in UTC.
- Convert to user / tenant timezone only at presentation boundaries.
- Strongly-typed options bound via
IOptions<TOptions>. - Options classes annotated with
[OptionsValidator]and validators. - Configuration sources, in order: environment variables, secret manager,
appsettings.{env}.json,appsettings.json. - No secrets in code, no secrets in git.
- Use
ILogger<T>with structured logging. - Never log secrets, passwords, tokens, or full payment payloads.
- See Observability Standards for tag conventions.
dynamic(except at provider-SDK boundaries with explicit justification).Task.Runto escape async context.Thread.Sleepoutside of well-explained tests.unsafecode outside justified hot paths.- Static mutable state.
- Service-locator pattern (
ServiceProvider.GetService<T>outside composition root). - Reflection at runtime in domain code.
- Public mutable properties on aggregates.
- One public type per file (records inside a file may share if related).
- Files match the type name.
- Test files mirror the structure of the source folder.
- Comment only when the why is non-obvious.
- Don't restate the code in prose.
- Public APIs should have an XML doc comment when consumed across module boundaries.
- TODO comments include a date and an owner (
// TODO(YYYY-MM-DD, @owner): ...).