refactor: Restructure Application layer with vertical slices and AutoMapper

Vertical slice architecture:

  - Move Features/Documents/{UseCase}/ to {UseCase}/Queries/

  - Query + Handler in SAME file (co-located)

  - Validator in separate file (single responsibility)

New structure:

  - ValidatePdf/Queries/ValidatePdfQuery.cs (Query + Handler)

  - ValidatePdf/Queries/ValidatePdfQueryValidator.cs

  - SwissQrCode/Queries/ExtractSwissQrCodeQuery.cs (Query + Handler)

  - SwissQrCode/Queries/ExtractSwissQrCodeQueryValidator.cs

AutoMapper integration:

  - Add Common/Mapping/MappingProfile.cs

  - Map PdfMetadata -> PdfValidationResult (domain -> DTO)

  - Map SwissQrCodeData -> SwissQrCodeExtractionResult (domain -> DTO)

  - Controllers now thin: pass request to MediatR, AutoMapper handles mapping

DTO improvements:

  - Rename: ValidatePdfResponse -> PdfValidationResult (business-friendly)

  - Rename: ExtractSwissQrCodeResponse -> SwissQrCodeExtractionResult

  - Support BOTH byte[] and Base64Pdf string (XOR validation)

  - Use modern C# 12 collection expressions

Code quality:

  - Use PascalCase for primary constructor parameters

  - Fix LoggingBehavior logging format

Deleted old structure:

  - Features/Documents/ValidatePdf/ (old horizontal structure)

  - Features/Documents/ExtractSwissQrCode/ (old horizontal structure)

  - Common/DTOs/{Request|Response} (replaced with {Result})

Result: Vertical slices, AutoMapper v16.2.0, thin controllers
This commit is contained in:
2026-07-07 19:01:05 +02:00
parent b460d8df39
commit d6e3a5fda1
16 changed files with 253 additions and 185 deletions

View File

@@ -0,0 +1,56 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
/// </summary>
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
/// <summary>
/// Optional reference strings (passed through to response for external tracking)
/// </summary>
public IReadOnlyList<string>? References { get; init; }
}
/// <summary>
/// Handler for ExtractSwissQrCodeQuery
/// Orchestrates Swiss QR Code extraction using ISwissQrCodeProcessor and AutoMapper
/// </summary>
public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcessor, IMapper mapper)
: IRequestHandler<ExtractSwissQrCodeQuery, SwissQrCodeExtractionResult>
{
/// <summary>
/// Extracts and parses Swiss QR Code from the last page of the PDF
/// </summary>
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Extract and parse Swiss QR Code from last page (can throw PdfProcessingException or QrCodeNotFoundException)
var qrCodeData = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken);
// Map domain value object to DTO using AutoMapper
var qrCodeDto = mapper.Map<SwissQrCodeDataDto>(qrCodeData);
// Return references (passed through) + QR code data
return new SwissQrCodeExtractionResult(
References: request.References ?? [],
QrCodeData: qrCodeDto
);
}
}

View File

@@ -0,0 +1,59 @@
using DocumentOperator.Application.SwissQrCode.Queries;
using FluentValidation;
namespace DocumentOperator.Application.SwissQrCode.Queries;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
/// </summary>
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
{
public ExtractSwissQrCodeQueryValidator()
{
RuleFor(x => x)
.Must(HasExactlyOneInput)
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
// Validate Base64 format if provided
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
{
RuleFor(x => x.Base64Pdf)
.Must(BeValidBase64)
.WithMessage("Invalid Base64 format");
});
// Validate byte array if provided
When(x => x.PdfBytes != null, () =>
{
RuleFor(x => x.PdfBytes)
.NotEmpty()
.WithMessage("PdfBytes cannot be empty");
});
}
private static bool HasExactlyOneInput(ExtractSwissQrCodeQuery request)
{
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
// XOR: exactly one must be true
return hasPdfBytes ^ hasBase64;
}
private static bool BeValidBase64(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))
return false;
try
{
Convert.FromBase64String(base64);
return true;
}
catch (FormatException)
{
return false;
}
}
}