Code Quality И Strong Typing — Enterprise .NET 10 Standards
Назначение
Документ фиксирует обязательные правила качества кода и строгой типизации для всех backend сервисов Pin. Правила применяются на уровне Domain, Application, Infrastructure и Api слоев. Любое отступление требует явного ADR или комментария с обоснованием.
Pin работает с деньгами, персональными данными, лицензируемой gambling-деятельностью и регуляторной отчетностью — поэтому правила здесь строже, чем в среднем .NET проекте.
Nullability
Включить Nullable Везде
Каждый проект содержит в .csproj:
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
Nullable warnings не подавляются через #pragma warning disable. Если тип может быть null, это явно отражается в сигнатуре.
Правила
// ХОРОШО — явное nullable
public string? MiddleName { get; init; }
public Guid? ActiveIncentiveId { get; private set; }
// ПЛОХО — скрытое null-поведение
public string MiddleName { get; init; } // не ясно, может ли быть null
null! (null-forgiving operator) разрешен только в:
- EF entity navigation properties с
[Required]и FK constraint; - AutoFixture/test builders с явным комментарием;
- конфигурациях, инициализируемых через DI (
IOptions<T>binding).
В production domain и application коде null! запрещен.
Nullable Reference Types vs Value Types
// Правило: nullable value types явно моделируют "отсутствие значения"
public decimal? MaxWithdrawalAmount { get; init; } // null = без лимита
public DateTimeOffset? ExpiresAt { get; init; } // null = не истекает
// Правило: коллекции никогда не бывают null — пустая коллекция, не null
public IReadOnlyList<IncentiveCode> GrantedCodes { get; init; } = [];
Records И Immutability
Records Для Value Objects И DTO
// Value Object — record struct для performance
public readonly record struct MoneyAmount(decimal Value, int CurrencyId)
{
public MoneyAmount
{
Guard.PositiveOrZero(Value, nameof(Value));
}
public MoneyAmount Add(MoneyAmount other)
{
Guard.Equal(CurrencyId, other.CurrencyId, "Cannot add different currencies");
return new MoneyAmount(Value + other.Value, CurrencyId);
}
}
// Immutable DTO — record class
public sealed record WithdrawalDecisionResult(
Guid WithdrawalId,
WithdrawalStatus NewStatus,
DateTimeOffset DecidedAt,
Guid DecidedBy);
// Kafka event payload — record class
public sealed record WithdrawalApprovedEvent : IIntegrationEvent
{
public static KafkaEventDescriptor KafkaEvent { get; } = new()
{
TopicName = "Pin.accounting.withdrawal-approved",
EventType = "withdrawal.approved",
EventVersion = 1,
AggregateType = "WithdrawalRequest",
ProducerService = "accounting"
};
public required Guid WithdrawalId { get; init; }
public required MoneyAmount Amount { get; init; }
public required DateTimeOffset ApprovedAt { get; init; }
}
Mutable Aggregates — Class С Private Setters
// Aggregate Root — class, private setters, domain methods
public sealed class WithdrawalRequest : AggregateRoot
{
public Guid UserId { get; private set; }
public MoneyAmount Amount { get; private set; }
public WithdrawalStatus Status { get; private set; }
public int Version { get; private set; }
private WithdrawalRequest() { } // EF constructor
public static WithdrawalRequest Create(Guid userId, MoneyAmount amount, Guid id)
{
Guard.NotEmpty(userId, nameof(userId));
Guard.PositiveAmount(amount, nameof(amount));
var withdrawal = new WithdrawalRequest
{
Id = id,
UserId = userId,
Amount = amount,
Status = WithdrawalStatus.Pending,
Version = 0
};
withdrawal.AddDomainEvent(new WithdrawalCreatedDomainEvent(id, userId, amount));
return withdrawal;
}
public Result Approve(Guid approvedBy, int expectedVersion)
{
if (Version != expectedVersion)
return Result.Fail(new ConcurrencyConflictError(Id, Version, expectedVersion));
if (Status != WithdrawalStatus.Pending)
return Result.Fail(new InvalidStatusTransitionError(Status, WithdrawalStatus.Approved));
Status = WithdrawalStatus.Approved;
Version++;
AddDomainEvent(new WithdrawalApprovedDomainEvent(Id, approvedBy, Amount, DateTimeOffset.UtcNow));
return Result.Ok();
}
}
Sealed By Default
Правило
Все классы в Domain и Application запечатаны по умолчанию. sealed снимается только если наследование явно проектируется.
// ХОРОШО
public sealed class IncentiveWagerCalculator { ... }
public sealed record WithdrawalDecisionResult(...);
public sealed class WithdrawalRepository : IWithdrawalRepository { ... }
// ПЛОХО — открытый класс без причины
public class IncentiveWagerCalculator { ... }
Исключения:
- базовые классы:
AggregateRoot<T>,PinEntity<T>,PinDbContext; PinHubBase<T>,PinConsumerBase<T>и другие building blocks, предназначенные для наследования.
Required Properties И Init-Only
C# 11+ Required Members
// Command — все поля обязательны на уровне компилятора
public sealed record ApproveWithdrawalCommand : ICommand<ApproveWithdrawalResult>
{
public required Guid WithdrawalId { get; init; }
public required int ExpectedVersion { get; init; }
public required string DecisionReasonCode { get; init; }
}
// Использование — компилятор требует все required поля
var command = new ApproveWithdrawalCommand
{
WithdrawalId = withdrawalId,
ExpectedVersion = 3,
DecisionReasonCode = "kyc_approved"
};
Init-Only Для Неизменяемых Данных После Создания
public sealed class UserProfile : PinEntity
{
// Задаётся один раз при создании, не меняется
public Guid UserId { get; init; }
public DateTimeOffset RegisteredAt { get; init; }
// Меняется через domain methods
public string DisplayName { get; private set; } = string.Empty;
public KycStatus KycStatus { get; private set; }
}
Primary Constructors (.NET 8+)
Используются для сервисов и handlers — удаляет boilerplate DI:
// ХОРОШО — primary constructor
public sealed class ApproveWithdrawalCommandHandler(
IWithdrawalRepository withdrawalRepository,
IWithdrawalDecision CheckPort decision checkPort,
IPinEventPublisher eventPublisher,
IActorContext actorContext,
ILogger<ApproveWithdrawalCommandHandler> logger)
: ICommandHandler<ApproveWithdrawalCommand, ApproveWithdrawalResult>
{
public async Task<Result<ApproveWithdrawalResult>> Handle(
ApproveWithdrawalCommand command,
CancellationToken ct)
{
// ...
}
}
// ПЛОХО — избыточный boilerplate
public sealed class ApproveWithdrawalCommandHandler : ICommandHandler<...>
{
private readonly IWithdrawalRepository _repository;
private readonly ILogger<ApproveWithdrawalCommandHandler> _logger;
public ApproveWithdrawalCommandHandler(
IWithdrawalRepository repository,
ILogger<ApproveWithdrawalCommandHandler> logger)
{
_repository = repository;
_logger = logger;
}
}
Правило: primary constructor параметры не присваиваются в поля вручную — используются напрямую в методах. Если параметр нужен в нескольких методах, допускается readonly поле.
Pattern Matching
Switch Expression Для State Transitions
// ХОРОШО — exhaustive switch expression
public static WithdrawalStatusCode ToStatusCode(WithdrawalStatus status) =>
status switch
{
WithdrawalStatus.Pending => WithdrawalStatusCode.Pending,
WithdrawalStatus.Approved => WithdrawalStatusCode.Approved,
WithdrawalStatus.Rejected => WithdrawalStatusCode.Rejected,
WithdrawalStatus.Cancelled => WithdrawalStatusCode.Cancelled,
WithdrawalStatus.Processing => WithdrawalStatusCode.Processing,
_ => throw new UnreachableException($"Unhandled status: {status}")
};
Is-Pattern Для Typed Result Branches
var result = await _repository.Find(command.WithdrawalId, ct);
if (result is null)
return Result.Fail(new NotFoundError(typeof(WithdrawalRequest), command.WithdrawalId));
// Nested pattern matching
if (result is { Status: WithdrawalStatus.Pending, UserId: var userId })
{
// работаем с userId, уже извлечённым
}
List Patterns (.NET 7+)
// Проверка структуры коллекции
var validationResult = errors switch
{
[] => Result.Ok(),
[var single] => Result.Fail(single),
[var first, ..] => Result.Fail(new AggregateError(errors))
};
Guard Clauses
Стандартный Guard API
// В Domain — бросает DomainException
public static class Guard
{
public static void NotNull<T>(T? value, string paramName) where T : class
{
if (value is null)
throw new DomainException($"{paramName} must not be null.");
}
public static void NotEmpty(string? value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException($"{paramName} must not be empty.");
}
public static void NotEmpty(Guid id, string paramName)
{
if (id == Guid.Empty)
throw new DomainException($"{paramName} must not be empty Guid.");
}
public static void PositiveAmount(MoneyAmount amount, string paramName)
{
if (amount.Value <= 0)
throw new DomainException($"{paramName} must be a positive amount.");
}
public static void PositiveOrZero(decimal value, string paramName)
{
if (value < 0)
throw new DomainException($"{paramName} must be >= 0.");
}
public static void Equal<T>(T expected, T actual, string message)
where T : IEquatable<T>
{
if (!expected.Equals(actual))
throw new DomainException(message);
}
public static void InRange(decimal value, decimal min, decimal max, string paramName)
{
if (value < min || value > max)
throw new DomainException($"{paramName} must be between {min} and {max}.");
}
}
Где Используется Guard
AggregateRoot.Create(...)— Factory method, валидирует входные параметры.Value Objectконструктор — гарантирует инварианты.Domain Service— проверяет предусловия до начала операции.
Guard не используется в Application handler — там FluentValidation и Result<T>.
Exception Strategy
Правило
| Ситуация | Подход |
|---|---|
| Ожидаемая бизнес-ошибка (нет доступа, неверный статус, лимит) | Result.Fail(new TypedError(...)) |
| Нарушение инварианта домена | throw new DomainException(message) в aggregate/guard |
| Неожиданная системная ошибка (БД недоступна, OOM) | Пробрасывается наверх, ловится в middleware |
| Unsupported enum case / unreachable code | throw new UnreachableException(...) |
| Отсутствие required configuration | throw new InvalidOperationException(...) в ValidateOnStart |
Typed Errors (FluentResults)
// Базовые типы ошибок
public sealed record NotFoundError(Type EntityType, object Id)
: IError
{
public string Message => $"{EntityType.Name} with id '{Id}' was not found.";
public Dictionary<string, object> Metadata => new() { ["entityType"] = EntityType.Name, ["id"] = Id };
public List<IError> Reasons => [];
}
public sealed record InvalidStatusTransitionError(object CurrentStatus, object TargetStatus)
: IError
{
public string Message => $"Cannot transition from {CurrentStatus} to {TargetStatus}.";
public Dictionary<string, object> Metadata => new();
public List<IError> Reasons => [];
}
public sealed record ConcurrencyConflictError(object EntityId, int CurrentVersion, int ExpectedVersion)
: IError
{
public string Message => $"Concurrency conflict on entity '{EntityId}'. Expected v{ExpectedVersion}, got v{CurrentVersion}.";
public Dictionary<string, object> Metadata => new();
public List<IError> Reasons => [];
}
public sealed record ForbiddenError(string RequiredPermission)
: IError
{
public string Message => $"Access denied. Required permission: {RequiredPermission}.";
public Dictionary<string, object> Metadata => new();
public List<IError> Reasons => [];
}
Запрещённые Паттерны
// ПЛОХО — throw для бизнес-логики
if (withdrawal.Status != WithdrawalStatus.Pending)
throw new InvalidOperationException("Withdrawal is not pending");
// ПЛОХО — bool return без информации об ошибке
bool Approve() { ... }
// ПЛОХО — Exception как control flow в application
try { await _repository.Get(id, ct); }
catch (NotFoundException) { return NotFound(); }
// ХОРОШО — typed result
var result = await _repository.Find(id, ct);
if (result is null)
return Result.Fail(new NotFoundError(typeof(WithdrawalRequest), id.Value));
Async / Await
Правила
// 1. CancellationToken ВЕЗДЕ где есть I/O
Task<Result<UserProfile>> Find(Guid userId, CancellationToken ct);
// 2. Не блокируй async — запрещены .Result, .Wait(), GetAwaiter().GetResult() в production коде
// ПЛОХО
var result = repository.Find(userId, ct).Result;
// ХОРОШО
var result = await repository.Find(userId, ct);
// 3. ConfigureAwait(false) в library/infrastructure коде
var data = await _dbContext.Withdrawals.FindAsync(id, ct).ConfigureAwait(false);
// 4. Собственные методы НЕ используют суффикс Async — только framework/library API
// ПЛОХО
public Task<WithdrawalRequest?> FindWithdrawalAsync(Guid id, CancellationToken ct)
// ХОРОШО
public Task<WithdrawalRequest?> Find(Guid id, CancellationToken ct)
// 5. ValueTask для hot path с синхронным fast-path
public ValueTask<bool> Exists(CacheKey key) =>
_cache.TryGet(key, out _) ? ValueTask.FromResult(true) : CheckDatabaseAsync(key);
// 6. IAsyncEnumerable для потоковых операций — не загружать всё в память
public IAsyncEnumerable<ExportRow> StreamExport(ExportFilter filter, CancellationToken ct);
Deadlock Prevention
// ЗАПРЕЩЕНО в handlers и services
Task.Run(() => SomeMethod().Result).Wait();
SomeMethod().GetAwaiter().GetResult(); // за исключением Main() entry points
// Исключение — Startup/Program.cs, где async не доступен
builder.Services.AddSingleton(_ =>
SomeAsyncFactory().GetAwaiter().GetResult()); // ПЛОХО даже здесь — использовать IHostedService
No Magic Strings И No Magic Numbers
Constants
// ПЛОХО
if (status == "pending") { }
if (attempts > 3) { }
await cache.Set("user:balance:" + userId, value);
// ХОРОШО — константы в доменном месте
public static class WithdrawalStatusCodes
{
public const string Pending = "pending";
public const string Approved = "approved";
public const string Rejected = "rejected";
}
public static class RetryPolicy
{
public const int MaxAttempts = 3;
public const int ExponentialBaseMs = 100;
}
public static class CacheKeys
{
public static string UserBalance(Guid userId) => $"user:balance:{userId}";
public static string GamePage(int brandId, int page, int pageSize) =>
$"game-catalog:{brandId}:{page}:{pageSize}";
}
Enums Вместо String Status
// ХОРОШО
public enum WithdrawalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Processing = 3,
Completed = 4,
Cancelled = 5,
Failed = 6
}
// EF stores as string для читаемости в БД
.HasConversion<string>()
Structural Equality И Comparisons
Value Objects Используют Records
// Record автоматически реализует ==, !=, Equals, GetHashCode
var a = new MoneyAmount(100m, currencyId: 1);
var b = new MoneyAmount(100m, currencyId: 1);
Assert.True(a == b); // true — value equality
// Record struct для value objects с бизнес-семантикой (не для entity ID)
public readonly record struct DateRange(DateTimeOffset From, DateTimeOffset To)
{
public bool Contains(DateTimeOffset point) => point >= From && point <= To;
}
public readonly record struct WagerMultiplier(decimal Value)
{
public WagerMultiplier { Guard.InRange(Value, 1m, 100m, nameof(Value)); }
}
IDisposable И Resource Management
// Using declarations — современный синтаксис
public async Task ProcessFile(Stream input, CancellationToken ct)
{
using var reader = new StreamReader(input);
await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct);
var content = await reader.ReadToEndAsync(ct);
// transaction auto-disposed even on exception
}
// IAsyncDisposable для async cleanup
public sealed class RealtimeNotificationRelay : BackgroundService, IAsyncDisposable
{
private readonly ISubscriber _subscriber;
public async ValueTask DisposeAsync()
{
await _subscriber.UnsubscribeAllAsync();
GC.SuppressFinalize(this);
}
}
Запрещённые Конструкции
| Конструкция | Причина | Альтернатива |
|---|---|---|
dynamic в domain/application | Нет type safety, нет IntelliSense, runtime ошибки | Typed model, discriminated union через sealed records |
object как параметр/поле в contracts | Скрывает реальный тип, нет валидации | Generic type parameter или конкретный sealed record |
Dictionary<string, object> в domain | Невозможно проверить completeness | Typed record с required полями |
JsonDocument / JsonElement в application | Нет типов, нет документации | Typed DTO с [JsonPropertyName] |
Thread.Sleep | Блокирует поток | await Task.Delay(ms, ct) |
.Result / .Wait() | Deadlock potential | await |
catch (Exception e) { /* ignore */ } | Скрывает ошибки | Логировать, маппить в Result или пробрасывать |
GC.Collect() | Нарушает GC schedule | Никогда в production коде |
| Mutable static state | Race conditions | DI singleton с lock или IOptions |
#pragma warning disable без комментария | Скрывает проблемы | Исправить проблему или документировать исключение |
Code Review Checklist — Quality
Перед merge:
-
#nullable enableво всех проектах, нетnull!в domain/application без комментария - Все records sealed, все классы sealed где нет явного наследования
- Нет magic strings/numbers — константы или enums
- Guard clauses в aggregate factory methods и value object constructors
- CancellationToken во всех I/O методах
- Нет
.Result/.Wait()в async-контексте - Бизнес-ошибки через
Result.Fail(new TypedError(...)), не черезthrow -
DomainExceptionтолько в aggregate/guard для нарушений инвариантов - Pattern matching exhaustive (underscore case с
UnreachableException) - Нет
dynamic,object,JsonDocumentв domain/application contracts -
IDisposable/IAsyncDisposableиспользуются черезusing/await using
Ссылки
- 01 — DDD И Domain Model
- 06 — MediatR, CQRS, Pipeline Behaviors
- 12 — Resilience, FluentValidation, Audit
- ADR-0045 — C# Language Standards
- Code Quality Standards
Обратные ссылки
Автоссылки: где используется этот документ.
- adr (1): ADR-0045: C# Language Standards
- index.md (1): Backend SDK — Руководство Разработчика
- backend-shared-standards-index.md (1): Backend Shared Standards Index
- code-quality-standards.md (1): Стандарты Качества Кода — Enterprise .NET