Skip to content

Coding Standards

ElementConventionExample
TypesPascalCase, sealed by defaultsealed class AuditedEntityInterceptor
InterfacesI + PascalCaseICurrentUserService
MethodsPascalCase, Async suffix for asyncEncryptAsync()
PropertiesPascalCaseCreatedAt, TenantId?
Private fields_camelCase (underscore prefix)_currentUserService
ConstantsPascalCase (not UPPER_SNAKE)SectionName
Parameters / localscamelCasestring errorCode
GenericsT or TPrefixTModule, TEntity
Options classesOptions suffix, sealedsealed class VaultOptions
DI extensionsAdd* / Use*AddGranitTiming()
Module classesModule suffixGranitTimingModule
EnumsPascalCase valuesSequentialGuidType.AtEnd
Endpoint DTOs[Module][Concept][Suffix]WorkflowTransitionRequest

OpenAPI flattens C# namespaces — only the short type name appears in the schema. Two modules exposing an AttachmentInfo will cause a conflict.

Every public type used as an endpoint parameter or return value must carry a prefix identifying its module:

Wrong (too generic)Correct (prefixed)Module
AttachmentInfoTimelineAttachmentInfoTimeline
ColumnMappingImportColumnMappingDataExchange
TransitionRequestWorkflowTransitionRequestWorkflow
SuffixRoleExample
RequestInput body (POST/PUT)CreateSavedViewRequest
ResponseTop-level return (GET, POST 201)UserNotificationResponse

EF Core entities must never be returned directly by an endpoint. Create a *Response record that projects only the fields relevant to the consumer.

// Correct -- dedicated Response record
public sealed record SavedViewResponse
{
public required Guid Id { get; init; }
public required string Name { get; init; }
public required string EntityType { get; init; }
}
// Wrong -- EF entity returned directly (leaks audit fields)
group.MapGet("/", () => TypedResults.Ok(efEntities));

Exemptions: shared cross-cutting types (PagedResult<T>, ProblemDetails) do not need a module prefix.

Use var when the type is apparent on the right side; explicit type otherwise.

var stream = File.OpenRead("data.csv"); // type is apparent (FileStream)
var users = new Dictionary<int, User>(); // type is apparent (new)
ImportResult result = _service.ImportAsync(data); // explicit -- type not obvious

Use expression body (=>) for single-statement methods:

public IReadOnlyList<Type> GetModuleTypes() =>
[.. _modules.Select(m => m.ModuleType)];

Even for single-line blocks:

// Correct
if (context is null)
{
return;
}
// Wrong
if (context is null) return;
  • Classes sealed by default — only leave unsealed when inheritance is explicitly intended
  • File-scoped namespacesnamespace Granit.Vault.Services;
  • Collection expressions (C# 12+)[] for empty lists, [.. enumerable] for spread
  • Pattern matchingis null, is not null (never == null)
  • Target-typed new() — when the type is explicit on the left side

The project must compile with zero warnings. Warnings are latent bugs. Fix them or suppress explicitly with #pragma warning disable plus a justification comment.

System, then Microsoft, then project/third-party (enforced by .editorconfig):

using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Granit.Vault.Options;
using VaultSharp;
  1. Using statements
  2. Namespace (file-scoped)
  3. XML documentation on the type
  4. Type declaration
  5. Private readonly fields
  6. Constructor (or primary constructor)
  7. Public properties
  8. Public methods
  9. Private methods
  10. Nested types (last)

One type per file, file name matches type name.

  • Required on all public types and members
  • <summary> brief (1 line), <remarks> for detail
  • <inheritdoc/> for interface implementations
  • <param> and <returns> for public methods
  • GDPR/ISO 27001 context in <remarks> when relevant
/// <summary>
/// Exception representing a violated business rule.
/// Maps to <c>400 Bad Request</c>.
/// </summary>
/// <remarks>
/// Implements <see cref="IUserFriendlyException"/>: the message is safe to
/// expose to clients.
/// </remarks>
public class BusinessException : Exception

Comments — explain the “why”, not the “what”

Section titled “Comments — explain the “why”, not the “what””

A comment has value only if it explains a non-obvious reason. Well-named code describes what it does on its own.

TODOs — attribution and traceability required

Section titled “TODOs — attribution and traceability required”

Every TODO must include the author and a GitHub issue number:

// TODO(JDO): Refactor this once we migrate to .NET 11 (Issue #452)

A TODO without an issue is noise. Create the issue first, then reference it.

  • No commented-out code — use Git for history
  • No // removed or // unused — delete dead code
  • No separator banners (// ===== Section =====) — use #region or extract a class
  • Async suffix on all async methods
  • CancellationToken as the last parameter with = default
  • ConfigureAwait(false) in library code (NuGet packages)
  • .WaitAsync(cancellationToken) for APIs without native CT support
public async Task<string> DecryptAsync(
string keyName,
string ciphertext,
CancellationToken cancellationToken = default)
{
Secret<DecryptionResponse> result = await _vaultClient.V1.Secrets.Transit
.DecryptAsync(keyName, requestOptions, mountPoint: _options.TransitMountPoint)
.ConfigureAwait(false);
// ...
}

Never use DateTime.Now, DateTime.UtcNow, DateTimeOffset.Now, or DateTimeOffset.UtcNow. Inject TimeProvider (native .NET 8+) or IClock (Granit.Timing):

// Correct
DateTimeOffset now = clock.Now;
// Wrong -- static call, not testable
DateTimeOffset now = DateTimeOffset.UtcNow;

Use [LoggerMessage] source-generated logging — never string interpolation in log calls:

[LoggerMessage(
Level = LogLevel.Debug,
Message = "Data encrypted with Transit key {KeyName}")]
private static partial void LogEncrypted(ILogger logger, string keyName);

Benefits: zero allocation when the log level is inactive, AOT-compatible, compile-time verified placeholders.

Use [GeneratedRegex] — never new Regex(..., RegexOptions.Compiled):

[GeneratedRegex(@"^[a-z0-9-]+$", RegexOptions.None, 100)]
private static partial Regex SlugRegex();

The third parameter is a timeout in milliseconds — mandatory for regex on user input.

Use ArgumentNullException.ThrowIfNull() for programmer errors (internal null checks). For user-facing validation, throw domain exceptions (ValidationException, BusinessException, EntityNotFoundException):

// Programmer error -- hidden 500 in production
ArgumentNullException.ThrowIfNull(service);
// User validation -- displayed in the UI (422)
if (string.IsNullOrWhiteSpace(email))
{
throw new ValidationException(new Dictionary<string, string[]>
{
["Email"] = ["The Email field is required."]
});
}

All error responses must use TypedResults.Problem() (RFC 7807), never TypedResults.BadRequest<string>():

// Correct -- structured ProblemDetails
return TypedResults.Problem(
detail: "Invalid webhook payload.",
statusCode: StatusCodes.Status400BadRequest);
// Wrong -- string body, no structured error
return TypedResults.BadRequest("Invalid webhook payload.");

The handler return type must reflect ProblemHttpResult:

private static Task<Results<Ok, ProblemHttpResult>> HandleWebhookAsync(...)
  • Minimal API only — no MVC controllers
  • Handlers must be named static methods (no inline lambdas)
  • Every endpoint requires .WithName(), .WithSummary(), and .WithTags()
  • Use TypedResults (not Results) for correct OpenAPI schema inference
  • No anonymous return types — create a typed record

The following APIs are banned at compile time via BannedSymbols.txt (Microsoft.CodeAnalysis.BannedApiAnalyzers):

Banned APIAlternativeReason
new HttpClient()IHttpClientFactorySocket exhaustion
new Regex(...)[GeneratedRegex]AOT, performance
Thread.Sleep()Task.Delay()Blocks thread pool
Task.Result / Task.Wait()awaitSync-over-async deadlock
GC.Collect()Forbidden in library code
Console.Write/WriteLineILogger + [LoggerMessage]Structured observability
Environment.GetEnvironmentVariableIConfigurationConfiguration injection