Coding Standards
Naming conventions
Section titled “Naming conventions”| Element | Convention | Example |
|---|---|---|
| Types | PascalCase, sealed by default | sealed class AuditedEntityInterceptor |
| Interfaces | I + PascalCase | ICurrentUserService |
| Methods | PascalCase, Async suffix for async | EncryptAsync() |
| Properties | PascalCase | CreatedAt, TenantId? |
| Private fields | _camelCase (underscore prefix) | _currentUserService |
| Constants | PascalCase (not UPPER_SNAKE) | SectionName |
| Parameters / locals | camelCase | string errorCode |
| Generics | T or TPrefix | TModule, TEntity |
| Options classes | Options suffix, sealed | sealed class VaultOptions |
| DI extensions | Add* / Use* | AddGranitTiming() |
| Module classes | Module suffix | GranitTimingModule |
| Enums | PascalCase values | SequentialGuidType.AtEnd |
| Endpoint DTOs | [Module][Concept][Suffix] | WorkflowTransitionRequest |
DTO naming rules
Section titled “DTO naming rules”OpenAPI flattens C# namespaces — only the short type name appears in the schema.
Two modules exposing an AttachmentInfo will cause a conflict.
Prefix with module context
Section titled “Prefix with module context”Every public type used as an endpoint parameter or return value must carry a prefix identifying its module:
| Wrong (too generic) | Correct (prefixed) | Module |
|---|---|---|
AttachmentInfo | TimelineAttachmentInfo | Timeline |
ColumnMapping | ImportColumnMapping | DataExchange |
TransitionRequest | WorkflowTransitionRequest | Workflow |
Required suffixes
Section titled “Required suffixes”| Suffix | Role | Example |
|---|---|---|
Request | Input body (POST/PUT) | CreateSavedViewRequest |
Response | Top-level return (GET, POST 201) | UserNotificationResponse |
Entity/API separation
Section titled “Entity/API separation”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 recordpublic 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.
C# style rules
Section titled “C# style rules”var usage (IDE0008)
Section titled “var usage (IDE0008)”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 obviousExpression body (IDE0022)
Section titled “Expression body (IDE0022)”Use expression body (=>) for single-statement methods:
public IReadOnlyList<Type> GetModuleTypes() => [.. _modules.Select(m => m.ModuleType)];Braces are mandatory
Section titled “Braces are mandatory”Even for single-line blocks:
// Correctif (context is null){ return;}
// Wrongif (context is null) return;Other style rules
Section titled “Other style rules”- Classes
sealedby default — only leave unsealed when inheritance is explicitly intended - File-scoped namespaces —
namespace Granit.Vault.Services; - Collection expressions (C# 12+) —
[]for empty lists,[.. enumerable]for spread - Pattern matching —
is null,is not null(never== null) - Target-typed
new()— when the type is explicit on the left side
Zero warnings
Section titled “Zero warnings”The project must compile with zero warnings. Warnings are latent bugs. Fix them
or suppress explicitly with #pragma warning disable plus a justification comment.
File organization
Section titled “File organization”using order
Section titled “using order”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;File structure
Section titled “File structure”- Using statements
- Namespace (file-scoped)
- XML documentation on the type
- Type declaration
- Private readonly fields
- Constructor (or primary constructor)
- Public properties
- Public methods
- Private methods
- Nested types (last)
One type per file, file name matches type name.
XML documentation
Section titled “XML documentation”- 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 : ExceptionComments and TODOs
Section titled “Comments and TODOs”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.
Forbidden comments
Section titled “Forbidden comments”- No commented-out code — use Git for history
- No
// removedor// unused— delete dead code - No separator banners (
// ===== Section =====) — use#regionor extract a class
Async patterns
Section titled “Async patterns”Asyncsuffix on all async methodsCancellationTokenas the last parameter with= defaultConfigureAwait(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); // ...}Time management
Section titled “Time management”Never use DateTime.Now, DateTime.UtcNow, DateTimeOffset.Now, or
DateTimeOffset.UtcNow. Inject TimeProvider (native .NET 8+) or IClock
(Granit.Timing):
// CorrectDateTimeOffset now = clock.Now;
// Wrong -- static call, not testableDateTimeOffset now = DateTimeOffset.UtcNow;Logging
Section titled “Logging”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.
Guard clauses
Section titled “Guard clauses”Use ArgumentNullException.ThrowIfNull() for programmer errors (internal null
checks). For user-facing validation, throw domain exceptions
(ValidationException, BusinessException, EntityNotFoundException):
// Programmer error -- hidden 500 in productionArgumentNullException.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."] });}Error responses
Section titled “Error responses”All error responses must use TypedResults.Problem() (RFC 7807), never
TypedResults.BadRequest<string>():
// Correct -- structured ProblemDetailsreturn TypedResults.Problem( detail: "Invalid webhook payload.", statusCode: StatusCodes.Status400BadRequest);
// Wrong -- string body, no structured errorreturn TypedResults.BadRequest("Invalid webhook payload.");The handler return type must reflect ProblemHttpResult:
private static Task<Results<Ok, ProblemHttpResult>> HandleWebhookAsync(...)Endpoint conventions
Section titled “Endpoint conventions”- Minimal API only — no MVC controllers
- Handlers must be named static methods (no inline lambdas)
- Every endpoint requires
.WithName(),.WithSummary(), and.WithTags() - Use
TypedResults(notResults) for correct OpenAPI schema inference - No anonymous return types — create a typed record
Banned APIs
Section titled “Banned APIs”The following APIs are banned at compile time via BannedSymbols.txt
(Microsoft.CodeAnalysis.BannedApiAnalyzers):
| Banned API | Alternative | Reason |
|---|---|---|
new HttpClient() | IHttpClientFactory | Socket exhaustion |
new Regex(...) | [GeneratedRegex] | AOT, performance |
Thread.Sleep() | Task.Delay() | Blocks thread pool |
Task.Result / Task.Wait() | await | Sync-over-async deadlock |
GC.Collect() | — | Forbidden in library code |
Console.Write/WriteLine | ILogger + [LoggerMessage] | Structured observability |
Environment.GetEnvironmentVariable | IConfiguration | Configuration injection |