diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 000000000..5fff55025 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj" +} \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1183550af..4a7544587 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,8 +34,16 @@ jobs: - name: Test run: dotnet test "${{ env.SOLUTION_FILE }}" --configuration Release --no-build --no-restore - - name: Pack - run: dotnet pack "${{ env.SOLUTION_FILE }}" -NoDefaultExcludes --configuration Release --no-build --no-restore --output "${{ env.PACK_OUTPUT }}" + - uses: nuget/setup-nuget@v2 + name: Setup NuGet + with: + nuget-version: 'latest' + + - name: Install Mono + run: sudo apt-get update && sudo apt-get install -y mono-complete + + - name: Pack (nuspec) + run: nuget pack "${{ env.NUSPEC_FILE }}" -OutputDirectory "${{ env.PACK_OUTPUT }}" -NoDefaultExcludes - name: NuGet login (OIDC -> temp API key) uses: NuGet/login@v1 @@ -44,7 +52,7 @@ jobs: user: ${{ secrets.NUGET_USER }} - name: Push to NuGet - if: startsWith(github.ref, 'refs/tags/') # only push if building a tag + if: ${{ github.event_name == 'release' && github.event.action == 'published' && github.event.release.tag_name != '' }} shell: pwsh run: | Get-ChildItem "${{ env.PACK_OUTPUT }}" -Filter *.nupkg | ForEach-Object { diff --git a/.runsettings b/.runsettings new file mode 100644 index 000000000..6862ac743 --- /dev/null +++ b/.runsettings @@ -0,0 +1,17 @@ + + + + + 0 + + false + + + + + true + true + 0 + + + diff --git a/.template.config/template.json b/.template.config/template.json index 6e136e104..a57990526 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -1,6 +1,6 @@ { "$schema": "http://json.schemastore.org/template", - "author": "Steve Smith @ardalis, Erik Dahl", + "author": "ardalis (Steve Smith)", "classifications": [ "Web", "ASP.NET", @@ -16,14 +16,6 @@ "sourceName": "Clean.Architecture", "templateFileExtensions": [ ".cs", ".csproj", ".slnx" ], "preferNameDirectory": true, - "symbols": { - "aspire": { - "type": "parameter", - "datatype": "bool", - "defaultValue": "true", - "description": "Include .NET Aspire." - } - }, "sources": [ { "include": [ @@ -34,18 +26,12 @@ ".vscode/**", ".git/**", ".github/**", + ".idea/**", ".template.config/**", + "docs/**", "sample/**" ], "modifiers": [ - { - "condition": "(!aspire)", - "exclude": [ - "src/Clean.Architecture.AspireHost/**", - "src/Clean.Architecture.ServiceDefaults/**", - "tests/Clean.Architecture.AspireTests/**" - ] - }, { "condition": "true", "copyOnly": [ "*.dll", "*.png", "*.ico", "*.jpg", "*.jpeg", "*.ps1" ] @@ -58,4 +44,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/Clean.Architecture-NoAspire.slnx b/Clean.Architecture-NoAspire.slnx deleted file mode 100644 index 4a216b126..000000000 --- a/Clean.Architecture-NoAspire.slnx +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Clean.Architecture.slnx b/Clean.Architecture.slnx index e057dd4e4..77b21ac68 100644 --- a/Clean.Architecture.slnx +++ b/Clean.Architecture.slnx @@ -6,18 +6,21 @@ + - - + + + + diff --git a/CleanArchitecture.nuspec b/CleanArchitecture.nuspec index 3017e0406..d780ff2ef 100644 --- a/CleanArchitecture.nuspec +++ b/CleanArchitecture.nuspec @@ -3,7 +3,7 @@ Ardalis.CleanArchitecture.Template ASP.NET Core Clean Architecture Solution - 10.1.1 + 11.0.0 Steve Smith The Clean Architecture Solution Template popularized by Steve @ardalis Smith. Provides a great starting point for modern and/or DDD solutions built with .NET 8 and C# 12. @@ -13,8 +13,7 @@ MIT https://github.com/ardalis/CleanArchitecture - * Aspire on by default - * Fixes issues with sln/slnx files + * Update everything to .NET 10 latest diff --git a/Directory.Build.props b/Directory.Build.props index d9ed7bc46..a3048152b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,9 +2,10 @@ true true - net9.0 + net10.0 enable enable + latest 1591 diff --git a/Directory.Packages.props b/Directory.Packages.props index daa10e2bc..39243a3c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,49 +5,53 @@ - + - - - + + + - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + + + + + - - - - - - - - - - - - + + + + + + + + + + + + \ No newline at end of file diff --git a/PARALLEL_TEST_EXECUTION.md b/PARALLEL_TEST_EXECUTION.md new file mode 100644 index 000000000..399bfa3ed --- /dev/null +++ b/PARALLEL_TEST_EXECUTION.md @@ -0,0 +1,76 @@ +# Parallel Test Execution Configuration + +This workspace is configured to run all three test projects in parallel in Visual Studio Test Explorer. + +## Configuration Files + +### `.runsettings` (Solution Root) +- Enables parallel test execution at the assembly level +- `MaxCpuCount=0` uses all available processors to run test assemblies in parallel +- `DisableParallelization=false` ensures parallelization is enabled + +### `xunit.runner.json` (In Each Test Project) +Each test project contains an `xunit.runner.json` file with: +- `parallelizeAssembly: true` - Allows tests from this assembly to run in parallel with other assemblies +- `parallelizeTestCollections: true` - Runs test collections within the assembly in parallel +- `maxParallelThreads: 0` - Uses xUnit's default algorithm (processors × 2) + +## How to Use in Visual Studio + +### Option 1: Configure via Test Explorer Settings +1. Open **Test Explorer** (Test ? Test Explorer) +2. Click the settings icon (??) in the toolbar +3. Select **Configure Run Settings** ? **Select Solution Wide runsettings File** +4. Browse to and select the `.runsettings` file at the solution root + +### Option 2: Configure via Visual Studio Settings +1. Go to **Tools** ? **Options** +2. Navigate to **Test** ? **General** +3. Under **Run Settings File**, browse and select the `.runsettings` file + +### Option 3: Automatic Detection +Visual Studio will automatically detect and use `.runsettings` in the solution root in most cases. + +## Verification + +After configuration, when you run all tests: +- The three test projects (UnitTests, IntegrationTests, FunctionalTests) will run in parallel +- Within each project, test collections will also run in parallel +- You should see multiple tests running simultaneously in Test Explorer + +## Performance Considerations + +**Recommended for:** +- Fast unit tests (UnitTests project) +- Independent integration tests that don't share state + +**Use with caution for:** +- Tests using shared resources (databases, files, ports) +- Tests with Testcontainers - these may need collection fixtures to control parallelization + +If you encounter issues with FunctionalTests (which use Testcontainers), you may need to: +1. Disable parallelization for that specific project by setting `parallelizeAssembly: false` in its `xunit.runner.json` +2. Use xUnit Collection Fixtures to control which tests can run in parallel +3. Configure Testcontainers to use unique ports/databases per test class + +## Disabling Parallel Execution + +To disable parallel execution for a specific test project, update its `xunit.runner.json`: +```json +{ + "shadowCopy": false, + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} +``` + +## Command Line Usage + +To use the .runsettings file when running tests from the command line: +```bash +dotnet test --settings .runsettings +``` + +## Related Documentation +- [xUnit Parallel Test Execution](https://xunit.net/docs/running-tests-in-parallel) +- [Visual Studio Run Settings](https://learn.microsoft.com/en-us/visualstudio/test/configure-unit-tests-by-using-a-dot-runsettings-file) diff --git a/TESTCONTAINERS_IMPLEMENTATION.md b/TESTCONTAINERS_IMPLEMENTATION.md new file mode 100644 index 000000000..ee1d0e7da --- /dev/null +++ b/TESTCONTAINERS_IMPLEMENTATION.md @@ -0,0 +1,85 @@ +# Testcontainers Implementation for Functional Tests + +## Summary + +Successfully migrated functional tests from in-memory database to **Testcontainers** with SQL Server 2022. This provides a more realistic testing environment that matches production database behavior. + +## Changes Made + +### 1. Package References + +**Added to `Directory.Packages.props`:** +```xml + + +``` + +**Updated `tests\Clean.Architecture.FunctionalTests\Clean.Architecture.FunctionalTests.csproj`:** +- Removed: `Microsoft.EntityFrameworkCore.InMemory` +- Added: `Testcontainers` and `Testcontainers.MsSql` + +### 2. CustomWebApplicationFactory + +**File:** `tests\Clean.Architecture.FunctionalTests\CustomWebApplicationFactory.cs` + +Key changes: +- Implements `IAsyncLifetime` for proper async initialization/cleanup +- Creates a SQL Server container using `MsSqlBuilder` +- Uses SQL Server 2022 image: `mcr.microsoft.com/mssql/server:2022-latest` +- Applies EF Core migrations instead of `EnsureCreated()` +- Each test run gets a fresh containerized SQL Server instance + +```csharp +private readonly MsSqlContainer _dbContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("Your_password123!") + .Build(); +``` + +### 3. Test Configuration + +Tests now: +- Use real SQL Server running in Docker containers +- Apply actual EF Core migrations (matches production) +- Run against isolated database instances (parallel test safety) +- Clean up containers automatically after tests complete + +## Benefits + +? **Realistic Testing**: Tests run against actual SQL Server, not in-memory provider +? **Migration Testing**: Validates that migrations work correctly +? **Production Parity**: Database behavior matches production environment +? **Isolation**: Each test class gets its own containerized database +? **Automatic Cleanup**: Containers are disposed after tests complete + +## Requirements + +- **Docker Desktop** must be running on the development machine +- Tests take slightly longer (~10-13 seconds vs instant with in-memory) +- First run downloads SQL Server 2022 Docker image (~1.5 GB) + +## Test Results + +All 18 tests passing: +- ? Unit Tests: 15 tests +- ? Functional Tests: 3 tests +- ? Total Duration: ~12 seconds + +## Usage + +Run tests normally: +```bash +dotnet test +``` + +Or specifically for functional tests: +```bash +dotnet test tests\Clean.Architecture.FunctionalTests\Clean.Architecture.FunctionalTests.csproj +``` + +## Notes + +- Tests use the "Testing" environment configuration +- SQL Server password: `Your_password123!` (only for test containers) +- Container lifecycle managed by xUnit's `IAsyncLifetime` +- Containers are automatically cleaned up even if tests fail diff --git a/global.json b/global.json index e6afd73f4..e69d70fba 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100-rc.1.25451.107", + "version": "10.0.100", "rollForward": "latestMajor", "allowPrerelease": true } diff --git a/sample/Directory.Packages.props b/sample/Directory.Packages.props index 127a715aa..5204db763 100644 --- a/sample/Directory.Packages.props +++ b/sample/Directory.Packages.props @@ -39,6 +39,7 @@ + diff --git a/sample/src/NimblePros.SampleToDo.AspireHost/Program.cs b/sample/src/NimblePros.SampleToDo.AspireHost/Program.cs index 05288ed86..fac157a45 100644 --- a/sample/src/NimblePros.SampleToDo.AspireHost/Program.cs +++ b/sample/src/NimblePros.SampleToDo.AspireHost/Program.cs @@ -1,4 +1,4 @@ -using System.Net.Sockets; +using System.Net.Sockets; var builder = DistributedApplication.CreateBuilder(args); @@ -20,12 +20,10 @@ // Your web project var web = builder.AddProject("web") - - // REMOVE the .WithReference(papercut) — it’s for resources that expose connection strings - - // Pass the endpoints to the app via env vars (EndpointReference resolves to a URL at run time) - .WithEnvironment("Papercut__Smtp__Url", papercut.GetEndpoint("smtp")) - .WithEnvironment("Papercut__Ui__Url", papercut.GetEndpoint("ui")); + .WithHttpHealthCheck("/health") + // Pass the endpoints to the app via env vars (EndpointReference resolves to a URL at run time) + .WithEnvironment("Papercut__Smtp__Url", papercut.GetEndpoint("smtp")) + .WithEnvironment("Papercut__Ui__Url", papercut.GetEndpoint("ui")); // (optionally) if your app wants separate host/port values, you can parse the URL at startup, // or expose two env vars and parse them from the URL inside the app. diff --git a/sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/ContributorId.cs b/sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/ContributorId.cs index 3a15ab53d..e0a50f03a 100644 --- a/sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/ContributorId.cs +++ b/sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/ContributorId.cs @@ -2,7 +2,7 @@ namespace NimblePros.SampleToDo.Core.ContributorAggregate; -[Vogen.ValueObject] +[ValueObject] public partial struct ContributorId { private static Validation Validate(int value) diff --git a/sample/src/NimblePros.SampleToDo.ServiceDefaults/Extensions.cs b/sample/src/NimblePros.SampleToDo.ServiceDefaults/Extensions.cs index 87808a346..c5e92ee25 100644 --- a/sample/src/NimblePros.SampleToDo.ServiceDefaults/Extensions.cs +++ b/sample/src/NimblePros.SampleToDo.ServiceDefaults/Extensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -10,109 +10,118 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; - builder.AddDefaultHealthChecks(); + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); - builder.Services.AddServiceDiscovery(); + builder.AddDefaultHealthChecks(); - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); + builder.Services.AddServiceDiscovery(); - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); - return builder; - } + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddAspNetCoreInstrumentation() - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } + return builder; + } - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); - if (useOtlpExporter) + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} + builder.AddOpenTelemetryExporters(); - return builder; - } + return builder; + } - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - return builder; + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); } - public static WebApplication MapDefaultEndpoints(this WebApplication app) + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); } + + return app; + } } diff --git a/sample/src/NimblePros.SampleToDo.Web/Configurations/MiddlewareConfig.cs b/sample/src/NimblePros.SampleToDo.Web/Configurations/MiddlewareConfig.cs index 6cbefb9b3..c8db739b6 100644 --- a/sample/src/NimblePros.SampleToDo.Web/Configurations/MiddlewareConfig.cs +++ b/sample/src/NimblePros.SampleToDo.Web/Configurations/MiddlewareConfig.cs @@ -27,6 +27,8 @@ public static async Task UseAppMiddleware(this WebApplicati await SeedDatabase(app); + app.MapDefaultEndpoints(); // aspire health checks + return app; } diff --git a/sample/src/NimblePros.SampleToDo.Web/NimblePros.SampleToDo.Web.csproj b/sample/src/NimblePros.SampleToDo.Web/NimblePros.SampleToDo.Web.csproj index 49883aaa6..4b55e3716 100644 --- a/sample/src/NimblePros.SampleToDo.Web/NimblePros.SampleToDo.Web.csproj +++ b/sample/src/NimblePros.SampleToDo.Web/NimblePros.SampleToDo.Web.csproj @@ -28,12 +28,14 @@ + + diff --git a/sample/src/NimblePros.SampleToDo.Web/Program.cs b/sample/src/NimblePros.SampleToDo.Web/Program.cs index 9180fc2fc..038d51a10 100644 --- a/sample/src/NimblePros.SampleToDo.Web/Program.cs +++ b/sample/src/NimblePros.SampleToDo.Web/Program.cs @@ -13,7 +13,9 @@ private static async Task Main(string[] args) { o.AddServerHeader = false; // <- removes "Server: Kestrel" }); - + builder.AddServiceDefaults(); + + var logger = Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .WriteTo.Console() diff --git a/sample/src/NimblePros.SampleToDo.Web/appsettings.json b/sample/src/NimblePros.SampleToDo.Web/appsettings.json index 125c00bf0..24340b923 100644 --- a/sample/src/NimblePros.SampleToDo.Web/appsettings.json +++ b/sample/src/NimblePros.SampleToDo.Web/appsettings.json @@ -7,7 +7,8 @@ "MinimumLevel": { "Default": "Information", "Override": { - "NimblePros.SampleToDo": "Debug" + "NimblePros.SampleToDo": "Debug", + "Microsoft" : "Warning" } }, "WriteTo": [ @@ -20,6 +21,9 @@ "path": "log.txt", "rollingInterval": "Day" } + }, + { + "Name": "OpenTelemetry" } //Uncomment this section if you'd like to push your logs to Azure Application Insights //Full list of Serilog Sinks can be found here: https://github.com/serilog/serilog/wiki/Provided-Sinks diff --git a/sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFromValue.cs b/sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs similarity index 93% rename from sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFromValue.cs rename to sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs index 0fdc97b9f..71218c8a5 100644 --- a/sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFromValue.cs +++ b/sample/tests/NimblePros.SampleToDo.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs @@ -2,7 +2,7 @@ namespace NimblePros.SampleToDo.UnitTests.Core.ContributorAggregate; -public class ContributorIdFromValue +public class ContributorIdFrom { [Fact] public void CreatesGivenValidValue() diff --git a/sample/tests/NimblePros.SampleToDo.UnitTests/GlobalUsings.cs b/sample/tests/NimblePros.SampleToDo.UnitTests/GlobalUsings.cs index 49185b91c..7998dab43 100644 --- a/sample/tests/NimblePros.SampleToDo.UnitTests/GlobalUsings.cs +++ b/sample/tests/NimblePros.SampleToDo.UnitTests/GlobalUsings.cs @@ -1,5 +1,4 @@ -global using System.Runtime.CompilerServices; -global using Ardalis.Result; +global using Ardalis.Result; global using Ardalis.Specification; global using Mediator; global using Microsoft.Extensions.Logging; diff --git a/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj b/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj index d4d12ace9..4585016fc 100644 --- a/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj +++ b/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj @@ -1,6 +1,6 @@ - + - + Exe @@ -10,6 +10,7 @@ + @@ -19,8 +20,7 @@ - + diff --git a/src/Clean.Architecture.AspireHost/GlobalUsings.cs b/src/Clean.Architecture.AspireHost/GlobalUsings.cs new file mode 100644 index 000000000..00af5e854 --- /dev/null +++ b/src/Clean.Architecture.AspireHost/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aspire.Hosting; diff --git a/src/Clean.Architecture.AspireHost/Program.cs b/src/Clean.Architecture.AspireHost/Program.cs index e9fa775bb..33b25fb98 100644 --- a/src/Clean.Architecture.AspireHost/Program.cs +++ b/src/Clean.Architecture.AspireHost/Program.cs @@ -1,5 +1,38 @@ +using System.Net.Sockets; + var builder = DistributedApplication.CreateBuilder(args); -builder.AddProject("web"); +// Add SQL Server container +var sqlServer = builder.AddSqlServer("sqlserver") + .WithLifetime(ContainerLifetime.Persistent); + +// Add the database +var cleanArchDb = sqlServer.AddDatabase("cleanarchitecture"); + +// Papercut SMTP container for email testing +var papercut = builder.AddContainer("papercut", "jijiechen/papercut", "latest") + .WithEndpoint("smtp", e => + { + e.TargetPort = 25; // container port + e.Port = 25; // host port + e.Protocol = ProtocolType.Tcp; + e.UriScheme = "smtp"; + }) + .WithEndpoint("ui", e => + { + e.TargetPort = 37408; + e.Port = 37408; + e.UriScheme = "http"; + }); + +// Add the web project with the database connection +builder.AddProject("web") + .WithReference(cleanArchDb) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", builder.Environment.EnvironmentName) + .WithEnvironment("Papercut__Smtp__Url", papercut.GetEndpoint("smtp")) + .WaitFor(cleanArchDb) + .WaitFor(papercut); -builder.Build().Run(); +builder + .Build() + .Run(); diff --git a/src/Clean.Architecture.AspireHost/README.md b/src/Clean.Architecture.AspireHost/README.md new file mode 100644 index 000000000..75e1eba30 --- /dev/null +++ b/src/Clean.Architecture.AspireHost/README.md @@ -0,0 +1,44 @@ +# Clean Architecture Aspire Host + +This project uses .NET Aspire to orchestrate the application and its dependencies. + +## SQL Server Container + +The Aspire host is configured to run a SQL Server container and automatically provides the connection string to the Web application. + +### Running the Application + +1. Set `Clean.Architecture.AspireHost` as the startup project +2. Run the application (F5 or Ctrl+F5) +3. The Aspire Dashboard will open, showing all running resources including the SQL Server container +4. The Web application will automatically connect to the SQL Server container + +### Connection String + +When running through Aspire, the connection string is automatically provided by Aspire and will override the `DefaultConnection` in appsettings.json. The connection is named "cleanarchitecture" and is referenced in the Web project. + +### Running Without Aspire + +If you run the Web project directly (not through AspireHost), it will fall back to using the SQLite connection string from appsettings.json. + +### Database Migrations + +The existing migrations were created for SQLite but will work with SQL Server as well. If you need to create a new migration: + +From the Web project directory: +```bash +dotnet ef migrations add MigrationName -c AppDbContext -p ../Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj -s Clean.Architecture.Web.csproj -o Data/Migrations +``` + +To update the database: +```bash +dotnet ef database update -c AppDbContext -p ../Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj -s Clean.Architecture.Web.csproj +``` + +Note: When running through Aspire, the database will be automatically created in the SQL Server container if it doesn't exist. + +### Container Persistence + +The SQL Server container is configured with `ContainerLifetime.Persistent`, which means the data will persist between application runs. To reset the database, you can: +1. Delete the container through the Aspire dashboard +2. Use the Docker CLI: `docker rm ` diff --git a/src/Clean.Architecture.Core/Clean.Architecture.Core.csproj b/src/Clean.Architecture.Core/Clean.Architecture.Core.csproj index 3d5552b58..7d50c9429 100644 --- a/src/Clean.Architecture.Core/Clean.Architecture.Core.csproj +++ b/src/Clean.Architecture.Core/Clean.Architecture.Core.csproj @@ -1,5 +1,8 @@  + + + @@ -7,8 +10,9 @@ - + + diff --git a/src/Clean.Architecture.Core/Clean.Architecture.Core.sln b/src/Clean.Architecture.Core/Clean.Architecture.Core.sln new file mode 100644 index 000000000..f3394f983 --- /dev/null +++ b/src/Clean.Architecture.Core/Clean.Architecture.Core.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Clean.Architecture.Core", "Clean.Architecture.Core.csproj", "{9870518F-D672-E9CD-6676-126851E99FD1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9870518F-D672-E9CD-6676-126851E99FD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9870518F-D672-E9CD-6676-126851E99FD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9870518F-D672-E9CD-6676-126851E99FD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9870518F-D672-E9CD-6676-126851E99FD1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5942B8EC-445E-4783-ACEF-56C707B6266C} + EndGlobalSection +EndGlobal diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Contributor.cs b/src/Clean.Architecture.Core/ContributorAggregate/Contributor.cs index 3c7daebcc..aafd68da5 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Contributor.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Contributor.cs @@ -1,37 +1,24 @@ -namespace Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Core.ContributorAggregate.Events; -public class Contributor : EntityBase, IAggregateRoot +namespace Clean.Architecture.Core.ContributorAggregate; + +public class Contributor(ContributorName name) : EntityBase, IAggregateRoot { - public Contributor(string name) - { - UpdateName(name); // TODO: Replace with value object and use primary constructor to populate field. - } - public string Name { get; private set; } = default!; + public ContributorName Name { get; private set; } = name; public ContributorStatus Status { get; private set; } = ContributorStatus.NotSet; public PhoneNumber? PhoneNumber { get; private set; } - public Contributor SetPhoneNumber(string phoneNumber) - { - PhoneNumber = new PhoneNumber(string.Empty, phoneNumber, string.Empty); - return this; - } - public Contributor UpdateName(string newName) + public Contributor UpdatePhoneNumber(PhoneNumber newPhoneNumber) { - Name = Guard.Against.NullOrEmpty(newName, nameof(newName)); + PhoneNumber = newPhoneNumber; return this; } -} -public class PhoneNumber(string countryCode, string number, string? extension) : ValueObject -{ - public string CountryCode { get; private set; } = countryCode; - public string Number { get; private set; } = number; - public string? Extension { get; private set; } = extension; - - protected override IEnumerable GetEqualityComponents() + public Contributor UpdateName(ContributorName newName) { - yield return CountryCode; - yield return Number; - yield return Extension ?? String.Empty; + if (Name == newName) return this; + Name = newName; + RegisterDomainEvent(new ContributorNameUpdatedEvent(this)); + return this; } } diff --git a/src/Clean.Architecture.Core/ContributorAggregate/ContributorId.cs b/src/Clean.Architecture.Core/ContributorAggregate/ContributorId.cs new file mode 100644 index 000000000..cf1b657b9 --- /dev/null +++ b/src/Clean.Architecture.Core/ContributorAggregate/ContributorId.cs @@ -0,0 +1,14 @@ +using Vogen; + +[assembly: VogenDefaults( + staticAbstractsGeneration: StaticAbstractsGeneration.MostCommon | StaticAbstractsGeneration.InstanceMethodsAndProperties)] + + +namespace Clean.Architecture.Core.ContributorAggregate; + +[ValueObject] +public readonly partial struct ContributorId +{ + private static Validation Validate(int value) + => value > 0 ? Validation.Ok : Validation.Invalid("ContributorId must be positive."); +} diff --git a/src/Clean.Architecture.Core/ContributorAggregate/ContributorName.cs b/src/Clean.Architecture.Core/ContributorAggregate/ContributorName.cs new file mode 100644 index 000000000..d3be473d1 --- /dev/null +++ b/src/Clean.Architecture.Core/ContributorAggregate/ContributorName.cs @@ -0,0 +1,15 @@ +using Vogen; + +namespace Clean.Architecture.Core.ContributorAggregate; + +[ValueObject(conversions: Conversions.SystemTextJson)] +public partial struct ContributorName +{ + public const int MaxLength = 100; + private static Validation Validate(in string name) => + string.IsNullOrEmpty(name) + ? Validation.Invalid("Name cannot be empty") + : name.Length > MaxLength + ? Validation.Invalid($"Name cannot be longer than {MaxLength} characters") + : Validation.Ok; +} diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs b/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs index 9db807fc2..fc43f20b9 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs @@ -3,8 +3,9 @@ /// /// A domain event that is dispatched whenever a contributor is deleted. /// The DeleteContributorService is used to dispatch this event. +/// NOTE: Would prefer this be internal but Mediator Source Generator needs access to it from elsewhere. /// -internal sealed class ContributorDeletedEvent(int contributorId) : DomainEventBase +public sealed class ContributorDeletedEvent(ContributorId contributorId) : DomainEventBase { - public int ContributorId { get; init; } = contributorId; + public ContributorId ContributorId { get; init; } = contributorId; } diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorNameUpdatedEvent.cs b/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorNameUpdatedEvent.cs new file mode 100644 index 000000000..9a913a1da --- /dev/null +++ b/src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorNameUpdatedEvent.cs @@ -0,0 +1,6 @@ +namespace Clean.Architecture.Core.ContributorAggregate.Events; + +public sealed class ContributorNameUpdatedEvent(Contributor contributor) : DomainEventBase +{ + public Contributor Contributor { get; init; } = contributor; +} diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs index 825485a1e..7562dbb51 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs @@ -3,13 +3,10 @@ namespace Clean.Architecture.Core.ContributorAggregate.Handlers; -/// -/// NOTE: Internal because ContributorDeleted is also marked as internal. -/// -internal class ContributorDeletedHandler(ILogger logger, +public class ContributorDeletedHandler(ILogger logger, IEmailSender emailSender) : INotificationHandler { - public async Task Handle(ContributorDeletedEvent domainEvent, CancellationToken cancellationToken) + public async ValueTask Handle(ContributorDeletedEvent domainEvent, CancellationToken cancellationToken) { logger.LogInformation("Handling Contributed Deleted event for {contributorId}", domainEvent.ContributorId); diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs new file mode 100644 index 000000000..b411ab493 --- /dev/null +++ b/src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEmailNotificationHandler.cs @@ -0,0 +1,19 @@ +using Clean.Architecture.Core.ContributorAggregate.Events; +using Clean.Architecture.Core.Interfaces; + +namespace Clean.Architecture.Core.ContributorAggregate.Handlers; + +public class ContributorNameUpdatedEmailNotificationHandler( + ILogger logger, + IEmailSender emailSender) : INotificationHandler +{ + public async ValueTask Handle(ContributorNameUpdatedEvent domainEvent, CancellationToken cancellationToken) + { + logger.LogInformation("Handling Contributor Name Updated event for {contributorId}", domainEvent.Contributor.Id); + + await emailSender.SendEmailAsync("to@test.com", + "from@test.com", + $"Contributor {domainEvent.Contributor.Id} Name Updated", +$"Contributor with id {domainEvent.Contributor.Id} had their name updated to {domainEvent.Contributor.Name}."); + } +} diff --git a/src/Clean.Architecture.Core/ContributorAggregate/PhoneNumber.cs b/src/Clean.Architecture.Core/ContributorAggregate/PhoneNumber.cs new file mode 100644 index 000000000..1b490ac53 --- /dev/null +++ b/src/Clean.Architecture.Core/ContributorAggregate/PhoneNumber.cs @@ -0,0 +1,21 @@ +namespace Clean.Architecture.Core.ContributorAggregate; + +public class PhoneNumber(string countryCode, string number, string? extension) : ValueObject +{ + public static PhoneNumber Unknown { get; } = new PhoneNumber(String.Empty, String.Empty, String.Empty); + public string CountryCode { get; private set; } = countryCode; + public string Number { get; private set; } = number; + public string? Extension { get; private set; } = extension; + + protected override IEnumerable GetEqualityComponents() + { + yield return CountryCode; + yield return Number; + yield return Extension ?? String.Empty; + } + + public override string ToString() + { + return $"{CountryCode} {Number}{(string.IsNullOrEmpty(Extension) ? String.Empty : $" x{Extension}")}"; + } +} diff --git a/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs b/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs index e8a428c00..697b228ec 100644 --- a/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs +++ b/src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs @@ -2,7 +2,7 @@ public class ContributorByIdSpec : Specification { - public ContributorByIdSpec(int contributorId) => + public ContributorByIdSpec(ContributorId contributorId) => Query .Where(contributor => contributor.Id == contributorId); } diff --git a/src/Clean.Architecture.Core/GlobalUsings.cs b/src/Clean.Architecture.Core/GlobalUsings.cs index 6b1ef8c3f..355c64d7c 100644 --- a/src/Clean.Architecture.Core/GlobalUsings.cs +++ b/src/Clean.Architecture.Core/GlobalUsings.cs @@ -3,5 +3,5 @@ global using Ardalis.SharedKernel; global using Ardalis.SmartEnum; global using Ardalis.Specification; -global using MediatR; +global using Mediator; global using Microsoft.Extensions.Logging; diff --git a/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs b/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs index e6de2ef82..8fc90f65c 100644 --- a/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs +++ b/src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs @@ -1,8 +1,10 @@ -namespace Clean.Architecture.Core.Interfaces; +using Clean.Architecture.Core.ContributorAggregate; + +namespace Clean.Architecture.Core.Interfaces; public interface IDeleteContributorService { // This service and method exist to provide a place in which to fire domain events // when deleting this aggregate root entity - public Task DeleteContributor(int contributorId); + public ValueTask DeleteContributor(ContributorId contributorId); } diff --git a/src/Clean.Architecture.Core/README.md b/src/Clean.Architecture.Core/README.md index 7912645b3..7e1c44ff2 100644 --- a/src/Clean.Architecture.Core/README.md +++ b/src/Clean.Architecture.Core/README.md @@ -8,13 +8,21 @@ This project should contain all of your Entities, Value Objects, and business lo Entities that are related and should change together should be grouped into an Aggregate. +Aggregate folders provide good top-level folders in this project. + Entities should leverage encapsulation and should minimize public setters. Entities can leverage Domain Events to communicate changes to other parts of the system. Entities can define Specifications that can be used to query for them. -For mutable access, Entities should be accessed through a Repository interface. +Value Objects should be immutable and should implement equality based on their properties. + +Value Objects help eliminate primitive obsession in your Entities. + +Follow Parse, Don't Validate principle. https://www.youtube.com/watch?v=KQVy0CaB7ds + +For mutable access, Aggregates should be accessed through a Repository interface. Read-only ad hoc queries can use separate Query Services that don't use the Domain Model. diff --git a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs index 48e890d78..8f9093490 100644 --- a/src/Clean.Architecture.Core/Services/DeleteContributorService.cs +++ b/src/Clean.Architecture.Core/Services/DeleteContributorService.cs @@ -2,7 +2,6 @@ using Clean.Architecture.Core.ContributorAggregate.Events; using Clean.Architecture.Core.Interfaces; - namespace Clean.Architecture.Core.Services; /// @@ -16,10 +15,10 @@ public class DeleteContributorService(IRepository _repository, IMediator _mediator, ILogger _logger) : IDeleteContributorService { - public async Task DeleteContributor(int contributorId) + public async ValueTask DeleteContributor(ContributorId contributorId) { _logger.LogInformation("Deleting Contributor {contributorId}", contributorId); - Contributor? aggregateToDelete = await _repository.GetByIdAsync(contributorId); + Contributor? aggregateToDelete = await _repository.GetByIdAsync(contributorId.Value); if (aggregateToDelete == null) return Result.NotFound(); await _repository.DeleteAsync(aggregateToDelete); diff --git a/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj b/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj index 93af4f2b4..14e05644a 100644 --- a/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj +++ b/src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj @@ -6,15 +6,17 @@ - - + + + diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs b/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs index d94989316..c4d32fcb6 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs @@ -6,9 +6,15 @@ public class ContributorConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.Property(p => p.Name) - .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) - .IsRequired(); + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(entity => entity.Name) + .HasVogenConversion() + .HasMaxLength(ContributorName.MaxLength) + .IsRequired(); builder.OwnsOne(builder => builder.PhoneNumber); diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs new file mode 100644 index 000000000..b26a46854 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Data/Config/VogenEfCoreConverters.cs @@ -0,0 +1,8 @@ +using Clean.Architecture.Core.ContributorAggregate; +using Vogen; + +namespace Clean.Architecture.Infrastructure.Data.Config; + +[EfCoreConverter] +[EfCoreConverter] +internal partial class VogenEfCoreConverters; diff --git a/src/Clean.Architecture.Infrastructure/Data/Config/VogenIdValueGenerator.cs b/src/Clean.Architecture.Infrastructure/Data/Config/VogenIdValueGenerator.cs new file mode 100644 index 000000000..95abfa244 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Data/Config/VogenIdValueGenerator.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace Clean.Architecture.Infrastructure.Data.Config; + +internal class VogenIdValueGenerator : ValueGenerator + where TContext : DbContext + where TEntityBase : EntityBase + where TId : IVogen +{ + private readonly PropertyInfo _matchPropertyGetter; + + public VogenIdValueGenerator() + { + var matchingProperties = + typeof(TContext).GetProperties().Where(p => p!.GetGetMethod()!.IsPublic && p.PropertyType == typeof(DbSet)).ToList(); + + if (matchingProperties.Count == 0) + { + throw new InvalidOperationException($"No properties found in the EFCore context for a DBSet of {nameof(TEntityBase)}"); + } + + if (matchingProperties.Count > 1) + { + throw new InvalidOperationException($"Multiple properties found in the EFCore context for a DBSet of {nameof(TEntityBase)}"); + } + + _matchPropertyGetter = matchingProperties[0]; + } + + public override TId Next(EntityEntry entry) + { + TContext ctx = (TContext)entry.Context; + + DbSet entities = (DbSet)_matchPropertyGetter!.GetValue(ctx)!; + + var next = Math.Max( + MaxFrom(entities.Local), + MaxFrom(entities)) + 1; + + return TId.From(next); + + static int MaxFrom(IEnumerable es) => + es.Any() ? es.Max(e => e.Id.Value) : 0; + } + + + public override bool GeneratesTemporaryValues => false; +} diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.Designer.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.Designer.cs new file mode 100644 index 000000000..9718e6310 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.Designer.cs @@ -0,0 +1,76 @@ +// +using Clean.Architecture.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Clean.Architecture.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251113164108_UpdateForNet10")] + partial class UpdateForNet10 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Clean.Architecture.Core.ContributorAggregate.Contributor", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("Clean.Architecture.Core.ContributorAggregate.Contributor", b => + { + b.OwnsOne("Clean.Architecture.Core.ContributorAggregate.PhoneNumber", "PhoneNumber", b1 => + { + b1.Property("ContributorId") + .HasColumnType("int"); + + b1.Property("CountryCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b1.Property("Number") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("ContributorId"); + + b1.ToTable("Contributors"); + + b1.WithOwner() + .HasForeignKey("ContributorId"); + }); + + b.Navigation("PhoneNumber"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs new file mode 100644 index 000000000..37ca0dd54 --- /dev/null +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/20251113164108_UpdateForNet10.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Clean.Architecture.Infrastructure.Data.Migrations; + + /// + public partial class UpdateForNet10 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Only alter columns if using SQL Server + // SQLite uses dynamic typing so these changes aren't necessary + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") + { + migrationBuilder.AlterColumn( + name: "Status", + table: "Contributors", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_Number", + table: "Contributors", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_Extension", + table: "Contributors", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_CountryCode", + table: "Contributors", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Contributors", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + // For SQLite, no changes needed - it handles both SQLite and SQL Server type names + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder.ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer") + { + migrationBuilder.AlterColumn( + name: "Status", + table: "Contributors", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_Number", + table: "Contributors", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_Extension", + table: "Contributors", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "PhoneNumber_CountryCode", + table: "Contributors", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Contributors", + type: "TEXT", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Contributors", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "int"); + } + } + } diff --git a/src/Clean.Architecture.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Clean.Architecture.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 60b081f62..0689d56ad 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -2,6 +2,7 @@ using Clean.Architecture.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable @@ -14,21 +15,24 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); modelBuilder.Entity("Clean.Architecture.Core.ContributorAggregate.Contributor", b => { b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.Property("Name") .IsRequired() .HasMaxLength(100) - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(100)"); b.Property("Status") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b.HasKey("Id"); @@ -40,18 +44,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsOne("Clean.Architecture.Core.ContributorAggregate.PhoneNumber", "PhoneNumber", b1 => { b1.Property("ContributorId") - .HasColumnType("INTEGER"); + .HasColumnType("int"); b1.Property("CountryCode") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b1.Property("Extension") - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b1.Property("Number") .IsRequired() - .HasColumnType("TEXT"); + .HasColumnType("nvarchar(max)"); b1.HasKey("ContributorId"); diff --git a/src/Clean.Architecture.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs b/src/Clean.Architecture.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs index 0d0156989..ff556b03c 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs @@ -1,16 +1,22 @@ -using Clean.Architecture.UseCases.Contributors; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors; using Clean.Architecture.UseCases.Contributors.List; namespace Clean.Architecture.Infrastructure.Data.Queries; public class FakeListContributorsQueryService : IListContributorsQueryService { - public Task> ListAsync() + public Task> ListAsync(int page, int perPage) { - IEnumerable result = - [new ContributorDTO(1, "Fake Contributor 1", ""), - new ContributorDTO(2, "Fake Contributor 2", "")]; + var items = new List(); + for (int i = 1; i <= 25; i++) + { + var phone = new PhoneNumber("+1", "555", "1234567"); + items.Add(new ContributorDto(ContributorId.From(i), ContributorName.From($"Fake {i}"), phone)); + } + int totalPages = (int)Math.Ceiling(items.Count / (double)perPage); + var result = new UseCases.PagedResult(items, page, perPage, items.Count, totalPages); return Task.FromResult(result); } } diff --git a/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs b/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs index 4e6346a89..28c24f36e 100644 --- a/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs +++ b/src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs @@ -1,20 +1,33 @@ -using Clean.Architecture.UseCases.Contributors; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors; using Clean.Architecture.UseCases.Contributors.List; namespace Clean.Architecture.Infrastructure.Data.Queries; -public class ListContributorsQueryService(AppDbContext _db) : IListContributorsQueryService +public class ListContributorsQueryService : IListContributorsQueryService { - // You can use EF, Dapper, SqlClient, etc. for queries - - // this is just an example + // You can use EF, Dapper, SqlClient, etc. for queries + private readonly AppDbContext _db; - public async Task> ListAsync() + public ListContributorsQueryService(AppDbContext db) { - // NOTE: This will fail if testing with EF InMemory provider! - var result = await _db.Database.SqlQuery( - $"SELECT Id, Name, PhoneNumber_Number AS PhoneNumber FROM Contributors") // don't fetch other big columns + _db = db; + } + + public async Task> ListAsync(int page, int perPage) + { + var items = await _db.Contributors.FromSqlRaw("SELECT Id, Name, PhoneNumber_CountryCode, PhoneNumber_Number, PhoneNumber_Extension FROM Contributors") // don't fetch other big columns + .OrderBy(c => c.Id) + .Skip((page - 1) * perPage) + .Take(perPage) + .Select(c => new ContributorDto(c.Id, c.Name, c.PhoneNumber ?? PhoneNumber.Unknown)) + .AsNoTracking() .ToListAsync(); + int totalCount = await _db.Contributors.CountAsync(); + int totalPages = (int)Math.Ceiling(totalCount / (double)perPage); + var result = new UseCases.PagedResult(items, page, perPage, totalCount, totalPages); + return result; } } diff --git a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs index 6871ea744..c8d99aaa6 100644 --- a/src/Clean.Architecture.Infrastructure/Data/SeedData.cs +++ b/src/Clean.Architecture.Infrastructure/Data/SeedData.cs @@ -4,8 +4,9 @@ namespace Clean.Architecture.Infrastructure.Data; public static class SeedData { - public static readonly Contributor Contributor1 = new("Ardalis"); - public static readonly Contributor Contributor2 = new("Snowfrog"); + public const int NUMBER_OF_CONTRIBUTORS = 27; // including the 2 below + public static readonly Contributor Contributor1 = new(ContributorName.From("Ardalis")); + public static readonly Contributor Contributor2 = new(ContributorName.From("Ilyana")); public static async Task InitializeAsync(AppDbContext dbContext) { @@ -18,5 +19,12 @@ public static async Task PopulateTestDataAsync(AppDbContext dbContext) { dbContext.Contributors.AddRange([Contributor1, Contributor2]); await dbContext.SaveChangesAsync(); + + // add a bunch more contributors to support demonstrating paging + for (int i = 1; i <= NUMBER_OF_CONTRIBUTORS-2; i++) + { + dbContext.Contributors.Add(new Contributor(ContributorName.From($"Contributor {i}"))); + } + await dbContext.SaveChangesAsync(); } } diff --git a/src/Clean.Architecture.Infrastructure/Email/SmtpEmailSender.cs b/src/Clean.Architecture.Infrastructure/Email/SmtpEmailSender.cs deleted file mode 100644 index 2f523d5d1..000000000 --- a/src/Clean.Architecture.Infrastructure/Email/SmtpEmailSender.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Clean.Architecture.Core.Interfaces; - -namespace Clean.Architecture.Infrastructure.Email; - -/// -/// MimeKit is recommended over this now: -/// https://weblogs.asp.net/sreejukg/system-net-mail-smtpclient-is-not-recommended-anymore-what-is-the-alternative -/// -public class SmtpEmailSender(ILogger logger, - IOptions mailserverOptions) : IEmailSender -{ - private readonly ILogger _logger = logger; - private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; - - public async Task SendEmailAsync(string to, string from, string subject, string body) - { - var emailClient = new System.Net.Mail.SmtpClient(_mailserverConfiguration.Hostname, _mailserverConfiguration.Port); - - var message = new MailMessage - { - From = new MailAddress(from), - Subject = subject, - Body = body - }; - message.To.Add(new MailAddress(to)); - await emailClient.SendMailAsync(message); - _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); - } -} diff --git a/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs b/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs index de38a44ed..dc48ef913 100644 --- a/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs +++ b/src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs @@ -4,7 +4,6 @@ using Clean.Architecture.Infrastructure.Data.Queries; using Clean.Architecture.UseCases.Contributors.List; - namespace Clean.Architecture.Infrastructure; public static class InfrastructureServiceExtensions { @@ -13,15 +12,33 @@ public static IServiceCollection AddInfrastructureServices( ConfigurationManager config, ILogger logger) { - string? connectionString = config.GetConnectionString("SqliteConnection"); + // Try to get connection strings in order of priority: + // 1. "cleanarchitecture" - provided by Aspire when using .WithReference(cleanArchDb) + // 2. "DefaultConnection" - traditional SQL Server connection + // 3. "SqliteConnection" - fallback to SQLite + string? connectionString = config.GetConnectionString("cleanarchitecture") + ?? config.GetConnectionString("DefaultConnection") + ?? config.GetConnectionString("SqliteConnection"); Guard.Against.Null(connectionString); services.AddScoped(); + services.AddScoped(); services.AddDbContext((provider, options) => { var eventDispatchInterceptor = provider.GetRequiredService(); - options.UseSqlite(connectionString); + + // Use SQL Server if Aspire or DefaultConnection is available, otherwise use SQLite + if (config.GetConnectionString("cleanarchitecture") != null || + config.GetConnectionString("DefaultConnection") != null) + { + options.UseSqlServer(connectionString); + } + else + { + options.UseSqlite(connectionString); + } + options.AddInterceptors(eventDispatchInterceptor); }); @@ -30,7 +47,6 @@ public static IServiceCollection AddInfrastructureServices( .AddScoped() .AddScoped(); - logger.LogInformation("{Project} services registered", "Infrastructure"); return services; diff --git a/src/Clean.Architecture.Infrastructure/README.md b/src/Clean.Architecture.Infrastructure/README.md index 4238330d7..25faac473 100644 --- a/src/Clean.Architecture.Infrastructure/README.md +++ b/src/Clean.Architecture.Infrastructure/README.md @@ -8,9 +8,20 @@ Infrastructure should depend on Core (and, optionally, Use Cases) where abstract Infrastructure classes implement interfaces found in the Core (Use Cases) project(s). -These implementations are wired up at startup using DI. In this case using `Microsoft.Extensions.DependencyInjection` and extension methods defined in each project. +These implementations are wired up at startup using DI. -Need help? Check out the sample here: +In this case using `Microsoft.Extensions.DependencyInjection` and extension methods defined in the project. + +## Database Support + +This project supports both **SQL Server** and **SQLite**: + +- **SQL Server**: When running through .NET Aspire (AspireHost project), a SQL Server container is automatically provisioned and the connection string is provided via Aspire service discovery (using the "DefaultConnection" key). +- **SQLite**: When running the Web project standalone, it uses the SQLite connection from appsettings.json as a fallback. + +The `InfrastructureServiceExtensions.AddInfrastructureServices()` method automatically detects which connection string is available and configures the appropriate database provider. + +Need help? Check out the larger sample here: https://github.com/ardalis/CleanArchitecture/tree/main/sample Still need help? diff --git a/src/Clean.Architecture.ServiceDefaults/Clean.Architecture.ServiceDefaults.csproj b/src/Clean.Architecture.ServiceDefaults/Clean.Architecture.ServiceDefaults.csproj index 09110f11a..c567fa5de 100644 --- a/src/Clean.Architecture.ServiceDefaults/Clean.Architecture.ServiceDefaults.csproj +++ b/src/Clean.Architecture.ServiceDefaults/Clean.Architecture.ServiceDefaults.csproj @@ -1,9 +1,6 @@ - net9.0 - enable - enable true diff --git a/src/Clean.Architecture.ServiceDefaults/Extensions.cs b/src/Clean.Architecture.ServiceDefaults/Extensions.cs index 87808a346..150b65ace 100644 --- a/src/Clean.Architecture.ServiceDefaults/Extensions.cs +++ b/src/Clean.Architecture.ServiceDefaults/Extensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -15,104 +15,103 @@ namespace Microsoft.Extensions.Hosting; // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); - builder.AddDefaultHealthChecks(); + builder.AddDefaultHealthChecks(); - builder.Services.AddServiceDiscovery(); + builder.Services.AddServiceDiscovery(); - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); - return builder; - } + return builder; + } - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => { - builder.Logging.AddOpenTelemetry(logging => + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); }); - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddAspNetCoreInstrumentation() - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + builder.AddOpenTelemetryExporters(); - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } + return builder; + } - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + if (useOtlpExporter) { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; + builder.Services.AddOpenTelemetry().UseOtlpExporter(); } - public static WebApplication MapDefaultEndpoints(this WebApplication app) + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); } + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + return app; + } } diff --git a/src/Clean.Architecture.UseCases/Clean.Architecture.UseCases.csproj b/src/Clean.Architecture.UseCases/Clean.Architecture.UseCases.csproj index 943148fdd..d1b2e4413 100644 --- a/src/Clean.Architecture.UseCases/Clean.Architecture.UseCases.csproj +++ b/src/Clean.Architecture.UseCases/Clean.Architecture.UseCases.csproj @@ -2,8 +2,6 @@ - - diff --git a/src/Clean.Architecture.UseCases/Constants.cs b/src/Clean.Architecture.UseCases/Constants.cs new file mode 100644 index 000000000..22ba6c62e --- /dev/null +++ b/src/Clean.Architecture.UseCases/Constants.cs @@ -0,0 +1,7 @@ +namespace Clean.Architecture.UseCases; + +public class Constants +{ + public const int DEFAULT_PAGE_SIZE = 10; + public const int MAX_PAGE_SIZE = 100; +} diff --git a/src/Clean.Architecture.UseCases/Contributors/ContributorDTO.cs b/src/Clean.Architecture.UseCases/Contributors/ContributorDTO.cs index 07ec97d7e..da7db9ca8 100644 --- a/src/Clean.Architecture.UseCases/Contributors/ContributorDTO.cs +++ b/src/Clean.Architecture.UseCases/Contributors/ContributorDTO.cs @@ -1,2 +1,4 @@ -namespace Clean.Architecture.UseCases.Contributors; -public record ContributorDTO(int Id, string Name, string? PhoneNumber); +using Clean.Architecture.Core.ContributorAggregate; + +namespace Clean.Architecture.UseCases.Contributors; +public record ContributorDto(ContributorId Id, ContributorName Name, PhoneNumber PhoneNumber); diff --git a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorCommand.cs b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorCommand.cs index 2dc2de3d3..239f2b941 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorCommand.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorCommand.cs @@ -1,9 +1,4 @@ -using System.Diagnostics; -using System.Reflection; -using Clean.Architecture.Core.ContributorAggregate; -using FastEndpoints; -using MediatR; -using Microsoft.Extensions.Logging; +using Clean.Architecture.Core.ContributorAggregate; namespace Clean.Architecture.UseCases.Contributors.Create; @@ -11,58 +6,4 @@ namespace Clean.Architecture.UseCases.Contributors.Create; /// Create a new Contributor. /// /// -public record CreateContributorCommand(string Name, string? PhoneNumber) : Ardalis.SharedKernel.ICommand>; - -public record CreateContributorCommand2(string Name) : FastEndpoints.ICommand>; - -public class CreateContributorCommandHandler2 : CommandHandler> -{ - private readonly IRepository _repository; - public CreateContributorCommandHandler2(IRepository repository) - { - _repository = repository; - } - public override async Task> ExecuteAsync(CreateContributorCommand2 request, CancellationToken cancellationToken) - { - var newContributor = new Contributor(request.Name); - var createdItem = await _repository.AddAsync(newContributor, cancellationToken); - - Console.WriteLine($"<<<<<<(ILogger logger) - : ICommandMiddleware where TCommand : FastEndpoints.ICommand -{ - private readonly ILogger _logger = logger; - - public async Task ExecuteAsync(TCommand command, - CommandDelegate next, - CancellationToken ct) - { - string commandName = command.GetType().Name; - if (_logger.IsEnabled(LogLevel.Information)) - { - _logger.LogInformation("Handling {RequestName}", commandName); - - // Reflection! Could be a performance concern - Type myType = command.GetType(); - IList props = new List(myType.GetProperties()); - foreach (PropertyInfo prop in props) - { - object? propValue = prop?.GetValue(command, null); - _logger.LogInformation("Property {Property} : {@Value}", prop?.Name, propValue); - } - } - - var sw = Stopwatch.StartNew(); - - var result = await next(); - - _logger.LogInformation("Handled {CommandName} with {Result} in {ms} ms", commandName, result, sw.ElapsedMilliseconds); - sw.Stop(); - - return result; - } -} +public record CreateContributorCommand(ContributorName Name, string? PhoneNumber) : ICommand>; diff --git a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs index c2b4fc3cd..51f5b157c 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs @@ -3,15 +3,16 @@ namespace Clean.Architecture.UseCases.Contributors.Create; public class CreateContributorHandler(IRepository _repository) - : ICommandHandler> + : ICommandHandler> { - public async Task> Handle(CreateContributorCommand request, + public async ValueTask> Handle(CreateContributorCommand command, CancellationToken cancellationToken) { - var newContributor = new Contributor(request.Name); - if (!string.IsNullOrEmpty(request.PhoneNumber)) + var newContributor = new Contributor(command.Name); + if (!string.IsNullOrEmpty(command.PhoneNumber)) { - newContributor.SetPhoneNumber(request.PhoneNumber); + var phoneNumber = new PhoneNumber("+1", command.PhoneNumber, String.Empty); + newContributor.UpdatePhoneNumber(phoneNumber); } var createdItem = await _repository.AddAsync(newContributor, cancellationToken); diff --git a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorCommand.cs b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorCommand.cs index 72c44e6d6..b916019ef 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorCommand.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorCommand.cs @@ -1,3 +1,5 @@ -namespace Clean.Architecture.UseCases.Contributors.Delete; +using Clean.Architecture.Core.ContributorAggregate; -public record DeleteContributorCommand(int ContributorId) : ICommand; +namespace Clean.Architecture.UseCases.Contributors.Delete; + +public record DeleteContributorCommand(ContributorId ContributorId) : ICommand; diff --git a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs index 7c1a69f07..8f56d47df 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs @@ -5,10 +5,11 @@ namespace Clean.Architecture.UseCases.Contributors.Delete; public class DeleteContributorHandler(IDeleteContributorService _deleteContributorService) : ICommandHandler { - public async Task Handle(DeleteContributorCommand request, CancellationToken cancellationToken) => + public async ValueTask Handle(DeleteContributorCommand request, CancellationToken cancellationToken) => // This Approach: Keep Domain Events in the Domain Model / Core project; this becomes a pass-through // This is @ardalis's preferred approach await _deleteContributorService.DeleteContributor(request.ContributorId); + // Another Approach: Do the real work here including dispatching domain events - change the event from internal to public // @ardalis prefers using the service above so that **domain** event behavior remains in the **domain model** (core project) // var aggregateToDelete = await _repository.GetByIdAsync(request.ContributorId); diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs index dd0745a97..9cdc4d598 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs @@ -7,14 +7,14 @@ namespace Clean.Architecture.UseCases.Contributors.Get; /// Queries don't necessarily need to use repository methods, but they can if it's convenient /// public class GetContributorHandler(IReadRepository _repository) - : IQueryHandler> + : IQueryHandler> { - public async Task> Handle(GetContributorQuery request, CancellationToken cancellationToken) + public async ValueTask> Handle(GetContributorQuery request, CancellationToken cancellationToken) { var spec = new ContributorByIdSpec(request.ContributorId); var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); if (entity == null) return Result.NotFound(); - return new ContributorDTO(entity.Id, entity.Name, entity.PhoneNumber?.Number ?? ""); + return new ContributorDto(entity.Id, entity.Name, entity.PhoneNumber ?? PhoneNumber.Unknown); } } diff --git a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs index 04865a456..c27d2f49b 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs @@ -1,3 +1,5 @@ -namespace Clean.Architecture.UseCases.Contributors.Get; +using Clean.Architecture.Core.ContributorAggregate; -public record GetContributorQuery(int ContributorId) : IQuery>; +namespace Clean.Architecture.UseCases.Contributors.Get; + +public record GetContributorQuery(ContributorId ContributorId) : IQuery>; diff --git a/src/Clean.Architecture.UseCases/Contributors/List/IListContributorsQueryService.cs b/src/Clean.Architecture.UseCases/Contributors/List/IListContributorsQueryService.cs index 6c34c7ccd..7ff659532 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/IListContributorsQueryService.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/IListContributorsQueryService.cs @@ -6,5 +6,5 @@ /// public interface IListContributorsQueryService { - Task> ListAsync(); + Task> ListAsync(int page, int perPage); } diff --git a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs index 13d85082c..1dbcd8aa2 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs @@ -1,11 +1,19 @@ namespace Clean.Architecture.UseCases.Contributors.List; -public class ListContributorsHandler(IListContributorsQueryService _query) - : IQueryHandler>> +public class ListContributorsHandler : IQueryHandler>> { - public async Task>> Handle(ListContributorsQuery request, CancellationToken cancellationToken) + private readonly IListContributorsQueryService _query; + + public ListContributorsHandler(IListContributorsQueryService query) { - var result = await _query.ListAsync(); + _query = query; + } + + public async ValueTask>> Handle(ListContributorsQuery request, + CancellationToken cancellationToken) + { + + var result = await _query.ListAsync(request.Page ?? 1, request.PerPage ?? Constants.DEFAULT_PAGE_SIZE); return Result.Success(result); } diff --git a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs index 00f298e86..b3dda9ee8 100644 --- a/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs +++ b/src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs @@ -1,27 +1,4 @@ -using Ardalis.SharedKernel; -using Clean.Architecture.Core.ContributorAggregate; -using Clean.Architecture.UseCases.Contributors.Create; -using FastEndpoints; +namespace Clean.Architecture.UseCases.Contributors.List; -namespace Clean.Architecture.UseCases.Contributors.List; - -public record ListContributorsQuery(int? Skip, int? Take) : IQuery>>; -public record ListContributorsQuery2(int? Skip, int? Take) : FastEndpoints.ICommand>>; - -public class ListContributorsQueryHandler2 : CommandHandler>> -{ - private readonly IListContributorsQueryService _query; - - public ListContributorsQueryHandler2(IListContributorsQueryService query) - { - _query = query; - } - public override async Task>> ExecuteAsync(ListContributorsQuery2 request, CancellationToken cancellationToken) - { - var result = await _query.ListAsync(); - - Console.WriteLine($"<<<<<<>>; diff --git a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorCommand.cs b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorCommand.cs index a0f36642a..582467ad2 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorCommand.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorCommand.cs @@ -1,3 +1,5 @@ -namespace Clean.Architecture.UseCases.Contributors.Update; +using Clean.Architecture.Core.ContributorAggregate; -public record UpdateContributorCommand(int ContributorId, string NewName) : ICommand>; +namespace Clean.Architecture.UseCases.Contributors.Update; + +public record UpdateContributorCommand(ContributorId ContributorId, ContributorName NewName) : ICommand>; diff --git a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs index a4cce0d1a..6cb2278bf 100644 --- a/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs +++ b/src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs @@ -3,21 +3,22 @@ namespace Clean.Architecture.UseCases.Contributors.Update; public class UpdateContributorHandler(IRepository _repository) - : ICommandHandler> + : ICommandHandler> { - public async Task> Handle(UpdateContributorCommand request, CancellationToken cancellationToken) + public async ValueTask> Handle(UpdateContributorCommand command, + CancellationToken ct) { - var existingContributor = await _repository.GetByIdAsync(request.ContributorId, cancellationToken); + var existingContributor = await _repository.GetByIdAsync(command.ContributorId, ct); if (existingContributor == null) { return Result.NotFound(); } - existingContributor.UpdateName(request.NewName!); + existingContributor.UpdateName(command.NewName); - await _repository.UpdateAsync(existingContributor, cancellationToken); + await _repository.UpdateAsync(existingContributor, ct); - return new ContributorDTO(existingContributor.Id, - existingContributor.Name, existingContributor.PhoneNumber?.Number ?? ""); + return new ContributorDto(existingContributor.Id, + existingContributor.Name, existingContributor.PhoneNumber ?? PhoneNumber.Unknown); } } diff --git a/src/Clean.Architecture.UseCases/GlobalUsings.cs b/src/Clean.Architecture.UseCases/GlobalUsings.cs index 6280800b9..8c10f169c 100644 --- a/src/Clean.Architecture.UseCases/GlobalUsings.cs +++ b/src/Clean.Architecture.UseCases/GlobalUsings.cs @@ -1,2 +1,3 @@ global using Ardalis.Result; global using Ardalis.SharedKernel; +global using Mediator; diff --git a/src/Clean.Architecture.UseCases/PagedResult.cs b/src/Clean.Architecture.UseCases/PagedResult.cs new file mode 100644 index 000000000..1367eb7dc --- /dev/null +++ b/src/Clean.Architecture.UseCases/PagedResult.cs @@ -0,0 +1,8 @@ +namespace Clean.Architecture.UseCases; + +public record PagedResult( + IReadOnlyList Items, + int Page, + int PerPage, + int TotalCount, + int TotalPages); diff --git a/src/Clean.Architecture.UseCases/README.md b/src/Clean.Architecture.UseCases/README.md index 973c0bf86..2636ecdcd 100644 --- a/src/Clean.Architecture.UseCases/README.md +++ b/src/Clean.Architecture.UseCases/README.md @@ -8,12 +8,12 @@ Use Cases should not depend directly on infrastructure concerns, making them sim Use Cases are often grouped into Commands and Queries, following CQRS. -Having Use Cases as a separate project can reduce the amount of logic in UI and Infrastructure projects. +Queries fetch data but do not mutate state. Commands mutate state but do not return data (other than perhaps an acknowledgment or ID). -For simpler projects, the Use Cases project can be omitted, and its behavior moved into the UI project, either as separate services or MediatR handlers, or by simply putting the logic into the API endpoints. +Having Use Cases as a separate project can reduce the amount of logic in UI and Infrastructure projects. -For ideas on organizing your Use Case project's folder structure, see this thread: -https://twitter.com/ardalis/status/1686406393018945536 +Using Use Case handlers combined with behavior can consolidate cross-cutting concerns like logging, validation, and error handling. +See: https://www.youtube.com/watch?v=vagyJdrWLr0 Need help? Check out the sample here: https://github.com/ardalis/CleanArchitecture/tree/main/sample diff --git a/src/Clean.Architecture.Web/Clean.Architecture.Web.csproj b/src/Clean.Architecture.Web/Clean.Architecture.Web.csproj index 9684c4edc..0c83530dc 100644 --- a/src/Clean.Architecture.Web/Clean.Architecture.Web.csproj +++ b/src/Clean.Architecture.Web/Clean.Architecture.Web.csproj @@ -14,20 +14,18 @@ - - + all runtime; build; native; contentfiles; analyzers; buildtransitive + - - diff --git a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs index fcde16074..d5c3ed9c4 100644 --- a/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs @@ -6,8 +6,14 @@ public static class LoggerConfigs { public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder builder) { - - builder.Host.UseSerilog((_, config) => config.ReadFrom.Configuration(builder.Configuration)); + // Add Serilog as an additional logging provider alongside OpenTelemetry + // This allows both Serilog (for console/file) and OpenTelemetry (for Aspire) to work together + builder.Logging.AddSerilog(new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", builder.Environment.ApplicationName) + .WriteTo.Console() + .CreateLogger()); return builder; } diff --git a/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs b/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs new file mode 100644 index 000000000..b151bb6d6 --- /dev/null +++ b/src/Clean.Architecture.Web/Configurations/MediatorConfig.cs @@ -0,0 +1,45 @@ +using Ardalis.SharedKernel; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.Infrastructure; +using Clean.Architecture.UseCases.Contributors.Create; + +namespace Clean.Architecture.Web.Configurations; + +public static class MediatorConfig +{ + // Should be called from ServiceConfigs.cs, not Program.cs + public static IServiceCollection AddMediatorSourceGen(this IServiceCollection services, + Microsoft.Extensions.Logging.ILogger logger) + { + logger.LogInformation("Registering Mediator SourceGen and Behaviors"); + services.AddMediator(options => + { + // Lifetime: Singleton is fastest per docs; Scoped/Transient also supported. + options.ServiceLifetime = ServiceLifetime.Scoped; + + // Supply any TYPE from each assembly you want scanned (the generator finds the assembly from the type) + options.Assemblies = + [ + typeof(Contributor), // Core + typeof(CreateContributorCommand), // UseCases + typeof(InfrastructureServiceExtensions), // Infrastructure + typeof(MediatorConfig) // Web + ]; + + // Register pipeline behaviors here (order matters) + options.PipelineBehaviors = + [ + typeof(LoggingBehavior<,>) + ]; + + // If you have stream behaviors: + // options.StreamPipelineBehaviors = [ typeof(YourStreamBehavior<,>) ]; + }); + + // Alternative: register behaviors via DI yourself (useful if not doing AOT): + // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + // services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); + + return services; + } +} diff --git a/src/Clean.Architecture.Web/Configurations/MediatrConfigs.cs b/src/Clean.Architecture.Web/Configurations/MediatrConfigs.cs deleted file mode 100644 index 4f70f85f4..000000000 --- a/src/Clean.Architecture.Web/Configurations/MediatrConfigs.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Ardalis.SharedKernel; -using Clean.Architecture.Core.ContributorAggregate; -using Clean.Architecture.UseCases.Contributors.Create; -using MediatR; -using System.Reflection; - -namespace Clean.Architecture.Web.Configurations; - -public static class MediatrConfigs -{ - public static IServiceCollection AddMediatrConfigs(this IServiceCollection services) - { - var mediatRAssemblies = new[] - { - Assembly.GetAssembly(typeof(Contributor)), // Core - Assembly.GetAssembly(typeof(CreateContributorCommand)) // UseCases - }; - - services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(mediatRAssemblies!)) - .AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)) - .AddScoped(); - - return services; - } -} diff --git a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs index 2751435eb..e5e1e14a7 100644 --- a/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs +++ b/src/Clean.Architecture.Web/Configurations/MiddlewareConfig.cs @@ -1,5 +1,6 @@ using Ardalis.ListStartupServices; using Clean.Architecture.Infrastructure.Data; +using Scalar.AspNetCore; namespace Clean.Architecture.Web.Configurations; @@ -13,37 +14,74 @@ public static async Task UseAppMiddlewareAndSeedDatabase(th app.UseShowAllServicesMiddleware(); // see https://github.com/ardalis/AspNetCoreStartupServices } else - { + { app.UseDefaultExceptionHandler(); // from FastEndpoints app.UseHsts(); } - app.UseFastEndpoints() - .UseSwaggerGen(); // Includes AddFileServer and static files middleware + app.UseFastEndpoints(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwaggerGen(options => + { + options.Path = "/openapi/{documentName}.json"; + }); + app.MapScalarApiReference(); + } app.UseHttpsRedirection(); // Note this will drop Authorization headers - await SeedDatabase(app); + // Run migrations and seed in Development or when explicitly requested via environment variable + var shouldMigrate = app.Environment.IsDevelopment() || + app.Configuration.GetValue("Database:ApplyMigrationsOnStartup"); + + if (shouldMigrate) + { + await MigrateDatabaseAsync(app); + await SeedDatabaseAsync(app); + } return app; } - static async Task SeedDatabase(WebApplication app) + static async Task MigrateDatabaseAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); + + try + { + logger.LogInformation("Applying database migrations..."); + var context = services.GetRequiredService(); + await context.Database.MigrateAsync(); + logger.LogInformation("Database migrations applied successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred migrating the DB. {exceptionMessage}", ex.Message); + throw; // Re-throw to make startup fail if migrations fail + } + } + + static async Task SeedDatabaseAsync(WebApplication app) { using var scope = app.Services.CreateScope(); var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); try { + logger.LogInformation("Seeding database..."); var context = services.GetRequiredService(); - // await context.Database.MigrateAsync(); - await context.Database.EnsureCreatedAsync(); await SeedData.InitializeAsync(context); + logger.LogInformation("Database seeded successfully"); } catch (Exception ex) { - var logger = services.GetRequiredService>(); logger.LogError(ex, "An error occurred seeding the DB. {exceptionMessage}", ex.Message); + // Don't re-throw for seeding errors - it's not critical } } } diff --git a/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs b/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs index 172ad9745..cca972657 100644 --- a/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs +++ b/src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs @@ -9,25 +9,23 @@ public static class ServiceConfigs public static IServiceCollection AddServiceConfigs(this IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger, WebApplicationBuilder builder) { services.AddInfrastructureServices(builder.Configuration, logger) - .AddMediatrConfigs(); - + .AddMediatorSourceGen(logger); if (builder.Environment.IsDevelopment()) { - // Use a local test email server + // Use a local test email server - configured in Aspire // See: https://ardalis.com/configuring-a-local-test-email-server/ services.AddScoped(); // Otherwise use this: //builder.Services.AddScoped(); - } else { services.AddScoped(); } - logger.LogInformation("{Project} services registered", "Mediatr and Email Sender"); + logger.LogInformation("{Project} services registered", "Mediator Source Generator and Email Sender"); return services; } diff --git a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorRequest.cs b/src/Clean.Architecture.Web/Contributors/Create.CreateContributorRequest.cs deleted file mode 100644 index 631bc14c9..000000000 --- a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Clean.Architecture.Web.Contributors; - -public class CreateContributorRequest -{ - public const string Route = "/Contributors"; - - [Required] - public string? Name { get; set; } - public string? PhoneNumber { get; set; } -} diff --git a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorResponse.cs b/src/Clean.Architecture.Web/Contributors/Create.CreateContributorResponse.cs deleted file mode 100644 index 603b90c55..000000000 --- a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Clean.Architecture.Web.Contributors; - -public class CreateContributorResponse(int id, string name) -{ - public int Id { get; set; } = id; - public string Name { get; set; } = name; -} diff --git a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorValidator.cs b/src/Clean.Architecture.Web/Contributors/Create.CreateContributorValidator.cs deleted file mode 100644 index bbd4d0d12..000000000 --- a/src/Clean.Architecture.Web/Contributors/Create.CreateContributorValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Clean.Architecture.Infrastructure.Data.Config; -using FastEndpoints; -using FluentValidation; - -namespace Clean.Architecture.Web.Contributors; - -/// -/// See: https://fast-endpoints.com/docs/validation -/// -public class CreateContributorValidator : Validator -{ - public CreateContributorValidator() - { - RuleFor(x => x.Name) - .NotEmpty() - .WithMessage("Name is required.") - .MinimumLength(2) - .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); - } -} diff --git a/src/Clean.Architecture.Web/Contributors/Create.cs b/src/Clean.Architecture.Web/Contributors/Create.cs index c17b92038..e061d8fc4 100644 --- a/src/Clean.Architecture.Web/Contributors/Create.cs +++ b/src/Clean.Architecture.Web/Contributors/Create.cs @@ -1,44 +1,87 @@ -using Clean.Architecture.UseCases.Contributors.Create; +using System.ComponentModel.DataAnnotations; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors.Create; +using Clean.Architecture.Web.Extensions; +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; namespace Clean.Architecture.Web.Contributors; -/// -/// Create a new Contributor -/// -/// -/// Creates a new Contributor given a name. -/// -public class Create(IMediator _mediator) - : Endpoint +// This shows an example of having all related types in one file for simplicity. +// Fast-Endpoints generally uses one file per class for larger projects, which +// is the recommended approach. More files, but fewer merge conflicts and easier to +// see what changed in a given commit or PR. + +public class Create(IMediator mediator) + : Endpoint, + ValidationProblem, + ProblemHttpResult>> { + private readonly IMediator _mediator = mediator; + public override void Configure() { Post(CreateContributorRequest.Route); AllowAnonymous(); Summary(s => { - // XML Docs are used by default but are overridden by these properties: - //s.Summary = "Create a new Contributor."; - //s.Description = "Create a new Contributor. A valid name is required."; - s.ExampleRequest = new CreateContributorRequest { Name = "Contributor Name" }; + s.Summary = "Create a new contributor"; + s.Description = "Creates a new contributor with the provided name. The contributor name must be between 2 and 100 characters long."; + s.ExampleRequest = new CreateContributorRequest { Name = "John Doe" }; + s.ResponseExamples[201] = new CreateContributorResponse(1, "John Doe"); + + // Document possible responses + s.Responses[201] = "Contributor created successfully"; + s.Responses[400] = "Invalid input data - validation errors"; + s.Responses[500] = "Internal server error"; }); + + // Add tags for API grouping + Tags("Contributors"); + + // Add additional metadata + Description(builder => builder + .Accepts("application/json") + .Produces(201, "application/json") + .ProducesProblem(400) + .ProducesProblem(500)); } - public override async Task HandleAsync( - CreateContributorRequest request, - CancellationToken cancellationToken) + public override async Task, ValidationProblem, ProblemHttpResult>> + ExecuteAsync(CreateContributorRequest request, CancellationToken cancellationToken) { - var result = await _mediator.Send(new CreateContributorCommand(request.Name!, - request.PhoneNumber), cancellationToken); + var result = await _mediator.Send(new CreateContributorCommand(ContributorName.From(request.Name!), request.PhoneNumber)); - var result2 = await new CreateContributorCommand2(request.Name!) - .ExecuteAsync(cancellationToken); + return result.ToCreatedResult( + id => $"/Contributors/{id}", + id => new CreateContributorResponse(id.Value, request.Name!)); + } +} - if (result.IsSuccess) - { - Response = new CreateContributorResponse(result.Value, request.Name!); - return; - } - // TODO: Handle other cases as necessary +public class CreateContributorRequest +{ + public const string Route = "/Contributors"; + + [Required] + public string Name { get; set; } = String.Empty; + public string? PhoneNumber { get; set; } = null; +} + +public class CreateContributorValidator : Validator +{ + public CreateContributorValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required.") + .MinimumLength(2) + .MaximumLength(ContributorName.MaxLength); } } + +public class CreateContributorResponse(int id, string name) +{ + public int Id { get; set; } = id; + public string Name { get; set; } = name; +} diff --git a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorValidator.cs b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorValidator.cs index 2813eb20c..eab091ed5 100644 --- a/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorValidator.cs +++ b/src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorValidator.cs @@ -1,5 +1,4 @@ -using FastEndpoints; -using FluentValidation; +using FluentValidation; namespace Clean.Architecture.Web.Contributors; diff --git a/src/Clean.Architecture.Web/Contributors/Delete.cs b/src/Clean.Architecture.Web/Contributors/Delete.cs index b3649c764..3c7207e7f 100644 --- a/src/Clean.Architecture.Web/Contributors/Delete.cs +++ b/src/Clean.Architecture.Web/Contributors/Delete.cs @@ -1,40 +1,52 @@ -using Clean.Architecture.UseCases.Contributors.Delete; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors.Delete; +using Clean.Architecture.Web.Extensions; +using Microsoft.AspNetCore.Http.HttpResults; namespace Clean.Architecture.Web.Contributors; -/// -/// Delete a Contributor. -/// -/// -/// Delete a Contributor by providing a valid integer id. -/// -public class Delete(IMediator _mediator) - : Endpoint +public class Delete + : Endpoint> { + private readonly IMediator _mediator; + public Delete(IMediator mediator) => _mediator = mediator; + public override void Configure() { Delete(DeleteContributorRequest.Route); AllowAnonymous(); - } + Summary(s => + { + s.Summary = "Delete a contributor"; + s.Description = "Deletes an existing contributor by ID. This action cannot be undone."; + s.ExampleRequest = new DeleteContributorRequest { ContributorId = 1 }; - public override async Task HandleAsync( - DeleteContributorRequest request, - CancellationToken cancellationToken) - { - var command = new DeleteContributorCommand(request.ContributorId); + // Document possible responses + s.Responses[204] = "Contributor deleted successfully"; + s.Responses[404] = "Contributor not found"; + s.Responses[400] = "Invalid request or deletion failed"; + }); - var result = await _mediator.Send(command, cancellationToken); + // Add tags for API grouping + Tags("Contributors"); - if (result.Status == ResultStatus.NotFound) - { - await SendNotFoundAsync(cancellationToken); - return; - } + // Add additional metadata + Description(builder => builder + .Accepts() + .Produces(204) + .ProducesProblem(404) + .ProducesProblem(400)); + } - if (result.IsSuccess) - { - await SendNoContentAsync(cancellationToken); - }; - // TODO: Handle other issues as needed + public override async Task> + ExecuteAsync(DeleteContributorRequest req, CancellationToken ct) + { + var cmd = new DeleteContributorCommand(ContributorId.From(req.ContributorId)); + var result = await _mediator.Send(cmd, ct); + + return result.ToDeleteResult(); } } diff --git a/src/Clean.Architecture.Web/Contributors/GetById.cs b/src/Clean.Architecture.Web/Contributors/GetById.cs index 36796e27b..94ce0555f 100644 --- a/src/Clean.Architecture.Web/Contributors/GetById.cs +++ b/src/Clean.Architecture.Web/Contributors/GetById.cs @@ -1,38 +1,57 @@ -using Clean.Architecture.UseCases.Contributors.Get; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors; +using Clean.Architecture.UseCases.Contributors.Get; +using Clean.Architecture.Web.Extensions; +using Microsoft.AspNetCore.Http.HttpResults; namespace Clean.Architecture.Web.Contributors; -/// -/// Get a Contributor by integer ID. -/// -/// -/// Takes a positive integer ID and returns a matching Contributor record. -/// -public class GetById(IMediator _mediator) - : Endpoint +public class GetById(IMediator mediator) + : Endpoint, + NotFound, + ProblemHttpResult>, + GetContributorByIdMapper> { public override void Configure() { Get(GetContributorByIdRequest.Route); AllowAnonymous(); - } - public override async Task HandleAsync(GetContributorByIdRequest request, - CancellationToken cancellationToken) - { - var query = new GetContributorQuery(request.ContributorId); + // Optional: document statuses for Swagger + Summary(s => + { + s.Summary = "Get a contributor by ID"; + s.Description = "Retrieves a specific contributor by their unique identifier. Returns detailed contributor information including ID and name."; + s.ExampleRequest = new GetContributorByIdRequest { ContributorId = 1 }; + s.ResponseExamples[200] = new ContributorRecord(1, "John Doe", "+1 555-555-5555"); - var result = await _mediator.Send(query, cancellationToken); + // Document possible responses + s.Responses[200] = "Contributor found and returned successfully"; + s.Responses[404] = "Contributor with specified ID not found"; + }); - if (result.Status == ResultStatus.NotFound) - { - await SendNotFoundAsync(cancellationToken); - return; - } + // Add tags for API grouping + Tags("Contributors"); - if (result.IsSuccess) - { - Response = new ContributorRecord(result.Value.Id, result.Value.Name, result.Value.PhoneNumber); - } + // Add additional metadata + Description(builder => builder + .Accepts() + .Produces(200, "application/json") + .ProducesProblem(404)); } + + public override async Task, NotFound, ProblemHttpResult>> + ExecuteAsync(GetContributorByIdRequest request, CancellationToken ct) + { + var result = await mediator.Send(new GetContributorQuery(ContributorId.From(request.ContributorId)), ct); + + return result.ToGetByIdResult(Map.FromEntity); + } +} +public sealed class GetContributorByIdMapper + : Mapper +{ + public override ContributorRecord FromEntity(ContributorDto e) + => new(e.Id.Value, e.Name.Value, e.PhoneNumber.ToString()); } diff --git a/src/Clean.Architecture.Web/Contributors/List.ContributorListResponse.cs b/src/Clean.Architecture.Web/Contributors/List.ContributorListResponse.cs deleted file mode 100644 index f51e4006e..000000000 --- a/src/Clean.Architecture.Web/Contributors/List.ContributorListResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Clean.Architecture.Web.Contributors; - -public class ContributorListResponse -{ - public List Contributors { get; set; } = []; -} diff --git a/src/Clean.Architecture.Web/Contributors/List.cs b/src/Clean.Architecture.Web/Contributors/List.cs index 6d714bdd7..6914695c5 100644 --- a/src/Clean.Architecture.Web/Contributors/List.cs +++ b/src/Clean.Architecture.Web/Contributors/List.cs @@ -1,35 +1,132 @@ -using Clean.Architecture.UseCases.Contributors; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors; using Clean.Architecture.UseCases.Contributors.List; +using FluentValidation; namespace Clean.Architecture.Web.Contributors; -/// -/// List all Contributors -/// -/// -/// List all contributors - returns a ContributorListResponse containing the Contributors. -/// -public class List(IMediator _mediator) : EndpointWithoutRequest +public class List(IMediator mediator) : Endpoint { + private readonly IMediator _mediator = mediator; + public override void Configure() { Get("/Contributors"); AllowAnonymous(); + + Summary(s => + { + s.Summary = "List contributors with pagination"; + s.Description = "Retrieves a paginated list of all contributors. Supports GitHub-style pagination with 1-based page indexing and configurable page size."; + s.ExampleRequest = new ListContributorsRequest { Page = 1, PerPage = 10 }; + s.ResponseExamples[200] = new ContributorListResponse( + new List + { + new(1, "John Doe", PhoneNumber.Unknown.ToString()), + new(2, "Jane Smith", PhoneNumber.Unknown.ToString()) + }, + 1, 10, 2, 1); + + // Document pagination parameters + s.Params["page"] = "1-based page index (default 1)"; + s.Params["per_page"] = $"Page size 1–{UseCases.Constants.MAX_PAGE_SIZE} (default {UseCases.Constants.DEFAULT_PAGE_SIZE})"; + + // Document possible responses + s.Responses[200] = "Paginated list of contributors returned successfully"; + s.Responses[400] = "Invalid pagination parameters"; + }); + + // Add tags for API grouping + Tags("Contributors"); + + // Add additional metadata + Description(builder => builder + .Accepts() + .Produces(200, "application/json") + .ProducesProblem(400)); } - public override async Task HandleAsync(CancellationToken cancellationToken) + public override async Task HandleAsync(ListContributorsRequest request, CancellationToken cancellationToken) { - Result> result = await _mediator.Send(new ListContributorsQuery(null, null), cancellationToken); + var result = await _mediator.Send(new ListContributorsQuery(request.Page, request.PerPage)); + if (!result.IsSuccess) + { + await Send.ErrorsAsync(statusCode: 400, cancellationToken); + return; + } - var result2 = await new ListContributorsQuery2(null, null) - .ExecuteAsync(cancellationToken); + var pagedResult = result.Value; + AddLinkHeader(pagedResult.Page, pagedResult.PerPage, pagedResult.TotalPages); - if (result.IsSuccess) + var response = Map.FromEntity(pagedResult); + await Send.OkAsync(response, cancellationToken); + } + + private void AddLinkHeader(int page, int perPage, int totalPages) + { + var baseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}{HttpContext.Request.Path}"; + string Link(string rel, int p) => $"<{baseUrl}?page={p}&per_page={perPage}>; rel=\"{rel}\""; + + var parts = new List(); + if (page > 1) + { + parts.Add(Link("first", 1)); + parts.Add(Link("prev", page - 1)); + } + if (page < totalPages) { - Response = new ContributorListResponse - { - Contributors = result.Value.Select(c => new ContributorRecord(c.Id, c.Name, c.PhoneNumber)).ToList() - }; + parts.Add(Link("next", page + 1)); + parts.Add(Link("last", totalPages)); } + + if (parts.Count > 0) + HttpContext.Response.Headers["Link"] = string.Join(", ", parts); + } +} + +public sealed class ListContributorsRequest +{ + // Bind to ?page= + [BindFrom("page")] + public int Page { get; init; } = 1; + + // Bind to ?per_page= + [BindFrom("per_page")] + public int PerPage { get; init; } = UseCases.Constants.DEFAULT_PAGE_SIZE; +} + +public record ContributorListResponse : UseCases.PagedResult +{ + public ContributorListResponse(IReadOnlyList Items, int Page, int PerPage, int TotalCount, int TotalPages) + : base(Items, Page, PerPage, TotalCount, TotalPages) + { + } +} + + +public sealed class ListContributorsValidator : Validator +{ + public ListContributorsValidator() + { + RuleFor(x => x.Page) + .GreaterThanOrEqualTo(1) + .WithMessage("page must be >= 1"); + + RuleFor(x => x.PerPage) + .InclusiveBetween(1, UseCases.Constants.MAX_PAGE_SIZE) + .WithMessage($"per_page must be between 1 and {UseCases.Constants.MAX_PAGE_SIZE}"); + } +} + +public sealed class ListContributorsMapper + : Mapper> +{ + public override ContributorListResponse FromEntity(UseCases.PagedResult e) + { + var items = e.Items + .Select(c => new ContributorRecord(c.Id.Value, c.Name.Value, c.PhoneNumber.ToString())) + .ToList(); + + return new ContributorListResponse(items, e.Page, e.PerPage, e.TotalCount, e.TotalPages); } } diff --git a/src/Clean.Architecture.Web/Contributors/Update.cs b/src/Clean.Architecture.Web/Contributors/Update.cs index bd467bdb6..0f017c1ab 100644 --- a/src/Clean.Architecture.Web/Contributors/Update.cs +++ b/src/Clean.Architecture.Web/Contributors/Update.cs @@ -1,51 +1,66 @@ -using Clean.Architecture.UseCases.Contributors.Get; +using Clean.Architecture.Core.ContributorAggregate; +using Clean.Architecture.UseCases.Contributors; +using Clean.Architecture.UseCases.Contributors.Get; using Clean.Architecture.UseCases.Contributors.Update; +using Clean.Architecture.Web.Extensions; +using Microsoft.AspNetCore.Http.HttpResults; namespace Clean.Architecture.Web.Contributors; -/// -/// Update an existing Contributor. -/// -/// -/// Update an existing Contributor by providing a fully defined replacement set of values. -/// See: https://stackoverflow.com/questions/60761955/rest-update-best-practice-put-collection-id-without-id-in-body-vs-put-collecti -/// -public class Update(IMediator _mediator) - : Endpoint +public class Update(IMediator mediator) + : Endpoint< + UpdateContributorRequest, + Results, NotFound, ProblemHttpResult>, + UpdateContributorMapper> { + private readonly IMediator _mediator = mediator; + public override void Configure() { Put(UpdateContributorRequest.Route); AllowAnonymous(); - } - - public override async Task HandleAsync( - UpdateContributorRequest request, - CancellationToken cancellationToken) - { - var result = await _mediator.Send(new UpdateContributorCommand(request.Id, request.Name!), cancellationToken); - if (result.Status == ResultStatus.NotFound) + // Optional but nice: enumerate for Swagger + Summary(s => { - await SendNotFoundAsync(cancellationToken); - return; - } + s.Summary = "Update a contributor"; + s.Description = "Updates an existing contributor's information. The contributor name must be between 2 and 100 characters long."; + s.ExampleRequest = new UpdateContributorRequest { Id = 1, Name = "Updated Name" }; + s.ResponseExamples[200] = new UpdateContributorResponse(new ContributorRecord(1, "Updated Name", "")); - var query = new GetContributorQuery(request.ContributorId); + // Document possible responses + s.Responses[200] = "Contributor updated successfully"; + s.Responses[404] = "Contributor with specified ID not found"; + s.Responses[400] = "Invalid input data or business rule violation"; + }); - var queryResult = await _mediator.Send(query, cancellationToken); + // Add tags for API grouping + Tags("Contributors"); - if (queryResult.Status == ResultStatus.NotFound) - { - await SendNotFoundAsync(cancellationToken); - return; - } + // Add additional metadata + Description(builder => builder + .Accepts("application/json") + .Produces(200, "application/json") + .ProducesProblem(404) + .ProducesProblem(400)); + } - if (queryResult.IsSuccess) - { - var dto = queryResult.Value; - Response = new UpdateContributorResponse(new ContributorRecord(dto.Id, dto.Name, dto.PhoneNumber)); - return; - } + public override async Task, NotFound, ProblemHttpResult>> + ExecuteAsync(UpdateContributorRequest request, CancellationToken ct) + { + var cmd = new UpdateContributorCommand( + ContributorId.From(request.Id), + ContributorName.From(request.Name!)); + + var result = await _mediator.Send(cmd, ct); + + return result.ToUpdateResult(Map.FromEntity); } } + +public sealed class UpdateContributorMapper + : Mapper +{ + public override UpdateContributorResponse FromEntity(ContributorDto e) + => new(new ContributorRecord(e.Id.Value, e.Name.Value, "")); +} diff --git a/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs b/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs new file mode 100644 index 000000000..8c03c36de --- /dev/null +++ b/src/Clean.Architecture.Web/Extensions/ResultExtensions.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace Clean.Architecture.Web.Extensions; + +public static class ResultExtensions +{ + /// + /// Maps Result to TypedResults for endpoints that return Created, ValidationProblem, or ProblemHttpResult + /// + public static Results, ValidationProblem, ProblemHttpResult> ToCreatedResult( + this Result result, + Func locationBuilder, + Func mapResponse) + { + return result.Status switch + { + ResultStatus.Ok => TypedResults.Created(locationBuilder(result.Value), mapResponse(result.Value)), + ResultStatus.Invalid => TypedResults.ValidationProblem( + result.ValidationErrors + .GroupBy(e => e.Identifier ?? string.Empty) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray() + ) + ), + _ => TypedResults.Problem( + title: "Create failed", + detail: string.Join("; ", result.Errors), + statusCode: StatusCodes.Status400BadRequest) + }; + } + + /// + /// Maps Result to TypedResults for GetById endpoints that return Ok, NotFound, or ProblemHttpResult + /// + public static Results, NotFound, ProblemHttpResult> ToGetByIdResult( + this Result result, + Func mapResponse) + { + return ToOkOrNotFoundResult(result, mapResponse, "Get"); + } + + /// + /// Maps Result to TypedResults for Update endpoints that return Ok, NotFound, or ProblemHttpResult + /// + public static Results, NotFound, ProblemHttpResult> ToUpdateResult( + this Result result, + Func mapResponse) + { + return ToOkOrNotFoundResult(result, mapResponse, "Update"); + } + + /// + /// Maps Result to TypedResults for Delete endpoints that return NoContent, NotFound, or ProblemHttpResult + /// + public static Results ToDeleteResult( + this Result result) + { + return result.Status switch + { + ResultStatus.Ok => TypedResults.NoContent(), + ResultStatus.NotFound => TypedResults.NotFound(), + _ => TypedResults.Problem( + title: "Delete failed", + detail: string.Join("; ", result.Errors), + statusCode: StatusCodes.Status400BadRequest) + }; + } + + /// + /// Private helper method for Ok/NotFound result patterns + /// + private static Results, NotFound, ProblemHttpResult> ToOkOrNotFoundResult( + Result result, + Func mapResponse, + string operationName) + { + return result.Status switch + { + ResultStatus.Ok => TypedResults.Ok(mapResponse(result.Value)), + ResultStatus.NotFound => TypedResults.NotFound(), + _ => TypedResults.Problem( + title: $"{operationName} failed", + detail: string.Join("; ", result.Errors), + statusCode: StatusCodes.Status400BadRequest) + }; + } + + /// + /// Maps Result to TypedResults for endpoints that return Ok only (like List endpoints) + /// + public static Ok ToOkOnlyResult( + this Result result, + Func mapResponse) + { + return TypedResults.Ok(mapResponse(result.Value)); + } +} diff --git a/src/Clean.Architecture.Web/GlobalUsings.cs b/src/Clean.Architecture.Web/GlobalUsings.cs index 973d81fbc..d442fff60 100644 --- a/src/Clean.Architecture.Web/GlobalUsings.cs +++ b/src/Clean.Architecture.Web/GlobalUsings.cs @@ -1,6 +1,7 @@ -global using FastEndpoints; +global using Ardalis.Result; +global using FastEndpoints; global using FastEndpoints.Swagger; -global using MediatR; +global using Mediator; +global using Microsoft.EntityFrameworkCore; global using Serilog; global using Serilog.Extensions.Logging; -global using Ardalis.Result; diff --git a/src/Clean.Architecture.Web/Program.cs b/src/Clean.Architecture.Web/Program.cs index 618321dec..051d3f863 100644 --- a/src/Clean.Architecture.Web/Program.cs +++ b/src/Clean.Architecture.Web/Program.cs @@ -1,45 +1,30 @@ -using Clean.Architecture.UseCases.Contributors.Create; -using Clean.Architecture.Web.Configurations; +using Clean.Architecture.Web.Configurations; var builder = WebApplication.CreateBuilder(args); -var logger = Log.Logger = new LoggerConfiguration() - .Enrich.FromLogContext() - .WriteTo.Console() - .CreateLogger(); +builder.AddServiceDefaults() // This sets up OpenTelemetry logging + .AddLoggerConfigs(); // This adds Serilog for console formatting -logger.Information("Starting web host"); +using var loggerFactory = LoggerFactory.Create(config => config.AddConsole()); +var startupLogger = loggerFactory.CreateLogger(); -builder.AddLoggerConfigs(); - -var appLogger = new SerilogLoggerFactory(logger) - .CreateLogger(); - -builder.Services.AddOptionConfigs(builder.Configuration, appLogger, builder); -builder.Services.AddServiceConfigs(appLogger, builder); +startupLogger.LogInformation("Starting web host"); +builder.Services.AddOptionConfigs(builder.Configuration, startupLogger, builder); +builder.Services.AddServiceConfigs(startupLogger, builder); builder.Services.AddFastEndpoints() .SwaggerDocument(o => { o.ShortSchemaNames = true; - }) - .AddCommandMiddleware(c => - { - c.Register(typeof(CommandLogger<,>)); }); -// wire up commands -//builder.Services.AddTransient>, CreateContributorCommandHandler2>(); - -#if (aspire) -builder.AddServiceDefaults(); -#endif - var app = builder.Build(); await app.UseAppMiddlewareAndSeedDatabase(); +app.MapDefaultEndpoints(); // Aspire health checks and metrics + app.Run(); // Make the implicit Program.cs class public, so integration tests can reference the correct assembly for host building diff --git a/src/Clean.Architecture.Web/Properties/launchSettings.json b/src/Clean.Architecture.Web/Properties/launchSettings.json index ab5960ae2..4f734484d 100644 --- a/src/Clean.Architecture.Web/Properties/launchSettings.json +++ b/src/Clean.Architecture.Web/Properties/launchSettings.json @@ -11,7 +11,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchUrl": "swagger", + "launchUrl": "scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/src/Clean.Architecture.Web/api.http b/src/Clean.Architecture.Web/api.http index af87498ae..468ba7a70 100644 --- a/src/Clean.Architecture.Web/api.http +++ b/src/Clean.Architecture.Web/api.http @@ -26,7 +26,7 @@ Content-Type: application/json ### // Update a contributor -@id_to_update=1 +@id_to_update=3 PUT {{host}}:{{port}}/Contributors/{{id_to_update}} Content-Type: application/json diff --git a/src/Clean.Architecture.Web/appsettings.Development.json b/src/Clean.Architecture.Web/appsettings.Development.json new file mode 100644 index 000000000..0b696f1b8 --- /dev/null +++ b/src/Clean.Architecture.Web/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Database": { + "ApplyMigrationsOnStartup": true + } +} diff --git a/src/Clean.Architecture.Web/appsettings.Testing.json b/src/Clean.Architecture.Web/appsettings.Testing.json new file mode 100644 index 000000000..317af71ce --- /dev/null +++ b/src/Clean.Architecture.Web/appsettings.Testing.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "ConnectionStrings": { + "SqliteConnection": ":memory:" + }, + "Database": { + "ApplyMigrationsOnStartup": false + } +} diff --git a/src/Clean.Architecture.Web/appsettings.json b/src/Clean.Architecture.Web/appsettings.json index 64e356fb6..1ee1ade1a 100644 --- a/src/Clean.Architecture.Web/appsettings.json +++ b/src/Clean.Architecture.Web/appsettings.json @@ -1,32 +1,44 @@ { "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\v11.0;Database=cleanarchitecture;Trusted_Connection=True;MultipleActiveResultSets=true", + // When running through Aspire, the connection string is provided automatically by the AspireHost + // This DefaultConnection is used only when running the Web project directly (without Aspire) + // You can configure this to point to your local SQL Server instance + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=cleanarchitecture;Trusted_Connection=True;MultipleActiveResultSets=true", + // SQLite is used as a fallback when running standalone without Aspire and no DefaultConnection is configured "SqliteConnection": "Data Source=database.sqlite" }, + "Database": { + // Set to true to apply migrations on startup (useful for containerized environments) + // By default, migrations run automatically in Development environment + "ApplyMigrationsOnStartup": false + }, "Serilog": { "MinimumLevel": { "Default": "Information" }, "WriteTo": [ - { - "Name": "Console" - }, - { - "Name": "File", - "Args": { - "path": "log.txt", - "rollingInterval": "Day" + { + "Name": "Console" } - } - //Uncomment this section if you'd like to push your logs to Azure Application Insights - //Full list of Serilog Sinks can be found here: https://github.com/serilog/serilog/wiki/Provided-Sinks - //{ - // "Name": "ApplicationInsights", - // "Args": { - // "instrumentationKey": "", //Fill in with your ApplicationInsights InstrumentationKey - // "telemetryConverter": "Serilog.Sinks.ApplicationInsights.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" - // } - //} + // uncomment if you want file logging + //, + //{ + // "Name": "File", + // "Args": { + // "path": "log.txt", + // "rollingInterval": "Day" + // } + //} + + //Uncomment this section if you'd like to push your logs to Azure Application Insights + //Full list of Serilog Sinks can be found here: https://github.com/serilog/serilog/wiki/Provided-Sinks + //{ + // "Name": "ApplicationInsights", + // "Args": { + // "instrumentationKey": "", //Fill in with your ApplicationInsights InstrumentationKey + // "telemetryConverter": "Serilog.Sinks.ApplicationInsights.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" + // } + //} ] }, "Mailserver": { diff --git a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorGetById.cs b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorGetById.cs index 4317d275e..b73d8d11b 100644 --- a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorGetById.cs +++ b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorGetById.cs @@ -15,7 +15,7 @@ public async Task ReturnsSeedContributorGivenId1() var result = await _client.GetAndDeserializeAsync(GetContributorByIdRequest.BuildRoute(1)); result.Id.ShouldBe(1); - result.Name.ShouldBe(SeedData.Contributor1.Name); + result.Name.ShouldBe(SeedData.Contributor1.Name.Value); } [Fact] diff --git a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs index 6c4e7458a..ec818d67e 100644 --- a/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs +++ b/tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs @@ -13,8 +13,8 @@ public async Task ReturnsTwoContributors() { var result = await _client.GetAndDeserializeAsync("/Contributors"); - result.Contributors.Count.ShouldBe(2); - result.Contributors.ShouldContain(contributor => contributor.Name == SeedData.Contributor1.Name); - result.Contributors.ShouldContain(contributor => contributor.Name == SeedData.Contributor2.Name); + Assert.Equal(SeedData.NUMBER_OF_CONTRIBUTORS, result.TotalCount); + Assert.Contains(result.Items, i => i.Name == SeedData.Contributor1.Name); + Assert.Contains(result.Items, i => i.Name == SeedData.Contributor2.Name); } } diff --git a/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj b/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj index e2aecb1f8..ceb57e331 100644 --- a/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj +++ b/tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj @@ -14,7 +14,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs index 63f8d6f02..977639db2 100644 --- a/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs +++ b/tests/Clean.Architecture.FunctionalTests/CustomWebApplicationFactory.cs @@ -1,9 +1,26 @@ using Clean.Architecture.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Testcontainers.MsSql; namespace Clean.Architecture.FunctionalTests; -public class CustomWebApplicationFactory : WebApplicationFactory where TProgram : class +public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class { + private readonly MsSqlContainer _dbContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("Your_password123!") + .Build(); + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.DisposeAsync(); + } + /// /// Overriding CreateHost to avoid creating a separate ServiceProvider per this thread: /// https://github.com/dotnet-architecture/eShopOnWeb/issues/465 @@ -12,7 +29,7 @@ public class CustomWebApplicationFactory : WebApplicationFactory protected override IHost CreateHost(IHostBuilder builder) { - builder.UseEnvironment("Development"); // will not send real emails + builder.UseEnvironment("Testing"); // will not send real emails var host = builder.Build(); host.Start(); @@ -29,21 +46,13 @@ protected override IHost CreateHost(IHostBuilder builder) var logger = scopedServices .GetRequiredService>>(); - // Reset Sqlite database for each test run - // If using a real database, you'll likely want to remove this step. - db.Database.EnsureDeleted(); - - // Ensure the database is created. - db.Database.EnsureCreated(); - try { - // Can also skip creating the items - //if (!db.ToDoItems.Any()) - //{ + // Apply migrations to create the database schema + db.Database.Migrate(); + // Seed the database with test data. SeedData.PopulateTestDataAsync(db).Wait(); - //} } catch (Exception ex) { @@ -60,26 +69,22 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder .ConfigureServices(services => { - // Configure test dependencies here - - //// Remove the app's ApplicationDbContext registration. - //var descriptor = services.SingleOrDefault( - //d => d.ServiceType == - // typeof(DbContextOptions)); - - //if (descriptor != null) - //{ - // services.Remove(descriptor); - //} + // Remove the app's ApplicationDbContext registration + var descriptors = services.Where( + d => d.ServiceType == typeof(AppDbContext) || + d.ServiceType == typeof(DbContextOptions)) + .ToList(); - //// This should be set for each individual test run - //string inMemoryCollectionName = Guid.NewGuid().ToString(); + foreach (var descriptor in descriptors) + { + services.Remove(descriptor); + } - //// Add ApplicationDbContext using an in-memory database for testing. - //services.AddDbContext(options => - //{ - // options.UseInMemoryDatabase(inMemoryCollectionName); - //}); + // Add ApplicationDbContext using the Testcontainers SQL Server instance + services.AddDbContext((provider, options) => + { + options.UseSqlServer(_dbContainer.GetConnectionString()); + }); }); } } diff --git a/tests/Clean.Architecture.FunctionalTests/xunit.runner.json b/tests/Clean.Architecture.FunctionalTests/xunit.runner.json index 7818fb233..27d661ba7 100644 --- a/tests/Clean.Architecture.FunctionalTests/xunit.runner.json +++ b/tests/Clean.Architecture.FunctionalTests/xunit.runner.json @@ -1,5 +1,6 @@ { "shadowCopy": false, - "parallelizeAssembly": false, - "parallelizeTestCollections": false + "parallelizeAssembly": true, + "parallelizeTestCollections": true, + "maxParallelThreads": 0 } \ No newline at end of file diff --git a/tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj b/tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj index 0112f0cf1..8f70645a0 100644 --- a/tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj +++ b/tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj @@ -11,6 +11,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -18,7 +20,6 @@ - diff --git a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryAdd.cs b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryAdd.cs index caa478619..16ed62d6d 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryAdd.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryAdd.cs @@ -7,7 +7,7 @@ public class EfRepositoryAdd : BaseEfRepoTestFixture [Fact] public async Task AddsContributorAndSetsId() { - var testContributorName = "testContributor"; + var testContributorName = ContributorName.From("testContributor"); var testContributorStatus = ContributorStatus.NotSet; var repository = GetRepository(); var Contributor = new Contributor(testContributorName); @@ -20,6 +20,6 @@ public async Task AddsContributorAndSetsId() newContributor.ShouldNotBeNull(); testContributorName.ShouldBe(newContributor.Name); testContributorStatus.ShouldBe(newContributor.Status); - newContributor.Id.ShouldBeGreaterThan(0); + newContributor.Id.Value.ShouldBeGreaterThan(0); } } diff --git a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryDelete.cs b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryDelete.cs index f4ac64f10..73e48ff19 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryDelete.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryDelete.cs @@ -9,7 +9,7 @@ public async Task DeletesItemAfterAddingIt() { // add a Contributor var repository = GetRepository(); - var initialName = Guid.NewGuid().ToString(); + var initialName = ContributorName.From(Guid.NewGuid().ToString()); var Contributor = new Contributor(initialName); await repository.AddAsync(Contributor); diff --git a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs index c854b3b9b..cab769537 100644 --- a/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs +++ b/tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs @@ -9,7 +9,7 @@ public async Task UpdatesItemAfterAddingIt() { // add a Contributor var repository = GetRepository(); - var initialName = Guid.NewGuid().ToString(); + var initialName = ContributorName.From(Guid.NewGuid().ToString()); var Contributor = new Contributor(initialName); await repository.AddAsync(Contributor); @@ -23,7 +23,7 @@ public async Task UpdatesItemAfterAddingIt() newContributor.ShouldNotBeNull(); Contributor.ShouldNotBeSameAs(newContributor); - var newName = Guid.NewGuid().ToString(); + var newName = ContributorName.From(Guid.NewGuid().ToString()); newContributor.UpdateName(newName); // Update the item diff --git a/tests/Clean.Architecture.IntegrationTests/xunit.runner.json b/tests/Clean.Architecture.IntegrationTests/xunit.runner.json new file mode 100644 index 000000000..af10a0278 --- /dev/null +++ b/tests/Clean.Architecture.IntegrationTests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "shadowCopy": false, + "parallelizeAssembly": true, + "parallelizeTestCollections": true, + "maxParallelThreads": 0 +} diff --git a/tests/Clean.Architecture.UnitTests/Clean.Architecture.UnitTests.csproj b/tests/Clean.Architecture.UnitTests/Clean.Architecture.UnitTests.csproj index 03fffe7b4..032562ad7 100644 --- a/tests/Clean.Architecture.UnitTests/Clean.Architecture.UnitTests.csproj +++ b/tests/Clean.Architecture.UnitTests/Clean.Architecture.UnitTests.csproj @@ -24,6 +24,8 @@ + + diff --git a/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorConstructor.cs b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorConstructor.cs index 93d52bc23..da888af0b 100644 --- a/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorConstructor.cs +++ b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorConstructor.cs @@ -2,7 +2,7 @@ public class ContributorConstructor { - private readonly string _testName = "test name"; + private readonly ContributorName _testName = ContributorName.From("test name"); private Contributor? _testContributor; private Contributor CreateContributor() diff --git a/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs new file mode 100644 index 000000000..9f2ae2b4a --- /dev/null +++ b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorIdFrom.cs @@ -0,0 +1,20 @@ +namespace Clean.Architecture.UnitTests.Core.ContributorAggregate; + +public class ContributorIdFrom +{ + [Fact] + public void CreatesGivenValidValue() + { + int validValue = 1; + var contributorId = ContributorId.From(validValue); + Assert.Equal(validValue, contributorId.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsGivenInvalidValue(int invalidValue) + { + Assert.Throws(() => ContributorId.From(invalidValue)); + } +} diff --git a/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorNameFrom.cs b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorNameFrom.cs new file mode 100644 index 000000000..bb16c8248 --- /dev/null +++ b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorNameFrom.cs @@ -0,0 +1,20 @@ +namespace Clean.Architecture.UnitTests.Core.ContributorAggregate; + +public class ContributorNameFrom +{ + [Fact] + public void CreatesGivenValidValue() + { + string validValue = "ardalis"; + var contributorName = ContributorName.From(validValue); + Assert.Equal(validValue, contributorName.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ThrowsGivenInvalidValue(string? invalidValue) + { + Assert.Throws(() => ContributorName.From(invalidValue!)); + } +} diff --git a/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorUpdateName.cs b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorUpdateName.cs new file mode 100644 index 000000000..ca586c3cf --- /dev/null +++ b/tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorUpdateName.cs @@ -0,0 +1,40 @@ +using Clean.Architecture.Core.ContributorAggregate.Events; + +namespace Clean.Architecture.UnitTests.Core.ContributorAggregate; + +public class ContributorUpdateName +{ + private readonly ContributorName _initialName = ContributorName.From("initial name"); + private readonly ContributorName _newName = ContributorName.From("new name"); + private Contributor? _testContributor; + private Contributor CreateContributor() + { + return new Contributor(_initialName); + } + + [Fact] + public void UpdatesName() + { + _testContributor = CreateContributor(); + _testContributor.UpdateName(_newName); + _testContributor.Name.ShouldBe(_newName); + } + + [Fact] + public void RegistersDomainEvent() + { + _testContributor = CreateContributor(); + _testContributor.UpdateName(_newName); + _testContributor.DomainEvents.Count.ShouldBe(1); + _testContributor.DomainEvents.First().ShouldBeOfType(); + } + + [Fact] + public void DoesNotRegisterDomainEventGivenCurrentName() + { + _testContributor = CreateContributor(); + _testContributor.UpdateName(_initialName); + _testContributor.DomainEvents.Count.ShouldBe(0); + } + +} diff --git a/tests/Clean.Architecture.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs b/tests/Clean.Architecture.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs index 51383a7b3..9804a9742 100644 --- a/tests/Clean.Architecture.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs +++ b/tests/Clean.Architecture.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs @@ -18,7 +18,8 @@ public DeleteContributorService_DeleteContributor() [Fact] public async Task ReturnsNotFoundGivenCantFindContributor() { - var result = await _service.DeleteContributor(0); + int missingId = 9999; + var result = await _service.DeleteContributor(ContributorId.From(missingId)); result.Status.ShouldBe(Ardalis.Result.ResultStatus.NotFound); } diff --git a/tests/Clean.Architecture.UnitTests/GlobalUsings.cs b/tests/Clean.Architecture.UnitTests/GlobalUsings.cs index 2c56b1ff0..eaf8aa6cc 100644 --- a/tests/Clean.Architecture.UnitTests/GlobalUsings.cs +++ b/tests/Clean.Architecture.UnitTests/GlobalUsings.cs @@ -3,7 +3,7 @@ global using Clean.Architecture.Core.ContributorAggregate; global using Clean.Architecture.UseCases.Contributors.Create; global using Shouldly; -global using MediatR; +global using Mediator; global using Microsoft.Extensions.Logging; global using NSubstitute; global using Xunit; diff --git a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs index 2aada4329..2358dab52 100644 --- a/tests/Clean.Architecture.UnitTests/NoOpMediator.cs +++ b/tests/Clean.Architecture.UnitTests/NoOpMediator.cs @@ -2,42 +2,59 @@ public class NoOpMediator : IMediator { - public Task Publish(object notification, CancellationToken cancellationToken = default) + public async Task> CreateStream(IStreamQuery query, CancellationToken cancellationToken = default) { - return Task.CompletedTask; + await Task.Delay(1); + return AsyncEnumerable.Empty(); } - public Task Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification + public IAsyncEnumerable CreateStream(IStreamRequest request, CancellationToken cancellationToken = default) { - return Task.CompletedTask; + return AsyncEnumerable.Empty(); } - public Task Send(IRequest request, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream(IStreamCommand command, CancellationToken cancellationToken = default) { - return Task.FromResult(default!); + return AsyncEnumerable.Empty(); } - public Task Send(object request, CancellationToken cancellationToken = default) + public IAsyncEnumerable CreateStream(object message, CancellationToken cancellationToken = default) { - return Task.FromResult(default); + return AsyncEnumerable.Empty(); } - public async IAsyncEnumerable CreateStream(IStreamRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public ValueTask Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification { - await Task.CompletedTask; - yield break; + return ValueTask.CompletedTask; } - public async IAsyncEnumerable CreateStream(object request, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public ValueTask Publish(object notification, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - yield break; + return ValueTask.CompletedTask; } - public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest + public ValueTask Send(IRequest request, CancellationToken cancellationToken = default) { - return Task.CompletedTask; + return ValueTask.FromResult(default(TResponse)!); + } + + public ValueTask Send(ICommand command, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(default(TResponse)!); + } + + public ValueTask Send(IQuery query, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(default(TResponse)!); + } + + public ValueTask Send(object message, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(null); + } + + IAsyncEnumerable ISender.CreateStream(IStreamQuery query, CancellationToken cancellationToken) + { + return AsyncEnumerable.Empty(); } } diff --git a/tests/Clean.Architecture.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs b/tests/Clean.Architecture.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs index 82012ed94..16b003725 100644 --- a/tests/Clean.Architecture.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs +++ b/tests/Clean.Architecture.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs @@ -2,7 +2,7 @@ public class CreateContributorHandlerHandle { - private readonly string _testName = "test name"; + private readonly ContributorName _testName = ContributorName.From("test name"); private readonly IRepository _repository = Substitute.For>(); private CreateContributorHandler _handler; diff --git a/tests/Clean.Architecture.UnitTests/xunit.runner.json b/tests/Clean.Architecture.UnitTests/xunit.runner.json index 7818fb233..27d661ba7 100644 --- a/tests/Clean.Architecture.UnitTests/xunit.runner.json +++ b/tests/Clean.Architecture.UnitTests/xunit.runner.json @@ -1,5 +1,6 @@ { "shadowCopy": false, - "parallelizeAssembly": false, - "parallelizeTestCollections": false + "parallelizeAssembly": true, + "parallelizeTestCollections": true, + "maxParallelThreads": 0 } \ No newline at end of file