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

@@ -8,16 +8,9 @@ namespace DocumentOperator.Application.Common.Behaviors;
/// MediatR Pipeline Behavior that logs requests and tracks performance
/// Executes AFTER ValidationBehavior, BEFORE Handler
/// </summary>
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> Logger) : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
@@ -25,21 +18,18 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
{
var requestName = typeof(TRequest).Name;
// Request Start
_logger.LogInformation("Handling {RequestName}: {@Request}", requestName, request);
// Performance Tracking
var stopwatch = Stopwatch.StartNew();
try
{
// Handler ausführen
var response = await next();
var response = await next(cancellationToken);
stopwatch.Stop();
// Request Success
_logger.LogInformation(
Logger.LogInformation(
"Handled {RequestName} in {ElapsedMs}ms",
requestName,
stopwatch.ElapsedMilliseconds
@@ -52,7 +42,7 @@ public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest,
stopwatch.Stop();
// Request Failed
_logger.LogError(
Logger.LogError(
ex,
"Error handling {RequestName} after {ElapsedMs}ms: {ErrorMessage}",
requestName,

View File

@@ -1,18 +0,0 @@
namespace DocumentOperator.Application.Common.DTOs;
/// <summary>
/// Request to extract Swiss QR Code from a PDF document.
/// The QR code must be located on the last page of the document.
/// </summary>
/// <param name="References">Array of reference strings to pass through in the response</param>
/// <param name="Base64Pdf">PDF document encoded as Base64 string</param>
/// <example>
/// {
/// "references": ["REF-001", "REF-002"],
/// "base64Pdf": "JVBERi0xLjQK..."
/// }
/// </example>
public record ExtractSwissQrCodeRequest(
IReadOnlyList<string> References,
string Base64Pdf
);

View File

@@ -4,12 +4,12 @@ namespace DocumentOperator.Application.Common.DTOs;
/// Response mit PDF-Metadaten
/// </summary>
/// <param name="PageCount">Anzahl der Seiten</param>
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
/// <param name="FileSizeBytes">Dateigröße in Bytes</param>
/// <param name="FileSizeMB">Dateigröße in MB (gerundet auf 2 Dezimalstellen)</param>
/// <param name="PdfVersion">PDF-Version (z.B. "1.4")</param>
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
public record ValidatePdfResponse(
/// <param name="HasAttachments">Hat das PDF Anhänge?</param>
/// <param name="AttachmentCount">Anzahl der Anhänge</param>
public record PdfValidationResult(
int PageCount,
long FileSizeBytes,
double FileSizeMB,

View File

@@ -29,7 +29,7 @@ namespace DocumentOperator.Application.Common.DTOs;
/// }
/// }
/// </example>
public record ExtractSwissQrCodeResponse(
public record SwissQrCodeExtractionResult(
IReadOnlyList<string> References,
SwissQrCodeDataDto QrCodeData
);

View File

@@ -1,7 +0,0 @@
namespace DocumentOperator.Application.Common.DTOs;
/// <summary>
/// Request für PDF-Validierung
/// </summary>
/// <param name="Base64Pdf">Base64-encodiertes PDF-Dokument</param>
public record ValidatePdfRequest(string Base64Pdf);

View File

@@ -0,0 +1,24 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentOperator.Domain.ValueObjects;
namespace DocumentOperator.Application.Common.Mapping;
/// <summary>
/// AutoMapper profile for mapping domain entities to DTOs
/// </summary>
public class MappingProfile : Profile
{
public MappingProfile()
{
// PdfMetadata -> PdfValidationResult
CreateMap<PdfMetadata, PdfValidationResult>();
// SwissQrCodeData -> SwissQrCodeDataDto
CreateMap<SwissQrCodeData, SwissQrCodeDataDto>();
// AddressData -> AddressDataDto
CreateMap<AddressData, AddressDataDto>();
}
}

View File

@@ -1,33 +0,0 @@
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Handles extraction of Swiss QR Code from PDF documents.
/// Uses ISwissQrCodeProcessor to extract QR code from the last page and parse it.
/// </summary>
public sealed class ExtractSwissQrCodeHandler : IRequestHandler<ExtractSwissQrCodeQuery, ExtractSwissQrCodeResult>
{
private readonly ISwissQrCodeProcessor _qrCodeProcessor;
public ExtractSwissQrCodeHandler(ISwissQrCodeProcessor qrCodeProcessor)
{
_qrCodeProcessor = qrCodeProcessor;
}
public async Task<ExtractSwissQrCodeResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
{
// Convert Base64 string to byte array
byte[] pdfBytes = request.PdfContent.ToByteArray();
// Extract and parse Swiss QR Code from last page
var qrCodeData = await _qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, cancellationToken);
// Return references (passed through) + QR code data
return new ExtractSwissQrCodeResult(
References: request.References,
QrCodeData: qrCodeData
);
}
}

View File

@@ -1,26 +0,0 @@
using DocumentOperator.Domain.Models.ValueObjects;
using DocumentOperator.Domain.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Query to extract Swiss QR Code data from a PDF document.
/// Returns references (passed through) and parsed QR code data.
/// </summary>
/// <param name="References">Array of reference strings to pass through in the response</param>
/// <param name="PdfContent">PDF document content as Base64 string</param>
public record ExtractSwissQrCodeQuery(
IReadOnlyList<string> References,
Base64String PdfContent
) : IRequest<ExtractSwissQrCodeResult>;
/// <summary>
/// Result containing passed-through references and extracted Swiss QR Code data
/// </summary>
/// <param name="References">Reference strings passed through from request</param>
/// <param name="QrCodeData">Parsed Swiss QR Code data from the last page of the PDF</param>
public record ExtractSwissQrCodeResult(
IReadOnlyList<string> References,
SwissQrCodeData QrCodeData
);

View File

@@ -1,21 +0,0 @@
using FluentValidation;
namespace DocumentOperator.Application.Features.Documents.ExtractSwissQrCode;
/// <summary>
/// Validates ExtractSwissQrCodeQuery before handler execution.
/// Ensures references array and PDF content are provided.
/// </summary>
public sealed class ExtractSwissQrCodeValidator : AbstractValidator<ExtractSwissQrCodeQuery>
{
public ExtractSwissQrCodeValidator()
{
RuleFor(x => x.References)
.NotNull()
.WithMessage("References array is required.");
RuleFor(x => x.PdfContent)
.NotNull()
.WithMessage("PDF content is required.");
}
}

View File

@@ -1,33 +0,0 @@
using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Handler for ValidatePdfQuery
/// Orchestrates PDF validation using IPdfProcessor
/// </summary>
public class ValidatePdfHandler : IRequestHandler<ValidatePdfQuery, PdfMetadata>
{
private readonly IPdfProcessor _pdfProcessor;
public ValidatePdfHandler(IPdfProcessor pdfProcessor)
{
_pdfProcessor = pdfProcessor;
}
/// <summary>
/// Validates PDF and returns metadata
/// </summary>
public async Task<PdfMetadata> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
{
// Value Object ? Byte Array
byte[] pdfBytes = request.PdfContent.ToByteArray();
// DevExpress Service aufrufen (kann PdfProcessingException werfen)
var metadata = await _pdfProcessor.ValidateAsync(pdfBytes);
return metadata;
}
}

View File

@@ -1,10 +0,0 @@
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Query to validate a PDF document and return metadata
/// </summary>
/// <param name="PdfContent">PDF content as Base64 string (validated by Value Object)</param>
public record ValidatePdfQuery(Base64String PdfContent) : IRequest<PdfMetadata>;

View File

@@ -1,17 +0,0 @@
using FluentValidation;
namespace DocumentOperator.Application.Features.Documents.ValidatePdf;
/// <summary>
/// Validator for ValidatePdfQuery
/// Validates that PdfContent is not null (Base64String already validates format in its constructor)
/// </summary>
public class ValidatePdfValidator : AbstractValidator<ValidatePdfQuery>
{
public ValidatePdfValidator()
{
RuleFor(x => x.PdfContent)
.NotNull()
.WithMessage("PDF content is required");
}
}

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

View File

@@ -0,0 +1,45 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Query for PDF validation (supports both byte array and Base64 input)
/// </summary>
public record ValidatePdfQuery : IRequest<PdfValidationResult>
{
/// <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>
/// Handler for ValidatePdfQuery
/// Orchestrates PDF validation using IPdfProcessor and AutoMapper
/// </summary>
public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
: IRequestHandler<ValidatePdfQuery, PdfValidationResult>
{
/// <summary>
/// Validates PDF and returns metadata
/// </summary>
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
{
// Use byte[] if available, otherwise convert Base64
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
// Call DevExpress service (can throw PdfProcessingException)
var metadata = await PdfProcessor.ValidateAsync(pdfBytes);
// Map domain entity to DTO using AutoMapper
return Mapper.Map<PdfValidationResult>(metadata);
}
}

View File

@@ -0,0 +1,59 @@
using DocumentOperator.Application.ValidatePdf.Queries;
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdf.Queries;
/// <summary>
/// Validator for ValidatePdfQuery
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
/// </summary>
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
{
public ValidatePdfQueryValidator()
{
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(ValidatePdfQuery 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;
}
}
}