Skip to content

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 minus Controller, so DocumentsController/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 provides GetUserContextAsync, 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 Async suffix 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]. CancellationToken is the last parameter, unbound.
  • Return Task<ResponseModel<T>> for JSON payloads, or Task<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 (AuthManager depends on IUsersManager) — orchestration across subjects is exactly its job. It must not reach into a provider's internals or bypass a provider to hit DbContext.
  • Split a large manager by sub-subject, never by verb. Documents growing a Versions concern becomes a DocumentVersionsManager, not a DocumentsReadManager / DocumentsWriteManager pair.

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, not DocumentsProvider) 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, MockSmsProvider all implement ISmsProvider. 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, not IEmailProviderService or IProvidesEmail.

Models — <Thing>Model / <Thing>Request / <Thing>Query

  • Suffix by role: *Model for a general data container (DocumentModel, DocumentDetailModel), *Request for a bound request body when the word "request" reads better (AssignDocumentsOwnerRequest), *Query for a search/filter input (GetDocumentsQuery). *Dto is not our convention — use *Model.
  • Immutable read modelrecord or { get; init; } class. Mutable bound model{ get; set; } auto-properties (model binding needs setters).
  • required and non-null defaults communicate what the model guarantees; prefer them over a comment.
  • Audit fields come from the AuthoredModel base (Created, CreatedBy, Modified, ModifiedBy) — don't re-declare them.
  • Generated row models (Generated/Models/CreateDocumentModel) are named <StoredFunctionName>Model by db-gen — see Generated code. Application models are distinct from these; a mapper bridges the two.

Mappers — <Subject>Mappers, To<Target> methods

  • static class, 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, no ctx, no I/O. A map_* 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)

  • static class, named <Domain>Helper or <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 *.Common so they travel; feature-specific ones stay in *.Web/Helpers.
  • Extension methods for framework types go in <Type>Extensions classes (StringExtensions, HttpContextExtensions, FormFileExtensions), one extended type per class.

Options — <Section>Options

  • One class per configuration section, suffixed Options: SmtpOptions, ActiveDirectoryOptions, JwtOptions.
  • Bind with nameof where 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

  • Constantsstatic class under Constants/, one file per domain (JobRunTypeCodes, SettingsKeys, AppClaimTypes). Member names are PascalCase; the value is whatever the domain dictates — frequently a snake_case database code: public const string ADSync = "ad_sync";. The C# name follows C# rules; the string value follows the database's.
  • EnumsPascalCase type and members, under Enums/, 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 takes page/pageSize and returns a PagedResultsModel<T> is a Search*, not a Get*. This matches the PostgreSQL get_ vs search_ split.
  • Boolean predicates read as propertiesIsActive, HasPermission, CanEdit. Don't prefix a predicate with Get (GetIsActive).
  • Map*/To* is reserved for pure mappers. Don't name a fetch-and-transform method Map*.
  • No Do* / Handle* / _impl decoration on private methods. private already says private; give it a real name (AppendDocumentAdditionalInfoAsync, not DoAppend).

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:

  1. Identifier of the entity acted upondocumentCode, userId, tenantId.
  2. Required data — the model to insert, the search text, the file.
  3. Filters and options — filter bundles, flags.
  4. Paginationpage, pageSize.
  5. 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 camelCase with 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 const stays PascalCase (private const string ProviderCode = "aad";).
  • Pick one and hold it: a class must not mix _field and field. New code uses plain camelCase.

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.
  • LogError passes 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