Перейти к основному содержимому

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 codethrow new UnreachableException(...)
Отсутствие required configurationthrow 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Невозможно проверить completenessTyped record с required полями
JsonDocument / JsonElement в applicationНет типов, нет документацииTyped DTO с [JsonPropertyName]
Thread.SleepБлокирует потокawait Task.Delay(ms, ct)
.Result / .Wait()Deadlock potentialawait
catch (Exception e) { /* ignore */ }Скрывает ошибкиЛогировать, маппить в Result или пробрасывать
GC.Collect()Нарушает GC scheduleНикогда в production коде
Mutable static stateRace conditionsDI 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

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.