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