Skip to content

C# 14 Coding Standards & .NET 10 Conventions

  • Primary constructors: for all DI service classes. Keep private readonly T _x = x; fields when the parameter is used in multiple methods.
  • Collection expressions: [x, y] instead of new[] { x, y }, [] instead of Array.Empty<T>() or new List<T>().
  • field keyword (semi-auto properties): use set => field = value; in properties with custom setter logic instead of declaring a manual backing field.
  • Extension members (extension blocks): prefer over static extension classes when adding multiple related members to the same type.
  • nameof on unbound generics: nameof(List<>) returns "List". NEVER use nameof(T) on a type parameter — use typeof(T).Name instead.
  • Pattern matching: prefer is, switch expressions, list patterns, property patterns.
  • File-scoped namespaces: always (namespace X;, no braces).
  • System.Threading.Lock: ALWAYS use private readonly Lock _lock = new(); for synchronization. NEVER lock on object, collections, or this.
  • params ReadOnlySpan<T>: prefer over params T[] for non-attribute methods to reduce allocations. Attributes must keep params T[] (compile-time constraint).
  • \e escape: use \e instead of \u001b or \x1b for ESCAPE characters.

.NET 10 / EF Core 10 features — use by default

Section titled “.NET 10 / EF Core 10 features — use by default”
  • Named Query Filters (EF Core 10): use HasQueryFilter(name, expr) for individually toggleable filters. Never use unnamed HasQueryFilter(expr).
  • Native OpenAPI 3.1: use Microsoft.AspNetCore.OpenApi + AddOpenApi() / MapOpenApi(). NEVER use Swashbuckle or NSwag. Scalar UI for documentation.
  • IMeterFactory: use for metrics creation. NEVER use new Meter(...) directly.
  • ActivitySource: native .NET diagnostics, one per module. Register via GranitActivitySourceRegistry.
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

Event naming (enforced by architecture tests)

Section titled “Event naming (enforced by architecture tests)”

Two event categories with mandatory suffixes:

ScopeInterfaceSuffixExampleLocation
Domain (in-process)IDomainEvent*EventBlobValidatedEventGranit.{Module}/Events/
Integration (distributed)IIntegrationEvent*EtoPersonalDataDeletedEtoGranit.{Module}/Events/
  • *Event — raised via AddDomainEvent(), synchronous, same transaction
  • *Eto (Event Transfer Object) — raised via AddDistributedEvent(), durable Wolverine outbox
  • Use sealed record implementing the marker interface
  • Past-tense verb + suffix (e.g., BlobValidatedEvent, not BlobValidated)
  • Generic lifecycle: EntityCreatedEvent<T>, EntityCreatedEto<T> — automatic via IEmitEntityLifecycleEvents

Background job naming (enforced by architecture tests)

Section titled “Background job naming (enforced by architecture tests)”
InterfaceAttributeSuffixExampleLocation
IBackgroundJob[RecurringJob]*JobOrphanBlobCleanupJobGranit.{Module}/Jobs/
  • sealed record implementing IBackgroundJob, decorated with [RecurringJob("cron", "name")]
  • Job name format: {module-kebab}-{action-kebab} (e.g., "blob-storage-orphan-cleanup")
  • Handler in same Jobs/ folder: internal static partial class {Action}Handler
  • Never use *Command suffix for jobs — commands are CQRS

All permission strings follow the [Group].[Resource].[Action] format — three dot-separated segments, no exceptions.

SegmentConventionExample
GroupPascalCase module name ({Module}Permissions.GroupName)BackgroundJobs, BlobStorage
ResourceNested static class (plural noun)Jobs, Blobs, Templates, Flags
ActionVerb describing the access levelRead, Manage, Execute, Create

Standard actions:

ActionMeaningUsage
ReadRead-only consultationAlways use Read, never View
ManageGrouped write operations (create, update, delete)Admin-level access
ExecuteSingle action (import, export, trigger)Operation-level access
Create / Update / DeleteGranular CRUD (only when separate control is needed)Fine-grained access

File structure per module:

FileLocation
{Module}Permissions.csPermissions/
{Module}PermissionDefinitionProvider.csPermissions/ (internal sealed)
{Module}EndpointsLocalizationResource.csInternal/
{culture}.json (17 files)Localization/{Module}Endpoints/

Localization keys:

  • Group: PermissionGroup:{Group} (e.g., PermissionGroup:BackgroundJobs)
  • Permission: Permission:{Group}.{Resource}.{Action} (e.g., Permission:BackgroundJobs.Jobs.Read)

Every module with observable operations SHOULD expose OpenTelemetry metrics via a dedicated *Metrics.cs class and, when long-running or cross-process operations exist, a *ActivitySource.cs class.

FileLocationExample
{Module}Metrics.csDiagnostics/ folderDiagnostics/BlobStorageMetrics.cs
{Module}ActivitySource.csDiagnostics/ folderDiagnostics/BlobStorageActivitySource.cs

Both classes live in the base abstraction project (Granit.{Module}), not in a provider or EF Core sub-project, so all implementations can share the same meter and activity source.

ElementConventionExample
Classsealed class {Module}Metricssealed class BlobStorageMetrics
Meter name (const)"Granit.{Module}" (PascalCase)"Granit.BlobStorage"
ConstructorIMeterFactory injection (never new Meter(...))BlobStorageMetrics(IMeterFactory meterFactory)
Instrument fieldsprivate readonlyprivate readonly Counter<long> _uploadsInitiated;
DI registrationservices.TryAddSingleton<{Module}Metrics>();In module’s Add*() extension

Metric naming (granit.{module}.{entity}.{action})

Section titled “Metric naming (granit.{module}.{entity}.{action})”
SegmentFormatExample
Prefixgranit (always)granit.
Modulelowercase, no dotsblobstorage, ratelimiting, dataexchange
Entitylowercase noun (plural for collections)blobs, requests, jobs, rows
Actionlowercase past-tense verb or adjectivecompleted, failed, active, duration

Full example: granit.blobstorage.blobs.deleted, granit.dataexchange.import.duration

TypeWhen to useUnit
Counter<long>Monotonically increasing countsnull (dimensionless)
UpDownCounter<long>Current state (active leases, connections)null
Histogram<double>Duration distributions (P50/P95/P99)"s" (seconds)
TagFormatRequiredExample
tenant_idsnake_caseAlways (coalesce null to "global")tenantId ?? "global"
Operation-specificsnake_casePer-metricstatus, channel, provider

Tags are passed via TagList (not KeyValuePair arrays) to avoid boxing and allocation on hot paths.

Public methods named Record{Action}() with minimal parameters. Use TagList for zero-allocation tag passing:

public void RecordDeleted(string? tenantId, string container) =>
_blobsDeleted.Add(1, new TagList
{
{ "tenant_id", tenantId ?? "global" },
{ "container", container },
});
ElementConventionExample
Classinternal static class {Module}ActivitySourceDataExchangeActivitySource
Name (const)"Granit.{Module}" (PascalCase, matches Meter)"Granit.DataExchange"
Operations{module-kebab}.{action} (kebab-case)data-exchange.import.execute
RegistrationGranitActivitySourceRegistry.Register(Name)In Add*() extension

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

Use AggregateRoot (or audited variants) when the entity has a state machine, raises domain events, or encapsulates invariants. Use plain Entity for append-only records, configuration, caches, or lookup tables.

Aggregate Root rules (enforced by DomainConventionTests):

  • Private setters: all properties { get; private set; }. Use behavior methods for state transitions (e.g., MarkAsValid(), Revoke())
  • Factory method: public static Xxx Create(...) — the only way to construct
  • Private EF Core constructor: keep private Xxx() { } for materialization
  • Domain events via base class: use AddDomainEvent() / AddDistributedEvent() — NEVER manually implement IDomainEventSource
  • No public setters on aggregate roots — architecture test enforces this
  • Inherit from SingleValueObject<T> for single-primitive wrappers
  • Must be sealed with init properties
  • Provide Create() factory with validation + implicit operators
  • EF Core converters auto-applied by ApplyGranitConventions

Isolated DbContext — MANDATORY for *.EntityFrameworkCore packages

Section titled “Isolated DbContext — MANDATORY for *.EntityFrameworkCore packages”

Every isolated DbContext MUST:

  1. <ProjectReference> to Granit.Persistence
  2. Constructor-inject ICurrentTenant? and IDataFilter? (both optional, default null)
  3. Call modelBuilder.ApplyGranitConventions(currentTenant, dataFilter) at end of OnModelCreating
  4. Wire interceptors via (sp, options) overload of AddDbContextFactory (Scoped)
  5. [DependsOn(typeof(GranitPersistenceModule))] on module class
  6. No manual HasQueryFilterApplyGranitConventions handles all standard filters
  7. IMultiTenant entities use Guid? TenantId (never string)
  • Auto-validation: use endpoints.MapGranitGroup(prefix) instead of MapGroup() — applies FluentValidationAutoEndpointFilter automatically
  • Validator discovery: GranitValidationModule auto-discovers all IValidator<T> from loaded module assemblies (no manual registration needed)
  • Opt-out: .WithMetadata(new SkipAutoValidationAttribute())
  • OpenAPI enrichment: FluentValidationSchemaTransformer exposes validation constraints in the OpenAPI schema
  • Architecture tests: ValidationConventionTests ensures all *Request types have validators and all route groups use MapGranitGroup()

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[] for empty, [x, y] for init, [.. spread]
  • Pattern matchingis null, is not null (never == null)
  • Target-typed new() — when the type is explicit on the left side
  • String interpolation$"..." over string.Concat() or string.Format()

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

PatternAlternative (banned)
[GeneratedRegex]new Regex(..., Compiled)
[LoggerMessage]String interpolation in logs
TimeProvider / IClockDateTime.Now / UtcNow
ConfigureAwait(false) in librariesMissing ConfigureAwait
ArgumentNullException.ThrowIfNull()Manual null checks
ArgumentException.ThrowIfNullOrEmpty()Manual string checks
TypedResults.Problem() (RFC 7807)TypedResults.BadRequest<string>()
System.Threading.Locklock (object) / lock (this)
IMeterFactorynew Meter(...)
AddAuthorizationBuilder()AddAuthorization(Action<>) (ASP0025)
HasQueryFilter(name, expr)Unnamed HasQueryFilter(expr)

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.

  • Async suffix on all async methods
  • CancellationToken as the last parameter with = default
  • ConfigureAwait(false) in library code (NuGet packages)
  • async void is FORBIDDEN — always return Task
  • .Result / .Wait() is FORBIDDEN — always await

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);

Never log raw PII (email addresses, phone numbers, IP addresses, usernames, device tokens). Use Granit.Diagnostics.LogRedaction to redact values before passing them to [LoggerMessage] methods or Activity.SetTag():

using Granit.Diagnostics;
// Email: "[email protected]" → "joh***@example.com"
LogEmailSent(LogRedaction.Email(message.To));
// Phone: "+33612345678" → "+336*****78"
LogSmsSent(LogRedaction.Phone(message.To));
// Device token: redacted to "abcd...5678"
LogTokenInvalidated(LogRedaction.Token(deviceToken));
// IP address: "192.168.1.42" → "192.168.1.***"
LogCidrRejected(LogRedaction.IpAddress(remoteIp));
// Username: "john_admin" → "joh***"
LogCredentialsObtained(LogRedaction.Username(dbUser));
// Span tags: use EmailDomain() or HashPrefix() for bounded cardinality
activity?.SetTag("email.domain", LogRedaction.EmailDomain(recipient));
activity?.SetTag("sms.recipient_hash", LogRedaction.HashPrefix(phone));

Naming convention: rename template parameters to signal redaction — {RedactedRecipient}, {MaskedIp}, {RedactedToken}. Two architecture tests enforce this:

  • LoggerMessagePiiConventionTests — flags PII-indicative parameter names in [LoggerMessage] templates
  • ActivitySourcePiiConventionTests — flags PII-indicative tag constants in *ActivitySource.cs files

GUIDs (user IDs, session IDs) are pseudonymous identifiers (GDPR Recital 26) and may be logged as-is for operational debugging. They are exempted in the architecture tests.

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.

  • Minimal API only — no MVC controllers
  • Handlers must be named static methods (no inline lambdas)
  • Handlers suffixed *Async (e.g., GetByIdAsync, not GetById)
  • Every endpoint requires .WithName() and .WithSummary()
  • Use TypedResults (not Results) for correct OpenAPI schema inference
  • No .Produces<>() / .ProducesProblem() — TypedResults inference is sufficient
  • Use MapGranitGroup() for auto-validation
  • No anonymous return types — create a typed record

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

Banned APIAlternativeReason
DateTime.Now / UtcNowTimeProvider / IClockNot testable
new HttpClient()IHttpClientFactorySocket exhaustion
new Regex(...)[GeneratedRegex]AOT, performance
Thread.Sleep()Task.Delay()Blocks thread pool
Task.Result / Task.Wait()awaitSync-over-async deadlock
async voidasync TaskUnobserved exceptions
new Meter(...)IMeterFactoryDI, testability
lock (object)System.Threading.LockC# 13 typed lock
GC.Collect()Forbidden in library code
Console.Write/WriteLineILogger + [LoggerMessage]Structured observability
Environment.GetEnvironmentVariableIConfigurationConfiguration injection
Swashbuckle / NSwagMicrosoft.AspNetCore.OpenApiNative .NET 10