From 60a7b790d45c3ec1e5c9e9ce19b387dff3ce96ec Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Fri, 19 Jun 2026 23:23:10 -0400 Subject: [PATCH] Scaffold vertical-slice migration: Shared.Contracts, Shared.Application, MediatR Begins the move to vertical-slice architecture. ResponseBody moves out of API.Core into Shared.Contracts so slices won't have to depend on the API host project for it. Shared.Application adds the MediatR pipeline's ValidationBehavior plus the cross-slice email commands that Features.Auth will send and Features.Emails will handle, keeping slices decoupled from each other. --- web/backend/API/API.Core/API.Core.csproj | 3 ++ .../JwtAuthenticationHandler.cs | 2 +- .../API/API.Core/Contracts/Auth/Login.cs | 2 +- .../API/API.Core/Contracts/Auth/Register.cs | 2 +- .../API.Core/Controllers/AuthController.cs | 2 +- .../API.Core/Controllers/BreweryController.cs | 2 +- .../Controllers/ProtectedController.cs | 2 +- web/backend/API/API.Core/GlobalException.cs | 2 +- web/backend/API/API.Core/Program.cs | 9 ++++ web/backend/Core.slnx | 4 ++ .../Behaviors/ValidationBehavior.cs | 44 +++++++++++++++++++ .../Emails/SendRegistrationEmailCommand.cs | 13 ++++++ .../SendResendConfirmationEmailCommand.cs | 13 ++++++ .../Shared.Application.csproj | 13 ++++++ .../Shared.Contracts}/ResponseBody.cs | 2 +- .../Shared.Contracts/Shared.Contracts.csproj | 8 ++++ 16 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs create mode 100644 web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs create mode 100644 web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs create mode 100644 web/backend/Shared/Shared.Application/Shared.Application.csproj rename web/backend/{API/API.Core/Contracts/Common => Shared/Shared.Contracts}/ResponseBody.cs (95%) create mode 100644 web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj diff --git a/web/backend/API/API.Core/API.Core.csproj b/web/backend/API/API.Core/API.Core.csproj index aab193e..35541dc 100644 --- a/web/backend/API/API.Core/API.Core.csproj +++ b/web/backend/API/API.Core/API.Core.csproj @@ -17,6 +17,7 @@ Include="FluentValidation.AspNetCore" Version="11.3.0" /> + @@ -33,6 +34,8 @@ + + diff --git a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs index 0072a84..90c5756 100644 --- a/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs +++ b/web/backend/API/API.Core/Authentication/JwtAuthenticationHandler.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using System.Text.Encodings.Web; using System.Text.Json; -using API.Core.Contracts.Common; +using Shared.Contracts; using Infrastructure.Jwt; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; diff --git a/web/backend/API/API.Core/Contracts/Auth/Login.cs b/web/backend/API/API.Core/Contracts/Auth/Login.cs index 4d9a93f..ce64c9a 100644 --- a/web/backend/API/API.Core/Contracts/Auth/Login.cs +++ b/web/backend/API/API.Core/Contracts/Auth/Login.cs @@ -1,4 +1,4 @@ -using API.Core.Contracts.Common; +using Shared.Contracts; using FluentValidation; namespace API.Core.Contracts.Auth; diff --git a/web/backend/API/API.Core/Contracts/Auth/Register.cs b/web/backend/API/API.Core/Contracts/Auth/Register.cs index 26ea2d3..a1407a2 100644 --- a/web/backend/API/API.Core/Contracts/Auth/Register.cs +++ b/web/backend/API/API.Core/Contracts/Auth/Register.cs @@ -1,4 +1,4 @@ -using API.Core.Contracts.Common; +using Shared.Contracts; using FluentValidation; namespace API.Core.Contracts.Auth; diff --git a/web/backend/API/API.Core/Controllers/AuthController.cs b/web/backend/API/API.Core/Controllers/AuthController.cs index 245f60f..58d7fdb 100644 --- a/web/backend/API/API.Core/Controllers/AuthController.cs +++ b/web/backend/API/API.Core/Controllers/AuthController.cs @@ -1,5 +1,5 @@ using API.Core.Contracts.Auth; -using API.Core.Contracts.Common; +using Shared.Contracts; using Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/web/backend/API/API.Core/Controllers/BreweryController.cs b/web/backend/API/API.Core/Controllers/BreweryController.cs index 6403027..d95074f 100644 --- a/web/backend/API/API.Core/Controllers/BreweryController.cs +++ b/web/backend/API/API.Core/Controllers/BreweryController.cs @@ -1,5 +1,5 @@ using API.Core.Contracts.Breweries; -using API.Core.Contracts.Common; +using Shared.Contracts; using Domain.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/web/backend/API/API.Core/Controllers/ProtectedController.cs b/web/backend/API/API.Core/Controllers/ProtectedController.cs index fb7e4f2..4f1be4f 100644 --- a/web/backend/API/API.Core/Controllers/ProtectedController.cs +++ b/web/backend/API/API.Core/Controllers/ProtectedController.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using API.Core.Contracts.Common; +using Shared.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/web/backend/API/API.Core/GlobalException.cs b/web/backend/API/API.Core/GlobalException.cs index 71b01bc..4af77ad 100644 --- a/web/backend/API/API.Core/GlobalException.cs +++ b/web/backend/API/API.Core/GlobalException.cs @@ -1,6 +1,6 @@ // API.Core/Filters/GlobalExceptionFilter.cs -using API.Core.Contracts.Common; +using Shared.Contracts; using Domain.Exceptions; using FluentValidation; using Microsoft.Data.SqlClient; diff --git a/web/backend/API/API.Core/Program.cs b/web/backend/API/API.Core/Program.cs index 09587b7..0106454 100644 --- a/web/backend/API/API.Core/Program.cs +++ b/web/backend/API/API.Core/Program.cs @@ -14,6 +14,7 @@ using Service.Auth; using Service.Breweries; using Service.Emails; using Service.UserManagement.User; +using Shared.Application.Behaviors; var builder = WebApplication.CreateBuilder(args); @@ -31,6 +32,14 @@ builder.Services.AddOpenApi(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddFluentValidationAutoValidation(); +// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced; +// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers. +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); +}); + // Add health checks builder.Services.AddHealthChecks(); diff --git a/web/backend/Core.slnx b/web/backend/Core.slnx index 6add115..860f7f7 100644 --- a/web/backend/Core.slnx +++ b/web/backend/Core.slnx @@ -29,4 +29,8 @@ + + + + diff --git a/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..305d057 --- /dev/null +++ b/web/backend/Shared/Shared.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using MediatR; + +namespace Shared.Application.Behaviors; + +/// +/// MediatR pipeline behavior that runs all registered FluentValidation validators for a +/// request before invoking its handler, short-circuiting with a +/// when any validator reports failures. +/// +public class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken + ) + { + if (!validators.Any()) + { + return await next(); + } + + var context = new ValidationContext(request); + + var failures = ( + await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken)) + ) + ) + .SelectMany(result => result.Errors) + .Where(failure => failure != null) + .ToList(); + + if (failures.Count > 0) + { + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs new file mode 100644 index 0000000..8a6c86a --- /dev/null +++ b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace Shared.Application.Emails; + +/// +/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// registration confirmation email, without either slice taking a project reference on the other. +/// +public record SendRegistrationEmailCommand( + string FirstName, + string Email, + string ConfirmationToken +) : IRequest; diff --git a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs new file mode 100644 index 0000000..e4d2ba4 --- /dev/null +++ b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace Shared.Application.Emails; + +/// +/// Cross-slice command sent by Features.Auth and handled by Features.Emails to dispatch a +/// fresh confirmation-link email, without either slice taking a project reference on the other. +/// +public record SendResendConfirmationEmailCommand( + string FirstName, + string Email, + string ConfirmationToken +) : IRequest; diff --git a/web/backend/Shared/Shared.Application/Shared.Application.csproj b/web/backend/Shared/Shared.Application/Shared.Application.csproj new file mode 100644 index 0000000..e03dc4e --- /dev/null +++ b/web/backend/Shared/Shared.Application/Shared.Application.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + Shared.Application + + + + + + + diff --git a/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs b/web/backend/Shared/Shared.Contracts/ResponseBody.cs similarity index 95% rename from web/backend/API/API.Core/Contracts/Common/ResponseBody.cs rename to web/backend/Shared/Shared.Contracts/ResponseBody.cs index e0c6935..080aca2 100644 --- a/web/backend/API/API.Core/Contracts/Common/ResponseBody.cs +++ b/web/backend/Shared/Shared.Contracts/ResponseBody.cs @@ -1,4 +1,4 @@ -namespace API.Core.Contracts.Common; +namespace Shared.Contracts; /// /// Generic envelope used to wrap API responses that carry a data payload alongside a human-readable message. diff --git a/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj new file mode 100644 index 0000000..7401600 --- /dev/null +++ b/web/backend/Shared/Shared.Contracts/Shared.Contracts.csproj @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + Shared.Contracts + +