Side-layer types — Models, Mappers, Helpers, Options, Constants, Jobs
Models — <Thing>Model / <Thing>Request / <Thing>Query
- Suffix by role:
*Modelfor a general data container (DocumentModel,DocumentDetailModel),*Requestfor a bound request body when the word "request" reads better (AssignDocumentsOwnerRequest),*Queryfor a search/filter input (GetDocumentsQuery).*Dtois not our convention — use*Model. - Immutable read model →
recordor{ get; init; }class. Mutable bound model →{ get; set; }auto-properties (model binding needs setters). requiredand non-null defaults communicate what the model guarantees; prefer them over a comment.- Audit fields come from the
AuthoredModelbase (Created,CreatedBy,Modified,ModifiedBy) — don't re-declare them. - Generated row models (
Generated/Models/CreateDocumentModel) are named<StoredFunctionName>Modelbydb-gen— see Generated code. Application models are distinct from these; a mapper bridges the two.
Mappers — <Subject>Mappers, To<Target> methods
staticclass, named<Subject>Mappers(DocumentMappers,TypedViewMappers).- Methods are
To<Target>/To<Target>Models, usually extension methods on the source type:rows.ToUpdatedDocumentModels(),row.ToUserModel(). Overloads with the same name for different source types are expected. - No
Async, noctx, no I/O. Amap_*name that does a fetch-and-transform is a lie — put the fetch in a provider and let the mapper transform the result. See the general Map verb.
Helpers — <Domain>Helper(s)
staticclass, named<Domain>Helperor<Domain>Helpers(StringHelpers,HashHelper,ActiveDirectoryHelpers,NpgsqlHelpers).- Stateless, no side effects beyond the obvious. A helper that touches config, HTTP, or a DB is misfiled — it's a Service or a Provider.
- Truly generic helpers live in
*.Commonso they travel; feature-specific ones stay in*.Web/Helpers. - Extension methods for framework types go in
<Type>Extensionsclasses (StringExtensions,HttpContextExtensions,FormFileExtensions), one extended type per class.
Options — <Section>Options
- One class per configuration section, suffixed
Options:SmtpOptions,ActiveDirectoryOptions,JwtOptions. - Bind with
nameofwhere the section name matches the class (GetSection(nameof(SmtpOptions))); use an explicit string only when it doesn't (GetSection("ADOptions")). - Every property has a sensible default so a missing value is diagnosable. Consume via
IOptions<T>.Value, read once in the constructor.
Constants and Enums
- Constants —
static classunderConstants/, one file per domain (JobRunTypeCodes,SettingsKeys,AppClaimTypes). Member names arePascalCase; the value is whatever the domain dictates — frequently asnake_casedatabase code:public const string ADSync = "ad_sync";. The C# name follows C# rules; the string value follows the database's. - Enums —
PascalCasetype and members, underEnums/, for closed sets that don't round-trip through the database as free text (ADProviderTypes,EmailProviderType). If a value is persisted as a code string, prefer a constant over an enum so the stored value is explicit.
Exceptions — <Reason>Exception
Custom exceptions are PascalCase ending in Exception, derive from Exception, and take a message: NotFoundException, NoAvailableTenantException, SelectedTenantNotFoundException. Throw them from managers/providers for domain failures; they surface as an error response via CatchMiddleware. See error handling.
Jobs — <Purpose>Job
Quartz jobs are PascalCase ending in Job, implement IJob, and expose public static readonly JobKey Key = new JobKey(nameof(XxxJob)): ADSyncJob, EmailQueueProcessorJob, OrphanedFilesRemovalJob. The name states the maintenance purpose, not the schedule.