Skip to content
IOF edited this page Nov 28, 2025 · 1 revision

Архитектура Vertival Slice

public record CreateAccountDto(
    AccountType Type,
    string Currency,
    decimal Balance,
    decimal? InterestRate
);

public static class CreateAccountEndpoint
{
    public static WebApplication MapEndpoint(this WebApplication app)
    {
        app.MapPost("/account", HandleEndpoint)
            .WithTags("Аккаунты")
            .WithName("CreateAccount")
            .WithSummary("Создание нового счета")
            .WithDescription("Возвращает объект счета Account")
            .Produces<Account>(StatusCodes.Status201Created)
            .Produces<MbResult<Account>>(StatusCodes.Status400BadRequest)
            .Produces(StatusCodes.Status401Unauthorized)
            .RequireAuthorization();

        return app;
    }

    public static async Task<IResult> HandleEndpoint(
        CreateAccountDto createAccountDto,
        IMediator mediator,
        ClaimsPrincipal user
    )
    {
        var ownerId = user.GetOwnerIdFromClaims();
        if (ownerId == Guid.Empty)
            return Results.Unauthorized();

        var request = new CreateAccountRequest(createAccountDto, ownerId);
        var response = await mediator.Send(request);

        return response.IsSuccess
            ? Results.CreatedAtRoute(
                "GetAccount",
                new { accountId = response.Value.Id },
                response.Value.ToDto()
            )
            // ? Results.Created($"/account/{response.Value.Id}", response.Value)
            : Results.BadRequest(response);
    }
}

public class CreateAccountHandler(
    IAccountRepository repo,
    ILogger<CreateAccountHandler> logger,
    ModuleBankAppContext db
) : IRequestHandler<CreateAccountRequest, MbResult<Account>>
{
    public async Task<MbResult<Account>> Handle(CreateAccountRequest request, CancellationToken ct)
    {
        // ReSharper disable once RedundantEmptyObjectCreationArgumentList
        var account = new Account()
        {
            Type = request.CreateAccountDto.Type,
            Balance = request.CreateAccountDto.Balance,
            InterestRate = request.CreateAccountDto.InterestRate,
            Currency = request.CreateAccountDto.Currency,
            OwnerId = (Guid)request.ClaimsId!,
        };

        var @event = new AccountOpened(
            Guid.NewGuid(),
            account.CreatedAt,
            account.Id,
            account.OwnerId,
            account.Currency,
            account.Type
        );

        await using var tx = await db.Database.BeginTransactionAsync(ct);

        var savedAccount = await repo.CreateAccount(account);
        logger.LogInformation(
            "\n Creating account {Id} for user {OwnerId} \n",
            savedAccount.Id,
            savedAccount.OwnerId
        );

        await db.Outbox.AddAsync(
            new OutboxMessage
            {
                Type = nameof(AccountOpened),
                Payload = JsonSerializer.Serialize(@event),
                Status = OutboxStatus.Pending,
            },
            ct
        );

        await db.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);

        return MbResult<Account>.Success(savedAccount);
    }
}

public record CreateAccountRequest(CreateAccountDto CreateAccountDto, Guid? ClaimsId)
    : IRequest<MbResult<Account>>;


public class CreateAccountValidator : AbstractValidator<CreateAccountRequest>
{
    public CreateAccountValidator(ICurrencyService currencyService)
    {
        ClassLevelCascadeMode = CascadeMode.Continue;
        RuleLevelCascadeMode = CascadeMode.Stop;

        RuleFor(x => x.CreateAccountDto.Type)
            .Must(t => t is AccountType.Credit or AccountType.Deposit or AccountType.Checking)
            .WithMessage("Тип счёта должен быть 'Credit', 'Deposit' или 'Checking'");

        RuleFor(x => x.CreateAccountDto.Currency)
            .NotEmpty()
            .WithMessage("Требуется код валюты.")
            .Length(3)
            .WithMessage("Код валюты должен состоять из 3 символов.")
            .Must(currencyService.IsValidCurrencyCode)
            .WithMessage("Неверный код валюты (ISO 4217).");

        // Депозит/расчётный — баланс >= 0
        When(
            x => x.CreateAccountDto.Type is AccountType.Deposit or AccountType.Checking,
            () =>
            {
                RuleFor(x => x.CreateAccountDto.Balance)
                    .NotNull()
                    .GreaterThanOrEqualTo(0)
                    .WithMessage("Баланс не может быть отрицательным для выбранного типа счёта.");
            }
        );

        // Кредит — баланс <= 0 (если нужен кредитный лимит, вынесите его отдельно)
        When(
            x => x.CreateAccountDto.Type is AccountType.Credit,
            () =>
            {
                RuleFor(x => x.CreateAccountDto.Balance)
                    .NotNull()
                    .LessThanOrEqualTo(0)
                    .WithMessage("Для кредитного счёта баланс должен быть неположительным (≤ 0).");
            }
        );

        // Ставка обязательна для Deposit/Credit
        When(
            x => x.CreateAccountDto.Type is AccountType.Deposit or AccountType.Credit,
            () =>
            {
                RuleFor(x => x.CreateAccountDto.InterestRate)
                    .NotNull()
                    .GreaterThan(0)
                    .WithMessage(
                        "Для выбранного типа счета требуется положительная процентная ставка."
                    );
            }
        );

        RuleFor(x => x.CreateAccountDto.Type)
            .Must(type => Enum.IsDefined(type) && type != AccountType.None)
            .WithMessage("Недопустимый тип счёта.");
    }
}

Clone this wiki locally