C# naming conventions
These rules extend the general naming conventions. Where the general rules and these overlap, the general rule is canonical; this page adds the C#-specific cases.
We assume Microsoft's C# naming and coding guidelines as the baseline — PascalCase for types, methods, properties and constants; camelCase for locals and parameters; I-prefixed interfaces; the Async suffix on awaitable methods. This page documents the points where the Bliss Framework constrains or extends those defaults — including one deliberate deviation from Microsoft's default (fields are not _-prefixed).
Casing summary
| Used for | Casing | Example |
|---|---|---|
| Projects | Company.Product.Layer (PascalCase, dotted) |
KeenMate.DocumentHub.Web, KeenMate.DocumentHub.Database |
| Namespaces | Match folder path, PascalCase |
KeenMate.DocumentHub.Web.Managers |
| Files | PascalCase.cs, one top-level type per file, named after the type |
DocumentsManager.cs, IDocumentProvider.cs |
| Folders | PascalCase, plural for collections of a kind |
Controllers/, Managers/, Models/Documents/ |
| Classes, records, structs, enums | PascalCase |
DocumentsManager, UserContext, ADProviderTypes |
| Interfaces | I + PascalCase |
IDocumentsManager, IEmailProvider |
| Methods | PascalCase, verb-first, Async suffix when awaitable |
GetDocumentsAsync, EnsureUserAsync |
| Properties | PascalCase |
DocumentCode, SelectedTenant |
| Public constants | PascalCase (name), value is whatever the domain needs |
JobRunTypeCodes.ADSync = "ad_sync" |
| Private fields | camelCase, no _ prefix |
logger, documentProvider, commonProvider |
| Local variables | camelCase, var when the type is obvious |
var rows, UserContext ctx |
| Parameters | camelCase |
documentCode, ctx, cancellationToken |
| Enum members | PascalCase |
EmailProviderType.Smtp |
| Type parameters | T + PascalCase |
ResponseModel<TData, TMetadata> |
| Known abbreviations | kept as a unit, upper for 2-letter, cased for longer | AD, OID, GC, UTM, UUID, SHA |
Formatting baseline (from .editorconfig): tabs, width 2, max line length 160, final newline, UTF-8. Braces on their own line (Allman). Prefer file-scoped namespaces (namespace Foo;) in new files.
Known abbreviations
The solution's ReSharper settings register a fixed set of abbreviations that stay upper-cased inside identifiers instead of being lower-camel'd: AD, GC, OID, SHA, UTM, UUID. So it is GetADObjectFullName, ProviderOID, ToSHA256, SelectedTenantUUID — not GetAdObjectFullName, ProviderOid. Add a new abbreviation to the .DotSettings list rather than casing it ad-hoc, so the whole team's tooling agrees.
Projects and namespaces
Most apps are a single project (Company.Product); split further only when a second app reuses code or size demands it — see One project, or several. When a solution does split, the projects and their roles are:
| Project | Role | What lives here |
|---|---|---|
Company.Product.Web |
The application | Controllers, Managers, Providers, Services, Models, Mappers, Helpers, Options, Middlewares, Jobs |
Company.Product.Database |
Generated provider infrastructure | Generated/DbContext.cs, Generated/Models/*, Generated/Processors/* — all autogenerated |
Company.Product.Common |
Cross-cutting, framework-free utilities | Extensions/*, shared value types that carry no ASP.NET dependency |
Namespaces mirror the folder path exactly: a file in KeenMate.DocumentHub.Web/Managers/ is in namespace KeenMate.DocumentHub.Web.Managers. Under a feature-first layout the same rule applies — Features/Documents/DocumentsManager.cs is in KeenMate.DocumentHub.Web.Features.Documents. Don't invent a namespace that doesn't match the folder, and don't put two unrelated top-level types in one file.
Controllers — <Subject>Controller
The I/O layer. One controller per subject, plural, matching the general singular/plural rule and the endpoint:
| Endpoint | Controller | Manager it calls |
|---|---|---|
/api/documents |
DocumentsController |
DocumentsManager |
/api/users |
UserController / UsersController |
UsersManager |
/api/email-templates |
EmailTemplatesController |
EmailTemplateManager |
/api/me |
MeController |
UsersManager |
Rules:
[Route("api/[controller]")]+[ApiController]is the default. The[controller]token resolves to the class name minusController, soDocumentsController→/api/documents. Use an explicit route string ([Route("api/message-logs")]) only when the token doesn't produce the URL you want.- Inherit the common base (
CommonController) that providesGetUserContextAsync, the cache, and the shared constructor dependencies via: base(...). - Coarse authorization on the class (
[Authorize]), fine-grained permission on the action ([PermissionsAuthorize("documents.create_document", …)],[AllowAnonymous]). - Action names are verb-first with the
Asyncsuffix and read as the operation:GetDocumentsAsync,UploadDocumentFileAsync,SetPreferredLocaleAsync. HTTP method comes from the attribute ([HttpGet],[HttpPost("documents")],[HttpPatch("{documentCode}/file/{id:guid}")]), not from the method name. - Bind explicitly:
[FromBody],[FromRoute],[FromQuery],[FromForm].CancellationTokenis the last parameter, unbound. - Return
Task<ResponseModel<T>>for JSON payloads, orTask<IActionResult>for file downloads / redirects / raw status codes. The action body stays under ~25 lines — see Controllers stay thin.
Managers — <Subject>Manager
The Management layer. One manager per business subject, plural:
| Manager | Interface | Subject |
|---|---|---|
DocumentsManager |
IDocumentsManager |
Documents |
UsersManager |
IUsersManager |
Users |
TenantManager |
ITenantManager |
Tenants |
EmailTemplateManager |
(none — no test double needed) | Email templates |
- Give a manager an interface when it has a seam worth faking — a controller test that stubs it, or an alternative implementation. A specialized manager with a single caller and no test double (
EmailTemplateManager,SendoutImportManager) can stay concrete until that changes. This is use only what you need applied to interfaces. - A manager may call other managers (
AuthManagerdepends onIUsersManager) — orchestration across subjects is exactly its job. It must not reach into a provider's internals or bypass a provider to hitDbContext. - Split a large manager by sub-subject, never by verb.
Documentsgrowing aVersionsconcern becomes aDocumentVersionsManager, not aDocumentsReadManager/DocumentsWriteManagerpair.
Providers — <Subject>Provider
The Providers layer. Atomic: one database subject or one external system each.
| Provider | Interface | Talks to |
|---|---|---|
DocumentProvider |
IDocumentProvider |
the document DB subject |
UsersProvider |
IUsersProvider |
the user DB subject |
EmailProvider |
IEmailProvider |
SMTP |
TwilioSmsProvider |
ISmsProvider |
Twilio |
ActiveDirectoryProvider |
IActiveDirectoryProvider |
LDAP / AD |
GraphApiProvider |
IActiveDirectoryProvider |
Microsoft Graph |
- Database providers are singular-subject (
DocumentProvider, notDocumentsProvider) when they wrap operations on that one entity's stored functions. Follow the surrounding codebase's existing choice for consistency; the rule that matters is one subject per provider. - External-system providers implement a capability interface so implementations are swappable:
TwilioSmsProvider,HttpSmsProvider,MockSmsProviderall implementISmsProvider. Group a family in its own folder (Providers/SmsProviders/). - A provider never calls another provider. If an operation needs two providers, the manager coordinates them.
Services — <Capability>Service
Side-layer DI'd utilities that are neither a single external system nor cross-subject orchestration: EmailHtmlService, EncryptionService, ContextService. Interface I<Capability>Service when it's worth faking. Naming is capability-first, not subject-plural — a service is "the thing that renders email HTML", so EmailHtmlService, not EmailsService. See the Provider vs Service vs Manager table.
Interfaces — I<Type>
- Prefix every interface with
I:IDocumentsManager,IEmailProvider,IContextService. - Create an interface for a manager/provider/service when there is a real seam — a second implementation, or a test that needs a double. Don't reflexively pair every class with a one-implementation interface; that is ceremony, not abstraction.
- Name the interface after the thing, not the pattern:
IEmailProvider, notIEmailProviderServiceorIProvidesEmail.
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.
Methods — verbs and shapes
The general Bliss verb registry applies. The C# shapes:
| Verb | Returns | Example |
|---|---|---|
Get* |
One entity, or a full (unpaged) set; T? or throw when absent |
GetDocumentAsync, GetUserAvailableTenantsAsync |
Search* |
Paged results — takes filters + paging, returns PagedResultsModel<T> |
SearchDocumentsAsync |
Create* |
Insert; returns the new entity (often T?) |
CreateDocumentAsync |
Update* |
Update; returns the updated entity or Task |
UpdateDocumentAsync |
Delete* |
Delete; returns the deleted entity or Task |
DeleteDocumentAsync |
Ensure* |
Idempotent upsert — create if missing, return the entity | EnsureUserAsync, EnsureDocumentContentTextAsync |
Process* |
Multi-step batch operation | ProcessImportFileAsync |
Parse* |
Input parsing (CSV, Excel, tokens) | ParseAsync (on a parsing provider) |
Map* / To* |
Side-layer transform (mapper) | ToUserModel, ToScopeModels |
Send* |
Email / SMS / notification dispatch | SendEmailAsync, SendSmsAsync |
Check* / Validate* / Verify* |
See the general Check-vs-Validate-vs-Verify table | CheckPermission, ValidateToken, VerifySignature |
Is* / Has* / Can* / Should* |
bool (or Task<bool>) predicate, reads like a property |
IsTenantAvailableForUserAsync, HasPermission |
Shape rules:
- Every awaitable method ends in
Async. No exceptions — this is applied 100% consistently in the reference codebase and code review should keep it that way. Get*for the complete set,Search*for paged. A method that takespage/pageSizeand returns aPagedResultsModel<T>is aSearch*, not aGet*. This matches the PostgreSQLget_vssearch_split.- Boolean predicates read as properties —
IsActive,HasPermission,CanEdit. Don't prefix a predicate withGet(GetIsActive). Map*/To*is reserved for pure mappers. Don't name a fetch-and-transform methodMap*.- No
Do*/Handle*/_impldecoration on private methods.privatealready says private; give it a real name (AppendDocumentAdditionalInfoAsync, notDoAppend).
The My/current-user shape
When a method is specifically about the calling user and the caller can't vary the target, a My-free name that reads off ctx is preferred (GetClientUserContextAsync(ctx, …)). When you genuinely need both "for me" and "for an arbitrary user" variants, distinguish them by an explicit parameter, not by two differently-named methods for the same operation.
Parameters
ctx is the first argument
UserContext (named ctx everywhere) is the first parameter of every controller-to-manager-to-provider call. Always — even when today's body doesn't read every field. Consistency at the call site outweighs parameter parsimony.
Task<UpdatedDocument?> CreateDocumentAsync(UserContext ctx, DocumentUpdateModel model, CancellationToken cancellationToken);
Task<DocumentDetailModel> GetDocumentDetailAsync(UserContext ctx, string documentCode, CancellationToken cancellationToken);
CancellationToken is the last argument
Every async method takes a CancellationToken as its last parameter and passes it straight through to the calls it makes. Name it cancellationToken — the full name is the house standard. The abbreviated ct appears in some older code and in the generated DbContext; prefer cancellationToken in hand-written code. Give it a = default only where the method is genuinely called both with and without one.
Parameter order
After ctx:
- Identifier of the entity acted upon —
documentCode,userId,tenantId. - Required data — the model to insert, the search text, the file.
- Filters and options — filter bundles, flags.
- Pagination —
page,pageSize. CancellationToken— always last.
This mirrors the PostgreSQL parameter order; a database provider that passes through to DbContext is a near-direct forwarding of the same order.
Standard parameter names
| Parameter | Type | Meaning |
|---|---|---|
ctx |
UserContext |
The actor / request context |
documentCode, userId, <entity>Id |
string / long / int |
The thing being acted on |
model |
a *Model |
A bound request/update body |
query |
a *Query |
A search/filter input |
page, pageSize |
int? |
Pagination |
cancellationToken |
CancellationToken |
Cooperative cancellation, always last |
Private fields (plain camelCase)
This is the one deliberate departure from Microsoft's default style, and it is applied consistently across the reference codebase (the overwhelming majority of classes; the _-prefixed minority are the exception to migrate, not the model):
- Private instance fields are
camelCasewith no underscore prefix:logger,documentProvider,commonProvider— not_logger,_documentProvider. - Assign them with an explicit
this.in the constructor to disambiguate from the same-named parameter:
public DocumentsManager(
ILogger<DocumentsManager> logger,
IDocumentProvider documentProvider,
CommonProvider commonProvider)
{
this.logger = logger;
this.documentProvider = documentProvider;
this.commonProvider = commonProvider;
}
private conststaysPascalCase(private const string ProviderCode = "aad";).- Pick one and hold it: a class must not mix
_fieldandfield. New code uses plaincamelCase.
Constructors and dependency injection
- Classic constructors, with the
ILogger<T>first and the: base(...)call last where a base class needs it. Primary constructors are not the house style in this codebase — match what surrounds you. - Inject interfaces where they exist (
IDocumentProvider documentProvider), concrete types where they don't (CommonProvider commonProvider). - One field per dependency,
readonly, assigned once in the constructor. No property injection, no service locator.
Logging
One convention, project-wide, via Serilog ILogger<T> injected as logger:
logger.LogInformation("Getting documents for user: {username}", ctx.Username);
logger.LogError(ex, "Error occurred while getting documents for user: {username}", ctx.Username);
- Structured placeholders in
{camelCase}, never string interpolation into the message template — the properties must stay queryable. LogErrorpasses the exception first, then a message beginning "Error occurred while …".- Levels:
Trace(provider DB detail) ·Debug(operation start) ·Information(user action / state change) ·Warning(degraded but proceeding) ·Error(operation failed).
Generated code (see PostgreSQL)
Generated/DbContext.cs, Generated/Models/*, Generated/Processors/*, and any *.generated.cs are produced by db-gen. Their names are driven entirely by the PostgreSQL naming conventions:
| SQL | C# |
|---|---|
Function public.create_document(...) |
DbContext.CreateDocumentAsync(...) → List<CreateDocumentModel> |
| — result row | Generated/Models/CreateDocumentModel ([DbColumnMapping] per column) |
| — row parser | Generated/Processors/CreateDocumentProcessor.Process(...) |
Function const.get_business_units(...) |
DbContext.ConstGetBusinessUnitsAsync(...) |
Don't rename, wrap, or hand-edit a generated member because you dislike its name. If the name is wrong, fix the SQL function and regenerate.
Comments
Default to no comment. Add one only when the why is non-obvious — a hidden constraint, a workaround for a library quirk, the rationale for a particular default. Don't restate the method body in English. Delete commented-out code; ship it, file an issue, or remove it. The Autogenerated using db-gen headers are load-bearing — leave them.
Anti-patterns
| Anti-pattern | Why | Use instead |
|---|---|---|
_camelCase private fields |
Not this codebase's convention; mixing the two is the real cost | Plain camelCase + this.field = field |
Async method without Async suffix |
Breaks the 100%-consistent convention; caller can't tell it awaits | GetDocumentsAsync |
Get* that takes page/pageSize and returns a page |
Blurs the Get/Search split | Search* returning PagedResultsModel<T> |
GetIsActive / Get-prefixed predicate |
A boolean reads as a property | IsActive, HasPermission |
DoCreate, HandleUpdate, CreateImpl for a private method |
private already says private |
A real name (AppendDocumentAdditionalInfoAsync) |
| Interface for every class reflexively | Ceremony with no seam | Interface only when there's a second impl or a test double |
DocumentsReadManager / DocumentsWriteManager split |
Horizontal split by verb | Split by sub-subject (DocumentVersionsManager) |
| A provider calling another provider | Couples the atomic layer | Orchestrate in the manager |
Calling DbContext from a manager/controller |
Bypasses the provider seam | Go through the *Provider |
Hand-editing Generated/* or *.generated.cs |
Overwritten on next db-gen run |
Fix the SQL function, regenerate |
IConfiguration["Smtp:Host"] string indexing in business code |
Untyped, scattered, no default | Bind to *Options, inject IOptions<T> |
Secrets in a checked-in appsettings.*.json |
Leaks into VCS | Environment variables (deploy) / user secrets (dev) |
HttpContext / static current-user below the controller |
Hidden state, hard to test | Pass ctx as the first argument |
Building UserContext inside a manager |
The manager shouldn't know about HttpContext |
Build it in CommonController.GetUserContextAsync |
| String interpolation into a log message | Loses structured properties | logger.LogInformation("... {username}", ctx.Username) |
Map*/To* method that does I/O |
A mapper must be a pure transform | Fetch in a provider; transform in the mapper |
*Dto suffix |
Not our convention | *Model (or *Request / *Query by role) |
CancellationToken ct in new hand-written code |
House standard is the full name | CancellationToken cancellationToken |
Lower-casing a known abbreviation (GetAdObject, ProviderOid) |
Contradicts the registered abbreviation list | GetADObject, ProviderOID; register new ones in .DotSettings |
Business literal buried inline ("ad_sync") |
Duplicated, un-greppable | A Constants member (JobRunTypeCodes.ADSync) |
Worked example — controller, manager, provider
The full trio for one operation. The controller binds HTTP and wraps the envelope; the manager orchestrates; the provider does the atomic DB call; the mapper shapes.
// I/O — DocumentsController (KeenMate.DocumentHub.Web/Controllers)
[HttpPost("documents")]
public async Task<ResponseModel<PagedResultsModel<Document>>> GetDocumentsAsync(
[FromBody] GetDocumentsQuery query,
CancellationToken cancellationToken)
{
UserContext ctx = await GetUserContextAsync(null, cancellationToken);
logger.LogInformation("Getting documents for user: {username}", ctx.Username);
try
{
PagedResultsModel<Document> results = await documentManager.GetDocumentsAsync(ctx, query, cancellationToken);
return new ResponseModel<PagedResultsModel<Document>>(results);
}
catch (Exception ex)
{
logger.LogError(ex, "Error occurred while getting documents for user: {username}", ctx.Username);
return new ErrorResponseModel<PagedResultsModel<Document>>(null);
}
}
// Management — DocumentsManager (KeenMate.DocumentHub.Web/Managers)
public async Task<PagedResultsModel<Document>> GetDocumentsAsync(UserContext ctx, GetDocumentsQuery query, CancellationToken cancellationToken)
{
var results = await documentProvider.SearchDocumentsAsync(ctx, query.Filters, query.Pagination, cancellationToken);
return results; // provider already returns the paged, mapped shape
}
// Providers — DocumentProvider (KeenMate.DocumentHub.Web/Providers)
public async Task<PagedResultsModel<Document>> SearchDocumentsAsync(
UserContext ctx, SearchDocumentsFiltersModel? filters, PaginationFilters? pagination, CancellationToken cancellationToken)
{
var rows = await dbContext.SearchDocumentsAsync(
ctx.Username, ctx.User.UserId,
filters?.SearchText.ToOptional() ?? Optional<string>.None,
(pagination?.Page).ToOptional(),
(pagination?.PageSize).ToOptional(),
(ctx.SelectedTenant?.TenantId).ToOptional(),
cancellationToken);
return rows.ToDocumentsPagedResult(); // Side-layer mapper: raw rows → PagedResultsModel<Document>
}
Four things in four places: the controller binds it to HTTP and the envelope; the manager decides; the provider does the atomic call; the mapper shapes. Each type owns its slice.
See also
- General naming conventions — the shared verb registry and the singular/plural rule.
- C# coding guidelines (this section's index) — one-project-vs-several, layering,
UserContext, configuration, error handling, jobs. - PostgreSQL naming conventions — governs every name you see in
DbContext. - Elixir naming conventions — the same verb registry and
ctx-first rule in asnake_caseworld. - Microsoft C# coding conventions — the baseline this page extends.