diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 6a23c0328..5c5a8b926 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -17,7 +17,9 @@ jobs: uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' - - name: Build with dotnet + - name: Build Full Template run: dotnet build ./Clean.Architecture.slnx -c Release - - name: Test with dotnet + - name: Test Full Template run: dotnet test ./Clean.Architecture.slnx -c Release --no-build --verbosity normal + - name: Build MinimalClean Template + run: dotnet build ./MinimalClean/MinimalClean.Architecture.slnx -c Release diff --git a/.github/workflows/publish-templates.yml b/.github/workflows/publish-templates.yml new file mode 100644 index 000000000..89e096ccc --- /dev/null +++ b/.github/workflows/publish-templates.yml @@ -0,0 +1,132 @@ +name: Publish Templates to NuGet + +on: + workflow_dispatch: + inputs: + publish-full: + description: 'Publish Full Clean Architecture Template' + required: true + type: boolean + default: false + publish-minimal: + description: 'Publish Minimal Clean Architecture Template' + required: true + type: boolean + default: false + push: + tags: + - 'v*' + release: + types: [published] + +env: + DOTNET_VERSION: '10.0.x' + PACK_OUTPUT: artifacts/packages + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Build Full Template Solution + run: dotnet build ./Clean.Architecture.slnx -c Release + + - name: Test Full Template + run: dotnet test ./Clean.Architecture.slnx -c Release --no-build --verbosity normal + + - name: Build MinimalClean Template Solution + run: dotnet build ./MinimalClean/MinimalClean.Architecture.slnx -c Release + + - name: Test Template Installations + shell: pwsh + run: | + # Test Full Template + Write-Host "Testing Full Clean Architecture Template..." + dotnet new install . + dotnet new clean-arch -o TestFullTemplate + dotnet build TestFullTemplate/TestFullTemplate.slnx + dotnet new uninstall Ardalis.CleanArchitecture.Template + Remove-Item -Recurse -Force TestFullTemplate + + # Test Minimal Template + Write-Host "Testing Minimal Clean Architecture Template..." + dotnet new install ./MinimalClean + dotnet new min-clean -o TestMinimalTemplate + dotnet build TestMinimalTemplate/TestMinimalTemplate.slnx + dotnet new uninstall Ardalis.MinimalClean.Template + Remove-Item -Recurse -Force TestMinimalTemplate + + package: + needs: build-and-test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup NuGet + uses: nuget/setup-nuget@v2 + with: + nuget-version: 'latest' + + - name: Create output directory + run: mkdir -p ${{ env.PACK_OUTPUT }} + + - name: Pack Full Template + if: ${{ github.event_name == 'release' || github.event.inputs.publish-full == 'true' }} + run: nuget pack CleanArchitecture.nuspec -OutputDirectory ${{ env.PACK_OUTPUT }} -NoDefaultExcludes + + - name: Pack Minimal Template + if: ${{ github.event_name == 'release' || github.event.inputs.publish-minimal == 'true' }} + run: nuget pack MinimalClean.nuspec -OutputDirectory ${{ env.PACK_OUTPUT }} -NoDefaultExcludes + + - name: Upload packages as artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: ${{ env.PACK_OUTPUT }}/*.nupkg + + publish: + needs: package + runs-on: ubuntu-latest + if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + permissions: + contents: read + id-token: write + + steps: + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: nuget-packages + path: ${{ env.PACK_OUTPUT }} + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: NuGet login (OIDC) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + + - name: Push packages to NuGet + shell: pwsh + run: | + Get-ChildItem "${{ env.PACK_OUTPUT }}" -Filter *.nupkg | ForEach-Object { + Write-Host "Publishing $($_.Name)..." + dotnet nuget push $_.FullName ` + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" ` + --source https://api.nuget.org/v3/index.json ` + --skip-duplicate + } diff --git a/MinimalClean.nuspec b/MinimalClean.nuspec new file mode 100644 index 000000000..41c3d3768 --- /dev/null +++ b/MinimalClean.nuspec @@ -0,0 +1,37 @@ + + + + Ardalis.MinimalClean.Template + ASP.NET Minimal Clean Architecture Solution + 1.0.0-beta1 + Steve Smith + + A simplified Clean Architecture template using vertical slice architecture in a single project. + Perfect for MVPs, smaller applications, or teams wanting architectural guidance without multi-project complexity. + Features Domain-Driven Design patterns, FastEndpoints, Entity Framework Core, and optional Aspire support. + Built with .NET 10 and C# 13. + + en-US + MIT + https://github.com/ardalis/CleanArchitecture + + * Initial release of Minimal Clean Architecture template + * Single-project vertical slice architecture + * Built with .NET 10 and C# 13 + * FastEndpoints for REPR pattern + * Pragmatic DDD with simplified patterns + * Optional Aspire support + + + + + Web ASP.NET "Clean Architecture" "Vertical Slice" minimal ddd domain-driven-design clean-architecture ardalis FastEndpoints + ./content/icon.png + README.md + + + + + + + diff --git a/MinimalClean/.editorconfig b/MinimalClean/.editorconfig new file mode 100644 index 000000000..7ea9b6496 --- /dev/null +++ b/MinimalClean/.editorconfig @@ -0,0 +1,151 @@ +############################### +# Core EditorConfig Options # +############################### +root = true +# All files +[*] +indent_style = space + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 2 +insert_final_newline = true +charset = utf-8-bom +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +############################### +# Naming Conventions # +############################### +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const +tab_width= 2 +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ +dotnet_style_operator_placement_when_wrapping = beginning_of_line +end_of_line = crlf +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +# Namespaces +csharp_style_namespace_declarations = file_scoped:warning +############################### +# C# Formatting Rules # +############################### +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +csharp_using_directive_placement = outside_namespace:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +############################### +# VB Coding Conventions # +############################### +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion \ No newline at end of file diff --git a/MinimalClean/.runsettings b/MinimalClean/.runsettings new file mode 100644 index 000000000..6862ac743 --- /dev/null +++ b/MinimalClean/.runsettings @@ -0,0 +1,17 @@ + + + + + 0 + + false + + + + + true + true + 0 + + + diff --git a/MinimalClean/.template.config/template.json b/MinimalClean/.template.config/template.json new file mode 100644 index 000000000..be1d8fc81 --- /dev/null +++ b/MinimalClean/.template.config/template.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "ardalis (Steve Smith)", + "classifications": [ + "Web", + "ASP.NET", + "Clean Architecture", + "Minimal" + ], + "tags": { + "language": "C#", + "type": "project" + }, + "identity": "Ardalis.MinimalClean.Template", + "name": "ASP.NET Minimal Clean Architecture Solution", + "shortName": "min-clean", + "sourceName": "MinimalClean.Architecture", + "templateFileExtensions": [ ".cs", ".csproj", ".slnx" ], + "preferNameDirectory": true, + "sources": [ + { + "include": [ + "**/*" + ], + "exclude": [ + ".vs/**", + ".vscode/**", + ".git/**", + ".github/**", + ".idea/**", + ".template.config/**", + "**/bin/**", + "**/obj/**", + "**/.vs/**" + ], + "modifiers": [ + { + "condition": "true", + "copyOnly": [ "*.dll", "*.png", "*.ico", "*.jpg", "*.jpeg", "*.ps1" ] + }, + { + "rename": { + "README.template.md": "README.md" + } + } + ] + } + ] +} diff --git a/MinimalClean/Directory.Build.props b/MinimalClean/Directory.Build.props new file mode 100644 index 000000000..a3048152b --- /dev/null +++ b/MinimalClean/Directory.Build.props @@ -0,0 +1,13 @@ + + + true + true + net10.0 + enable + enable + latest + + + 1591 + + \ No newline at end of file diff --git a/MinimalClean/Directory.Packages.props b/MinimalClean/Directory.Packages.props new file mode 100644 index 000000000..0c9de6e64 --- /dev/null +++ b/MinimalClean/Directory.Packages.props @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/MinimalClean/MinimalClean.Architecture.slnx b/MinimalClean/MinimalClean.Architecture.slnx new file mode 100644 index 000000000..2a7d9cedd --- /dev/null +++ b/MinimalClean/MinimalClean.Architecture.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/MinimalClean/README.template.md b/MinimalClean/README.template.md new file mode 100644 index 000000000..b5fae77f4 --- /dev/null +++ b/MinimalClean/README.template.md @@ -0,0 +1,159 @@ +# MinimalClean.Architecture + +Welcome to your new project generated with the **Minimal Clean Architecture** template! + +This is a streamlined, single-project Vertical Slice Architecture (VSA) that follows Clean Architecture principles without the overhead of multiple projects. Perfect for smaller applications, MVPs, or teams that want architectural guidance without complex project boundaries. + +## Getting Started + +### Build and Run + +```powershell +# Build the solution +dotnet build + +# Run the application +dotnet run --project src/MinimalClean.Architecture.Web + +# Or run with Aspire (if using) +dotnet run --project src/MinimalClean.Architecture.AspireHost +``` + +### Database Setup + +```powershell +dotnet ef database update -c AppDbContext -p src/MinimalClean.Architecture.Web -s src/MinimalClean.Architecture.Web +``` + +The database file will be created at: `src/MinimalClean.Architecture.Web/ToDo.db` + +## Project Structure + +This template uses a **single Web project** organized by **vertical slices** (features): + +```text +src/MinimalClean.Architecture.Web/ +├── Domain/ # Domain entities and aggregates +│ ├── CartAggregate/ +│ ├── OrderAggregate/ +│ └── ProductAggregate/ +├── Infrastructure/ # Data access and external services +│ ├── Data/ +│ │ ├── AppDbContext.cs +│ │ ├── Config/ # EF Core configurations +│ │ └── Migrations/ +│ └── Email/ # Email services +├── Endpoints/ # API endpoints (FastEndpoints) +│ ├── Cart/ +│ ├── Order/ +│ └── Product/ +└── Program.cs # Application startup +``` + +### Key Design Decisions + +```text +Single project vertical slice architecture +``` + +- **Single Project**: All code in one Web project - simpler dependencies, faster builds +- **Vertical Slices**: Organized by feature (Cart, Order, Product) not layer +- **Domain-Driven Design**: Entities use proper encapsulation and business logic +- **FastEndpoints**: REPR pattern for clean, testable API endpoints +- **Entity Framework Core**: Simple data access with SQLite (easily switched to SQL Server) +- **Mediator Pattern**: Optional - use for cross-cutting concerns or remove for simplicity + +## What's Different from Full Clean Architecture? + +This minimal template simplifies the full Clean Architecture template: + +| Full Template | Minimal Template | +|--------------|------------------| +| 4+ projects (Core, UseCases, Infrastructure, Web) | 1 Web project | +| Repository pattern with Specifications | Direct DbContext usage (or simple repositories) | +| Extensive use of interfaces and abstractions | Pragmatic abstractions where needed | +| Separate Use Cases project with MediatR | Optional MediatR, logic can be in endpoints | +| Complex domain patterns (Aggregates, Value Objects, Domain Events) | Simplified domain model, pragmatic DDD | + +## When to Use This Template + +**Use Minimal Clean Architecture when:** + +- ✓ Building MVPs or smaller applications +- ✓ You want architectural guidance without project ceremony +- ✓ Team prefers simplicity and fast iteration +- ✓ Vertical slice architecture appeals to you +- ✓ You may grow into full Clean Architecture later + +**Use Full Clean Architecture when:** + +- × Building large, complex enterprise applications +- × Multiple teams working on different layers +- × Need strict separation of concerns and dependencies +- × Domain complexity requires extensive DDD patterns +- × Long-term maintenance and evolution expected + +## Technology Stack + +- **.NET 10**: Latest LTS framework +- **FastEndpoints**: REPR pattern for API endpoints +- **Entity Framework Core**: Data access with migrations +- **SQLite**: Default database (easily switched to SQL Server, PostgreSQL, etc.) +- **Aspire**: Optional cloud-ready orchestration and observability +- **Serilog**: Structured logging +- **FluentValidation**: Request validation + +## Common Tasks + +### Adding a New Feature (Vertical Slice) + +1. **Create Domain Entity**: Add to `Domain/YourFeatureAggregate/` +2. **Add EF Configuration**: Create config in `Infrastructure/Data/Config/` +3. **Create Migration**: `dotnet ef migrations add AddYourFeature` +4. **Create Endpoints**: Add FastEndpoints in `Endpoints/YourFeature/` + +### Switching to SQL Server + +Update `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=YourDb;Trusted_Connection=true;" + } +} +``` + +### Running Tests + +```powershell +dotnet test +``` + +## Migration Path to Full Clean Architecture + +As your application grows, you can migrate to the full Clean Architecture template: + +1. **Extract Core**: Move Domain entities to separate Core project +2. **Extract UseCases**: Move business logic to UseCases project with MediatR +3. **Extract Infrastructure**: Move data access to Infrastructure project +4. **Update Dependencies**: Set up proper dependency flow (Core ← UseCases ← Infrastructure) + +Learn more: [Clean Architecture Template](https://github.com/ardalis/CleanArchitecture) + +## Resources + +- [Clean Architecture Template Repository](https://github.com/ardalis/CleanArchitecture) +- [Vertical Slice Architecture](https://jimmybogard.com/vertical-slice-architecture/) +- [FastEndpoints Documentation](https://fast-endpoints.com/) +- [Ardalis on YouTube](https://www.youtube.com/@Ardalis) + +## Need Help? + +- Report issues: [GitHub Issues](https://github.com/ardalis/CleanArchitecture/issues) +- Ask questions: [Discussions](https://github.com/ardalis/CleanArchitecture/discussions) +- Follow updates: [@ardalis](https://twitter.com/ardalis) + +--- + +**Happy Coding!** 🚀 diff --git a/MinimalClean/global.json b/MinimalClean/global.json new file mode 100644 index 000000000..b48eef46e --- /dev/null +++ b/MinimalClean/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.AspireHost/AppHost.cs b/MinimalClean/src/MinimalClean.Architecture.AspireHost/AppHost.cs new file mode 100644 index 000000000..aad9808f7 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.AspireHost/AppHost.cs @@ -0,0 +1,36 @@ +using System.Net.Sockets; + +var builder = DistributedApplication.CreateBuilder(args); + +// Add SQL Server container +var sqlServer = builder.AddSqlServer("sqlserver") + .WithLifetime(ContainerLifetime.Persistent); + +// Add the database +var appDb = sqlServer.AddDatabase("AppDb"); + +// 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"; + }); + +// register the API project and link the DB +builder.AddProject("web") + .WithEnvironment("ASPNETCORE_ENVIRONMENT", builder.Environment.EnvironmentName) + .WithReference(appDb) + .WaitFor(papercut) + .WaitFor(appDb) + .WithUrl("https://localhost:57379"); // Match launchSettings + +builder.Build().Run(); diff --git a/MinimalClean/src/MinimalClean.Architecture.AspireHost/MinimalClean.Architecture.AspireHost.csproj b/MinimalClean/src/MinimalClean.Architecture.AspireHost/MinimalClean.Architecture.AspireHost.csproj new file mode 100644 index 000000000..c47fadab1 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.AspireHost/MinimalClean.Architecture.AspireHost.csproj @@ -0,0 +1,30 @@ + + + + + + Exe + 47f99468-f41d-48b8-ae3f-69682954d973 + + + + + + + + + + + + + + + + + + + + + diff --git a/MinimalClean/src/MinimalClean.Architecture.AspireHost/Properties/launchSettings.json b/MinimalClean/src/MinimalClean.Architecture.AspireHost/Properties/launchSettings.json new file mode 100644 index 000000000..f3d5d8617 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.AspireHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17143;http://localhost:15258", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21007", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22245" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15258", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19187", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20134" + } + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.Development.json b/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.json b/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.AspireHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/Extensions.cs b/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..3ddbafb69 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET 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 +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // 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"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + 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(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // 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()) + { + // 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/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/MinimalClean.Architecture.ServiceDefaults.csproj b/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/MinimalClean.Architecture.ServiceDefaults.csproj new file mode 100644 index 000000000..c567fa5de --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.ServiceDefaults/MinimalClean.Architecture.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/AssemblyInfo.cs b/MinimalClean/src/MinimalClean.Architecture.Web/AssemblyInfo.cs new file mode 100644 index 000000000..f51f16b6f --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using Vogen; +[assembly: VogenDefaults( + staticAbstractsGeneration: StaticAbstractsGeneration.MostCommon | + StaticAbstractsGeneration.InstanceMethodsAndProperties)] +namespace MinimalClean.Architecture.Web; + +public class AssemblyInfo { } diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartEndpoint.cs new file mode 100644 index 000000000..fa484eef8 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartEndpoint.cs @@ -0,0 +1,117 @@ +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; +using MinimalClean.Architecture.Web.Domain.CartAggregate; + +namespace MinimalClean.Architecture.Web.CartFeatures.AddToCart; + +public sealed class AddToCartRequest +{ + public const string Route = "/cart"; + + public Guid? CartId { get; init; } + public int ProductId { get; init; } + public int Quantity { get; init; } +} + + +public class AddToCartEndpoint(IMediator mediator) + : FastEndpoints.Endpoint, + NotFound, + ValidationProblem, + ProblemHttpResult>, + AddToCartMapper> +{ + public override void Configure() + { + Post(AddToCartRequest.Route); + AllowAnonymous(); + + Summary(s => + { + s.Summary = "Add item to cart"; + s.Description = "Adds a product to an existing cart or creates a new cart if CartId is not provided. Returns the updated cart with all items."; + s.ExampleRequest = new AddToCartRequest + { + CartId = null, // null creates new cart + ProductId = 1, + Quantity = 2 + }; + s.ResponseExamples[200] = new CartResponse( + Guid.Empty, + new List + { + new(1, 2, 999.99m, 1999.98m) + }, + 1999.98m); + + // Document possible responses + s.Responses[200] = "Item added to cart successfully"; + s.Responses[404] = "Product or Cart not found"; + s.Responses[400] = "Invalid request data"; + }); + + // Add tags for API grouping + Tags("Cart"); + + // Add additional metadata + Description(builder => builder + .Accepts("application/json") + .Produces(200, "application/json") + .ProducesProblem(404) + .ProducesProblem(400)); + } + + public override async Task, NotFound, ValidationProblem, ProblemHttpResult>> + ExecuteAsync(AddToCartRequest request, CancellationToken ct) + { + var cartId = request.CartId.HasValue ? CartId.From(request.CartId.Value) : (CartId?)null; + var command = new AddToCartCommand(cartId, request.ProductId, request.Quantity); + var result = await mediator.Send(command, ct); + + if (result.Status == ResultStatus.NotFound) + { + return TypedResults.NotFound(); + } + + if (!result.IsSuccess) + { + return TypedResults.Problem(result.Errors.FirstOrDefault() ?? "An error occurred"); + } + + var response = Map.FromEntity(result.Value); + return TypedResults.Ok(response); + } +} + +public sealed class AddToCartValidator : FastEndpoints.Validator +{ + public AddToCartValidator() + { + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("Product ID must be greater than 0"); + + RuleFor(x => x.Quantity) + .GreaterThan(0) + .WithMessage("Quantity must be greater than 0"); + } +} + + +public sealed class AddToCartMapper + : FastEndpoints.Mapper +{ + public override CartResponse FromEntity(CartDto e) + { + var items = e.Items.Select(i => new CartItemResponse( + i.ProductId, + i.Quantity, + i.UnitPrice, + i.TotalPrice + )).ToList(); + + return new CartResponse(e.Id.Value, items, e.Total); + } +} + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartHandler.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartHandler.cs new file mode 100644 index 000000000..367c9d3ca --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/AddToCart/AddToCartHandler.cs @@ -0,0 +1,62 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Domain.CartAggregate.Specifications; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate.Specifications; + +namespace MinimalClean.Architecture.Web.CartFeatures.AddToCart; + +public record AddToCartCommand(CartId? CartId, int ProductId, int Quantity) : ICommand>; + +public class AddToCartHandler( + IRepository cartRepository, + IReadRepository productRepository) + : ICommandHandler> +{ + public async ValueTask> Handle(AddToCartCommand request, CancellationToken cancellationToken) + { + // Validate product exists + var productSpec = new ProductByIdSpec(ProductId.From(request.ProductId)); + var product = await productRepository.FirstOrDefaultAsync(productSpec, cancellationToken); + if (product == null) + { + return Result.NotFound("Product not found"); + } + + // Get or create cart + Cart cart; + if (request.CartId.HasValue) + { + var cartSpec = new CartByIdSpec(request.CartId.Value); + var existingCart = await cartRepository.FirstOrDefaultAsync(cartSpec, cancellationToken); + if (existingCart == null) + { + return Result.NotFound("Cart not found"); + } + cart = existingCart; + } + else + { + // Create new cart - AddAsync saves by default + cart = new Cart(); + cart = await cartRepository.AddAsync(cart, cancellationToken); + } + + // Add item to cart + cart.AddItem(request.ProductId, request.Quantity, product.UnitPrice); + + // Update the cart with the new items + await cartRepository.UpdateAsync(cart, cancellationToken); + + // Map to DTO + var items = cart.Items.Select(i => new CartItemDto( + i.ProductId, + i.Quantity, + i.UnitPrice, + i.Quantity * i.UnitPrice + )).ToList(); + + var total = items.Sum(i => i.TotalPrice); + + return new CartDto(cart.Id, items, total); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartDto.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartDto.cs new file mode 100644 index 000000000..735a6f7df --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartDto.cs @@ -0,0 +1,7 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; + +namespace MinimalClean.Architecture.Web.CartFeatures; + +public record CartDto(CartId Id, IReadOnlyList Items, decimal Total); + +public record CartItemDto(int ProductId, int Quantity, decimal UnitPrice, decimal TotalPrice); diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartResponse.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartResponse.cs new file mode 100644 index 000000000..3e886ef75 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/CartResponse.cs @@ -0,0 +1,5 @@ +namespace MinimalClean.Architecture.Web.CartFeatures; + +public record CartResponse(Guid CartId, IReadOnlyList Items, decimal Total); + +public record CartItemResponse(int ProductId, int Quantity, decimal UnitPrice, decimal TotalPrice); diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutEndpoint.cs new file mode 100644 index 000000000..5b0ec479c --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutEndpoint.cs @@ -0,0 +1,111 @@ +using FastEndpoints; +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; +using MinimalClean.Architecture.Web.Domain.CartAggregate; + +namespace MinimalClean.Architecture.Web.CartFeatures.Checkout; + +public sealed class CheckoutRequest +{ + public const string Route = "/cart/{CartId}/checkout"; + + public Guid CartId { get; init; } + public string Email { get; init; } = string.Empty; +} + +public record CheckoutResponse(Guid OrderId); + +public class CheckoutEndpoint(IMediator mediator) + : Endpoint, + NotFound, + ValidationProblem, + ProblemHttpResult>, + CheckoutMapper> +{ + public override void Configure() + { + Post(CheckoutRequest.Route); + AllowAnonymous(); + + Summary(s => + { + s.Summary = "Checkout cart"; + s.Description = "Processes checkout for a cart, creating an order and marking the cart as completed. Requires a guest email address."; + s.ExampleRequest = new CheckoutRequest + { + CartId = Guid.Parse("12345678-1234-1234-1234-123456789012"), + Email = "guest@example.com" + }; + s.ResponseExamples[200] = new CheckoutResponse( + Guid.Parse("87654321-4321-4321-4321-210987654321")); + + // Document possible responses + s.Responses[200] = "Checkout completed successfully"; + s.Responses[404] = "Cart not found"; + s.Responses[400] = "Invalid request data or empty cart"; + }); + + // Add tags for API grouping + Tags("Cart"); + + // Add additional metadata + Description(builder => builder + .Accepts("application/json") + .Produces(200, "application/json") + .ProducesProblem(404) + .ProducesProblem(400)); + } + + public override async Task, NotFound, ValidationProblem, ProblemHttpResult>> + ExecuteAsync(CheckoutRequest request, CancellationToken ct) + { + var cartId = CartId.From(request.CartId); + var command = new CheckoutCommand(cartId, request.Email); + var result = await mediator.Send(command, ct); + + if (result.Status == ResultStatus.NotFound) + { + return TypedResults.NotFound(); + } + + if (result.Status == ResultStatus.Invalid) + { + return TypedResults.ValidationProblem(result.ValidationErrors.ToDictionary(e => e.Identifier, e => new[] { e.ErrorMessage })); + } + + if (!result.IsSuccess) + { + return TypedResults.Problem(result.Errors.FirstOrDefault() ?? "An error occurred during checkout"); + } + + var response = Map.FromEntity(result.Value); + return TypedResults.Ok(response); + } +} + +public sealed class CheckoutValidator : Validator +{ + public CheckoutValidator() + { + RuleFor(x => x.CartId) + .NotEqual(Guid.Empty) + .WithMessage("Cart ID is required"); + + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage("Email is required") + .EmailAddress() + .WithMessage("Email must be a valid email address"); + } +} + +public sealed class CheckoutMapper + : Mapper +{ + public override CheckoutResponse FromEntity(CheckoutResult e) + { + return new CheckoutResponse(e.OrderId.Value); + } +} + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutHandler.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutHandler.cs new file mode 100644 index 000000000..5bcdd38ca --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/Checkout/CheckoutHandler.cs @@ -0,0 +1,60 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Domain.CartAggregate.Specifications; +using MinimalClean.Architecture.Web.Domain.GuestUserAggregate; +using MinimalClean.Architecture.Web.Domain.GuestUserAggregate.Specifications; +using MinimalClean.Architecture.Web.Domain.OrderAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.CartFeatures.Checkout; + +public record CheckoutCommand(CartId CartId, string Email) : ICommand>; + +public record CheckoutResult(OrderId OrderId); + +public class CheckoutHandler( + IRepository cartRepository, + IRepository guestUserRepository, + IRepository orderRepository) + : ICommandHandler> +{ + public async ValueTask> Handle(CheckoutCommand request, CancellationToken cancellationToken) + { + // Find the cart with its items + var cartSpec = new CartByIdSpec(request.CartId); + var cart = await cartRepository.FirstOrDefaultAsync(cartSpec, cancellationToken); + + if (cart == null) return Result.NotFound("Cart not found"); + if (!cart.Items.Any()) return Result.Invalid(new ValidationError("Cart is empty")); + + // Get or create guest user + var guestUserSpec = new GuestUserByEmailSpec(request.Email); + var guestUser = await guestUserRepository.FirstOrDefaultAsync(guestUserSpec, cancellationToken); + + if (guestUser == null) + { + var guestUserId = GuestUserId.From(Guid.NewGuid()); + guestUser = new GuestUser(guestUserId, request.Email); + guestUser = await guestUserRepository.AddAsync(guestUser, cancellationToken); + } + + // Create new order + var orderId = OrderId.From(Guid.NewGuid()); + var order = new Order(orderId, guestUser.Id.Value); + + // Add items from cart to order + foreach (var cartItem in cart.Items) + { + order.AddItem( + ProductId.From(cartItem.ProductId), + Quantity.From(cartItem.Quantity), + Price.From(cartItem.UnitPrice)); + } + + await orderRepository.AddAsync(order, cancellationToken); + + cart.MarkAsDeleted(); + await cartRepository.UpdateAsync(cart, cancellationToken); + + return new CheckoutResult(order.Id); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetByIdEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetByIdEndpoint.cs new file mode 100644 index 000000000..d035df0f6 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetByIdEndpoint.cs @@ -0,0 +1,82 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Extensions; +using Microsoft.AspNetCore.Http.HttpResults; +using FastEndpoints; + +namespace MinimalClean.Architecture.Web.CartFeatures.GetById; + +public sealed class GetCartRequest +{ + public const string Route = "/cart/{CartId}"; + + public Guid CartId { get; init; } +} + +public class GetByIdEndpoint(IMediator mediator) + : Endpoint, + NotFound, + ProblemHttpResult>, + GetCartMapper> +{ + public override void Configure() + { + Get(GetCartRequest.Route); + AllowAnonymous(); + + Summary(s => + { + s.Summary = "Get cart by ID"; + s.Description = "Retrieves a cart and all its items by cart ID."; + s.ExampleRequest = new GetCartRequest + { + CartId = Guid.Parse("12345678-1234-1234-1234-123456789012") + }; + s.ResponseExamples[200] = new CartResponse( + Guid.Parse("12345678-1234-1234-1234-123456789012"), + new List + { + new(1, 2, 999.99m, 1999.98m) + }, + 1999.98m); + + // Document possible responses + s.Responses[200] = "Cart retrieved successfully"; + s.Responses[404] = "Cart not found"; + }); + + // Add tags for API grouping + Tags("Cart"); + + // Add additional metadata + Description(builder => builder + .Produces(200, "application/json") + .ProducesProblem(404)); + } + + public override async Task, NotFound, ProblemHttpResult>> + ExecuteAsync(GetCartRequest request, CancellationToken ct) + { + var cartId = CartId.From(request.CartId); + var query = new GetCartQuery(cartId); + var result = await mediator.Send(query, ct); + + return result.ToGetByIdResult(Map.FromEntity); + } +} + +public sealed class GetCartMapper + : Mapper +{ + public override CartResponse FromEntity(CartDto e) + { + var items = e.Items.Select(i => new CartItemResponse( + i.ProductId, + i.Quantity, + i.UnitPrice, + i.TotalPrice + )).ToList(); + + return new CartResponse(e.Id.Value, items, e.Total); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetCartHandler.cs b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetCartHandler.cs new file mode 100644 index 000000000..a1d832a9b --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/CartFeatures/GetById/GetCartHandler.cs @@ -0,0 +1,32 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Domain.CartAggregate.Specifications; + +namespace MinimalClean.Architecture.Web.CartFeatures.GetById; + +public record GetCartQuery(CartId CartId) : IQuery>; +public class GetCartHandler(IReadRepository repository) + : IQueryHandler> +{ + public async ValueTask> Handle(GetCartQuery request, CancellationToken cancellationToken) + { + var spec = new CartByIdSpec(request.CartId); + var cart = await repository.FirstOrDefaultAsync(spec, cancellationToken); + + if (cart == null) + { + return Result.NotFound("Cart not found"); + } + + // Map to DTO + var items = cart.Items.Select(i => new CartItemDto( + i.ProductId, + i.Quantity, + i.UnitPrice, + i.Quantity * i.UnitPrice + )).ToList(); + + var total = items.Sum(i => i.TotalPrice); + + return new CartDto(cart.Id, items, total); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/DatabaseOptions.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/DatabaseOptions.cs new file mode 100644 index 000000000..5da29851f --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/DatabaseOptions.cs @@ -0,0 +1,13 @@ +namespace MinimalClean.Architecture.Web.Configurations; + +/// +/// Configuration options for database management +/// +public class DatabaseOptions +{ + /// + /// If true, drops and recreates the database on startup in Development environment. + /// WARNING: This will delete all existing data! + /// + public bool RecreateOnStartup { get; set; } = false; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggerConfigs.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggerConfigs.cs new file mode 100644 index 000000000..49d2341ec --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggerConfigs.cs @@ -0,0 +1,20 @@ +using Serilog; + +namespace MinimalClean.Architecture.Web.Configurations; + +public static class LoggerConfigs +{ + public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder builder) + { + // 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/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggingBehavior.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggingBehavior.cs new file mode 100644 index 000000000..69075f0d0 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/LoggingBehavior.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; + +namespace Nimble.Modulith.Web; + +public class LoggingBehavior : IPipelineBehavior + where TRequest : notnull, IMessage +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async ValueTask Handle( + TRequest request, + MessageHandlerDelegate next, + CancellationToken cancellationToken) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + // Use structured logging with the whole object instead of reflection + _logger.LogInformation("Handling {RequestName}: {@Request}", + typeof(TRequest).Name, request); + } + + var sw = Stopwatch.StartNew(); + var response = await next(request, cancellationToken); + + _logger.LogInformation( + "Handled {RequestName} with {Response} in {Ms} ms", + typeof(TRequest).Name, + response, + sw.ElapsedMilliseconds); + + sw.Stop(); + return response; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MediatorConfig.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MediatorConfig.cs new file mode 100644 index 000000000..6348d37c8 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MediatorConfig.cs @@ -0,0 +1,33 @@ +namespace MinimalClean.Architecture.Web.Configurations; + +public static class MediatorConfig +{ + // Should be called from ServiceConfigs.cs, not Program.cs + public static IServiceCollection AddMediatorSourceGen(this IServiceCollection services, + 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(MediatorConfig) // Web + ]; + + // Register AOT pipeline behaviors here (order matters) + options.PipelineBehaviors = + [ + typeof(Nimble.Modulith.Web.LoggingBehavior<,>) + ]; + + // If you have stream behaviors: + // options.StreamPipelineBehaviors = [ typeof(YourStreamBehavior<,>) ]; + }); + + return services; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MiddlewareConfig.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MiddlewareConfig.cs new file mode 100644 index 000000000..c11444b7d --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/MiddlewareConfig.cs @@ -0,0 +1,76 @@ +using Ardalis.ListStartupServices; +using FastEndpoints; +using FastEndpoints.Swagger; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using MinimalClean.Architecture.Web.Infrastructure.Data; +using Scalar.AspNetCore; + +namespace MinimalClean.Architecture.Web.Configurations; + +public static class MiddlewareConfig +{ + public static async Task UseAppMiddlewareAndSeedDatabase(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + app.UseShowAllServicesMiddleware(); // see https://github.com/ardalis/AspNetCoreStartupServices + } + else + { + app.UseDefaultExceptionHandler(); // from FastEndpoints + app.UseHsts(); + } + + 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); + + return app; + } + + static async Task SeedDatabase(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var services = scope.ServiceProvider; + var logger = services.GetRequiredService>(); + + try + { + var context = services.GetRequiredService(); + var dbOptions = services.GetService>()?.Value; + + // Drop and recreate database in development if configured + if (app.Environment.IsDevelopment() && dbOptions?.RecreateOnStartup == true) + { + logger.LogWarning("DROPPING database for fresh start (DatabaseOptions:RecreateOnStartup = true)..."); + await context.Database.EnsureDeletedAsync(); + logger.LogInformation("Database dropped."); + } + + // Apply all pending migrations + logger.LogInformation("Applying database migrations..."); + await context.Database.MigrateAsync(); + logger.LogInformation("Database migrations applied successfully."); + + // Seed data + await SeedData.InitializeAsync(context, logger); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred seeding the DB. {exceptionMessage}", ex.Message); + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/OptionConfigs.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/OptionConfigs.cs new file mode 100644 index 000000000..6f319a5ac --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/OptionConfigs.cs @@ -0,0 +1,38 @@ +using Ardalis.ListStartupServices; +using MinimalClean.Architecture.Web.Infrastructure.Email; + +namespace MinimalClean.Architecture.Web.Configurations; + +public static class OptionConfigs +{ + public static IServiceCollection AddOptionConfigs(this IServiceCollection services, + IConfiguration configuration, + Microsoft.Extensions.Logging.ILogger logger, + WebApplicationBuilder builder) + { + services.Configure(configuration.GetSection("Mailserver")) + .Configure(configuration.GetSection("DatabaseOptions")) + // Configure Web Behavior + .Configure(options => + { + options.CheckConsentNeeded = context => true; + options.MinimumSameSitePolicy = SameSiteMode.None; + }); + + if (builder.Environment.IsDevelopment()) + { + // add list services for diagnostic purposes - see https://github.com/ardalis/AspNetCoreStartupServices + services.Configure(config => + { + config.Services = new List(builder.Services); + + // optional - default path to view services is /listallservices - recommended to choose your own path + config.Path = "/listservices"; + }); + } + + logger.LogInformation("{Project} were configured", "Options"); + + return services; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/ServiceConfigs.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/ServiceConfigs.cs new file mode 100644 index 000000000..c6c66d85c --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Configurations/ServiceConfigs.cs @@ -0,0 +1,35 @@ +using MinimalClean.Architecture.Web.Domain.Interfaces; +using MinimalClean.Architecture.Web.Infrastructure; +using MinimalClean.Architecture.Web.Infrastructure.Email; + +namespace MinimalClean.Architecture.Web.Configurations; + +public static class ServiceConfigs +{ + public static IServiceCollection AddServiceConfigs(this IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger, WebApplicationBuilder builder) + { + services.AddInfrastructureServices(builder.Configuration, logger) + .AddMediatorSourceGen(logger); + + if (builder.Environment.IsDevelopment()) + { + // Use a local test email server + // 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"); + + return services; + } + + +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Constants.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Constants.cs new file mode 100644 index 000000000..57aa2a2e0 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Constants.cs @@ -0,0 +1,7 @@ +namespace MinimalClean.Architecture.Web; + +public class Constants +{ + public const int DEFAULT_PAGE_SIZE = 10; + public const int MAX_PAGE_SIZE = 100; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Cart.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Cart.cs new file mode 100644 index 000000000..760cbb433 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Cart.cs @@ -0,0 +1,21 @@ +namespace MinimalClean.Architecture.Web.Domain.CartAggregate; + +public class Cart : EntityBase, IAggregateRoot +{ + private readonly List _items = new(); + + public DateTime CreatedOn { get; private set; } = DateTime.UtcNow; + public bool Deleted { get; private set; } = false; + public IReadOnlyList Items => _items.AsReadOnly(); + + public void AddItem(int productId, int quantity, decimal unitPrice) + { + var item = new CartItem(productId, quantity, unitPrice); + _items.Add(item); + } + + public void MarkAsDeleted() + { + Deleted = true; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartId.cs new file mode 100644 index 000000000..0737d3e3c --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartId.cs @@ -0,0 +1,12 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.CartAggregate; + +[ValueObject] +public readonly partial struct CartId +{ + private static Validation Validate(Guid value) + => value != Guid.Empty ? Validation.Ok : Validation.Invalid("CartId must set to non-default value."); +} +[ValueObject] +public readonly partial struct TestId { } diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItem.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItem.cs new file mode 100644 index 000000000..da47a33cd --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItem.cs @@ -0,0 +1,18 @@ +namespace MinimalClean.Architecture.Web.Domain.CartAggregate; + +public class CartItem : EntityBase +{ + // Private constructor for EF Core + private CartItem() { } + + public CartItem(int productId, int quantity, decimal unitPrice) + { + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } + + public int ProductId { get; private set; } + public int Quantity { get; private set; } + public decimal UnitPrice { get; private set; } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItemId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItemId.cs new file mode 100644 index 000000000..b1a994892 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/CartItemId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.CartAggregate; + +[ValueObject] +public readonly partial struct CartItemId +{ + private static Validation Validate(Guid value) + => value != Guid.Empty ? Validation.Ok : Validation.Invalid("CartItemId must set to non-default value."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Specifications/CartByIdSpec.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Specifications/CartByIdSpec.cs new file mode 100644 index 000000000..f6df41bc4 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/CartAggregate/Specifications/CartByIdSpec.cs @@ -0,0 +1,9 @@ +namespace MinimalClean.Architecture.Web.Domain.CartAggregate.Specifications; + +public class CartByIdSpec : Specification +{ + public CartByIdSpec(CartId cartId) => + Query + .Include(c => c.Items) + .Where(cart => cart.Id == cartId && !cart.Deleted); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUser.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUser.cs new file mode 100644 index 000000000..5d4601f23 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUser.cs @@ -0,0 +1,18 @@ +namespace MinimalClean.Architecture.Web.Domain.GuestUserAggregate; + +public class GuestUser : EntityBase, IAggregateRoot +{ + public GuestUser(GuestUserId id, string email) + { + Id = id; + Email = email; + } + + public string Email { get; private set; } + + public GuestUser UpdateEmail(string newEmail) + { + Email = newEmail; + return this; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUserId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUserId.cs new file mode 100644 index 000000000..b0a9121c0 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/GuestUserId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.GuestUserAggregate; + +[ValueObject] +public readonly partial struct GuestUserId +{ + private static Validation Validate(Guid value) + => value != Guid.Empty ? Validation.Ok : Validation.Invalid("GuestUserId cannot be empty."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByEmailSpec.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByEmailSpec.cs new file mode 100644 index 000000000..e15758a5a --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByEmailSpec.cs @@ -0,0 +1,7 @@ +namespace MinimalClean.Architecture.Web.Domain.GuestUserAggregate.Specifications; + +public class GuestUserByEmailSpec : Specification +{ + public GuestUserByEmailSpec(string email) => + Query.Where(g => g.Email == email); +} \ No newline at end of file diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByIdSpec.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByIdSpec.cs new file mode 100644 index 000000000..0e0cff5aa --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/GuestUserAggregate/Specifications/GuestUserByIdSpec.cs @@ -0,0 +1,7 @@ +namespace MinimalClean.Architecture.Web.Domain.GuestUserAggregate.Specifications; + +public class GuestUserByIdSpec : Specification +{ + public GuestUserByIdSpec(GuestUserId guestUserId) => + Query.Where(g => g.Id == guestUserId); +} \ No newline at end of file diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/Interfaces/IEmailSender.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/Interfaces/IEmailSender.cs new file mode 100644 index 000000000..41cec31da --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/Interfaces/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace MinimalClean.Architecture.Web.Domain.Interfaces; + +public interface IEmailSender +{ + Task SendEmailAsync(string to, string from, string subject, string body); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Order.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Order.cs new file mode 100644 index 000000000..f99d4573b --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Order.cs @@ -0,0 +1,43 @@ +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using MinimalClean.Architecture.Web.Infrastructure.Data; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +public class Order : EntityBase, IAggregateRoot +{ + private readonly List _items = new(); + + public Order(OrderId id, Guid guestUserId) + { + Id = id; + GuestUserId = guestUserId; + + } + + public DateTimeOffset CreatedOn { get; private set; } = DateTimeOffset.UtcNow; + public Guid GuestUserId { get; private set; } + public DateTimeOffset? DatePaid { get; private set; } + public string PaymentReference { get; private set; } = string.Empty; + public IReadOnlyList Items => _items.AsReadOnly(); + + public decimal Total => _items.Sum(i => i.UnitPrice.Value * i.Quantity.Value); + + public void AddItem(ProductId productId, Quantity quantity, Price unitPrice) + { + var item = new OrderItem(Id, productId, quantity, unitPrice); + + if(Items.Any(i => i.ProductId == productId)) + { + var existingItem = Items.First(i => i.ProductId == productId); + existingItem.IncreaseQuantity(quantity); + return; + } + _items.Add(item); + } + + public void ConfirmPayment(DateTimeOffset datePaid, string paymentReference) + { + DatePaid = datePaid; + PaymentReference = paymentReference; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderId.cs new file mode 100644 index 000000000..2a8b56386 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +[ValueObject] +public readonly partial struct OrderId +{ + private static Validation Validate(Guid value) + => value != Guid.Empty ? Validation.Ok : Validation.Invalid("OrderId cannot be empty."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItem.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItem.cs new file mode 100644 index 000000000..336d1df5b --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItem.cs @@ -0,0 +1,18 @@ + +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +public class OrderItem(OrderId orderId, ProductId productId, Quantity quantity, Price unitPrice) + : EntityBase +{ + public OrderId OrderId { get; private set; } = orderId; + public ProductId ProductId { get; private set; } = productId; + public Quantity Quantity { get; private set; } = quantity; + public Price UnitPrice { get; private set; } = unitPrice; + + internal void IncreaseQuantity(Quantity quantity) + { + Quantity += quantity; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItemId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItemId.cs new file mode 100644 index 000000000..113bc031e --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/OrderItemId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +[ValueObject] +public readonly partial struct OrderItemId +{ + private static Validation Validate(Guid value) + => value != Guid.Empty ? Validation.Ok : Validation.Invalid("OrderItemId cannot be empty."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Price.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Price.cs new file mode 100644 index 000000000..1fa05e2e0 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Price.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +[ValueObject] +public readonly partial struct Price +{ + private static Validation Validate(decimal value) + => value > 0 ? Validation.Ok : Validation.Invalid("Price must be greater than zero."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Quantity.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Quantity.cs new file mode 100644 index 000000000..f2a35d110 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/OrderAggregate/Quantity.cs @@ -0,0 +1,15 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.OrderAggregate; + +[ValueObject] +public readonly partial struct Quantity +{ + private static Validation Validate(int value) + => value > 0 ? Validation.Ok : Validation.Invalid("Quantity must be greater than zero."); + + public static Quantity operator +(Quantity left, Quantity right) + { + return From(left.Value + right.Value); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Product.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Product.cs new file mode 100644 index 000000000..baad0480d --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Product.cs @@ -0,0 +1,26 @@ +namespace MinimalClean.Architecture.Web.Domain.ProductAggregate; + +public class Product : EntityBase, IAggregateRoot +{ + public Product(ProductId id, string name, decimal unitPrice) + { + Id = id; + Name = name; + UnitPrice = unitPrice; + } + + public string Name { get; private set; } + public decimal UnitPrice { get; private set; } + + public Product UpdateName(string newName) + { + Name = newName; + return this; + } + + public Product UpdatePrice(decimal newPrice) + { + UnitPrice = newPrice; + return this; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/ProductId.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/ProductId.cs new file mode 100644 index 000000000..7309754ea --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/ProductId.cs @@ -0,0 +1,10 @@ +using Vogen; + +namespace MinimalClean.Architecture.Web.Domain.ProductAggregate; + +[ValueObject] +public readonly partial struct ProductId +{ + private static Validation Validate(int value) + => value > 0 ? Validation.Ok : Validation.Invalid("ProductId must be positive."); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Specifications/ProductByIdSpec.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Specifications/ProductByIdSpec.cs new file mode 100644 index 000000000..9a33310cc --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Domain/ProductAggregate/Specifications/ProductByIdSpec.cs @@ -0,0 +1,8 @@ +namespace MinimalClean.Architecture.Web.Domain.ProductAggregate.Specifications; + +public class ProductByIdSpec : Specification +{ + public ProductByIdSpec(ProductId productId) => + Query + .Where(product => product.Id == productId); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Extensions/ResultExtensions.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Extensions/ResultExtensions.cs new file mode 100644 index 000000000..8fa0ae19a --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Extensions/ResultExtensions.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace MinimalClean.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/MinimalClean/src/MinimalClean.Architecture.Web/GlobalUsings.cs b/MinimalClean/src/MinimalClean.Architecture.Web/GlobalUsings.cs new file mode 100644 index 000000000..20b453b7a --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using Mediator; +global using Ardalis.Result; +global using Ardalis.Specification; +global using Ardalis.SharedKernel; diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContext.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContext.cs new file mode 100644 index 000000000..7467fd8ce --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContext.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Domain.GuestUserAggregate; +using MinimalClean.Architecture.Web.Domain.OrderAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; +public class AppDbContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Products => Set(); + public DbSet Carts => Set(); + public DbSet CartItems => Set(); + public DbSet GuestUsers => Set(); + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); + } + + public override int SaveChanges() => + SaveChangesAsync().GetAwaiter().GetResult(); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextExtensions.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextExtensions.cs new file mode 100644 index 000000000..91c571502 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; + +public static class AppDbContextExtensions +{ + public static void AddApplicationDbContext(this IServiceCollection services, string connectionString) => + services.AddDbContext(options => + options.UseSqlServer(connectionString)); + +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextFactory.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextFactory.cs new file mode 100644 index 000000000..1ed5e5338 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/AppDbContextFactory.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; + +/// +/// Design-time factory for creating DbContext instances for EF Core tools. +/// This is ONLY used when CREATING migrations (e.g., 'dotnet ef migrations add'). +/// It is NOT used at runtime - Aspire handles the runtime connection. +/// +/// Migrations are automatically APPLIED at runtime via MigrateAsync() in MiddlewareConfig.cs. +/// +/// To create migrations with SQL Server, set an environment variable: +/// PowerShell: $env:ConnectionStrings__AppDb = "Server=localhost,1433;Database=AppDb;User ID=sa;Password=YourPassword;TrustServerCertificate=true" +/// Bash: export ConnectionStrings__AppDb="Server=localhost,1433;Database=AppDb;User ID=sa;Password=YourPassword;TrustServerCertificate=true" +/// +/// Always uses SQL Server for migrations. +/// +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Try to get connection string from environment variable (ASP.NET Core format) + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__AppDb") + ?? "Server=localhost,1433;Database=AppDb_Design;User ID=sa;Password=Your$tr0ngP@ss!;TrustServerCertificate=true"; // Default SQL Server connection for design-time + + optionsBuilder.UseSqlServer(connectionString); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartConfiguration.cs new file mode 100644 index 000000000..792435a4b --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.CartAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class CartConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(entity => entity.CreatedOn) + .IsRequired(); + + builder.Property(entity => entity.Deleted) + .IsRequired(); + + // CartItems relationship + builder.HasMany(c => c.Items); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartItemConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartItemConfiguration.cs new file mode 100644 index 000000000..fcb6e38c7 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/CartItemConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.CartAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class CartItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CartItems"); + + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(ci => ci.ProductId) + .IsRequired(); + + builder.Property(ci => ci.Quantity) + .IsRequired(); + + builder.Property(ci => ci.UnitPrice) + .HasPrecision(18, 2) + .IsRequired(); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/DataSchemaConstants.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/DataSchemaConstants.cs new file mode 100644 index 000000000..ce9d2b01a --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/DataSchemaConstants.cs @@ -0,0 +1,6 @@ +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public static class DataSchemaConstants +{ + public const int DEFAULT_NAME_LENGTH = 100; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/GuestUserConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/GuestUserConfiguration.cs new file mode 100644 index 000000000..26bbb1d37 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/GuestUserConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.GuestUserAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class GuestUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(entity => entity.Email) + .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) + .IsRequired(); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderConfiguration.cs new file mode 100644 index 000000000..9e4e935ff --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderConfiguration.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.OrderAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(entity => entity.CreatedOn) + .IsRequired(); + + builder.Property(entity => entity.GuestUserId) + .IsRequired(); + + builder.Property(entity => entity.DatePaid) + .IsRequired(false); + + builder.Property(entity => entity.PaymentReference) + .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) + .IsRequired(false); + + // OrderItems relationship + builder.HasMany(o => o.Items); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderItemConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderItemConfiguration.cs new file mode 100644 index 000000000..48482fa10 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/OrderItemConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.OrderAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class OrderItemConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.ToTable("OrderItems"); + + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(oi => oi.OrderId) + .HasVogenConversion() + .IsRequired(); + + builder.Property(oi => oi.ProductId) + .HasVogenConversion() + .IsRequired(); + + builder.Property(oi => oi.Quantity) + .HasVogenConversion() + .IsRequired(); + + builder.Property(oi => oi.UnitPrice) + .HasVogenConversion() + .HasPrecision(18, 2) + .IsRequired(); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/ProductConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/ProductConfiguration.cs new file mode 100644 index 000000000..1182ab899 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/ProductConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(entity => entity.Id) + .HasValueGenerator>() + .HasVogenConversion() + .IsRequired(); + + builder.Property(entity => entity.Name) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(entity => entity.UnitPrice) + .HasPrecision(18, 2) + .IsRequired(); + + builder.HasData( + new Product( + ProductId.From(1), + "Coffee Mug", + 9.99m), + new Product( + ProductId.From(2), + "T-Shirt", + 19.99m), + new Product( + ProductId.From(3), + "Sticker Pack", + 3.99m) + ); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenEfCoreConverters.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenEfCoreConverters.cs new file mode 100644 index 000000000..f4047d0a9 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenEfCoreConverters.cs @@ -0,0 +1,17 @@ +using MinimalClean.Architecture.Web.Domain.CartAggregate; +using MinimalClean.Architecture.Web.Domain.GuestUserAggregate; +using MinimalClean.Architecture.Web.Domain.OrderAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using Vogen; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +internal partial class VogenEfCoreConverters; diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenGuidIdValueGenerator.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenGuidIdValueGenerator.cs new file mode 100644 index 000000000..09fd6b2f7 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenGuidIdValueGenerator.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ValueGeneration; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +internal class VogenGuidIdValueGenerator : ValueGenerator + where TContext : DbContext + where TEntityBase : EntityBase + where TId : struct +{ + public override TId Next(EntityEntry entry) + { + // Use reflection to call the static From method on the value object + var fromMethod = typeof(TId).GetMethod("From", new[] { typeof(Guid) }); + if (fromMethod == null) + { + throw new InvalidOperationException($"Type {typeof(TId).Name} does not have a From(Guid) method"); + } + + return (TId)fromMethod.Invoke(null, new object[] { Guid.NewGuid() })!; + } + + public override bool GeneratesTemporaryValues => false; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenIntIdValueGenerator.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenIntIdValueGenerator.cs new file mode 100644 index 000000000..cb8ee7e53 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Config/VogenIntIdValueGenerator.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.ValueGeneration; +using Vogen; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Config; + +internal class VogenIntIdValueGenerator : ValueGenerator + where TContext : DbContext + where TEntityBase : EntityBase + where TId : IVogen +{ + private readonly PropertyInfo _matchPropertyGetter; + + public VogenIntIdValueGenerator() + { + 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); + } + + private static int MaxFrom(IEnumerable es) + { + if (!es.Any()) return 0; + return es.Max(e => e.Id.Value); + } + + public override bool GeneratesTemporaryValues => false; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EfRepository.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EfRepository.cs new file mode 100644 index 000000000..6774490af --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EfRepository.cs @@ -0,0 +1,9 @@ +using Ardalis.Specification.EntityFrameworkCore; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; + +// inherit from Ardalis.Specification type +public class EfRepository(AppDbContext dbContext) : + RepositoryBase(dbContext), IReadRepository, IRepository where T : class, IAggregateRoot +{ +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EventDispatcherInterceptor.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EventDispatcherInterceptor.cs new file mode 100644 index 000000000..bd3b84a59 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/EventDispatcherInterceptor.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; + +// Intercepts SaveChanges to dispatch domain events after changes are successfully saved +public class EventDispatchInterceptor(IDomainEventDispatcher domainEventDispatcher) : SaveChangesInterceptor +{ + private readonly IDomainEventDispatcher _domainEventDispatcher = domainEventDispatcher; + + // Called after SaveChangesAsync has completed successfully + public override async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, + CancellationToken cancellationToken = new CancellationToken()) + { + var context = eventData.Context; + if (context is not AppDbContext appDbContext) + { + return await base.SavedChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false); + } + + // Retrieve all tracked entities that have domain events + var entitiesWithEvents = appDbContext.ChangeTracker.Entries() + .Select(e => e.Entity) + .Where(e => e.DomainEvents.Any()) + .ToArray(); + + // Dispatch and clear domain events + await _domainEventDispatcher.DispatchAndClearEvents(entitiesWithEvents); + + return await base.SavedChangesAsync(eventData, result, cancellationToken); + + } +} + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.Designer.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.Designer.cs new file mode 100644 index 000000000..c9fc642ff --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.Designer.cs @@ -0,0 +1,122 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinimalClean.Architecture.Web.Infrastructure.Data; + +#nullable disable + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251020223304_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CartId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.ToTable("CartItems", (string)null); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.ProductAggregate.Product", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products"); + + b.HasData( + new + { + Id = 1, + Name = "Laptop", + UnitPrice = 999.99m + }, + new + { + Id = 2, + Name = "Smartphone", + UnitPrice = 499.99m + }, + new + { + Id = 3, + Name = "Tablet", + UnitPrice = 299.99m + }); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.HasOne("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", null) + .WithMany("Items") + .HasForeignKey("CartId"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.cs new file mode 100644 index 000000000..e9cf73520 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251020223304_Initial.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Carts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CreatedOn = table.Column(type: "datetime2", nullable: false), + Deleted = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Carts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + UnitPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CartItems", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "int", nullable: false), + Quantity = table.Column(type: "int", nullable: false), + UnitPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + CartId = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CartItems", x => x.Id); + table.ForeignKey( + name: "FK_CartItems_Carts_CartId", + column: x => x.CartId, + principalTable: "Carts", + principalColumn: "Id"); + }); + + migrationBuilder.InsertData( + table: "Products", + columns: new[] { "Id", "Name", "UnitPrice" }, + values: new object[,] + { + { 1, "Laptop", 999.99m }, + { 2, "Smartphone", 499.99m }, + { 3, "Tablet", 299.99m } + }); + + migrationBuilder.CreateIndex( + name: "IX_CartItems_CartId", + table: "CartItems", + column: "CartId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CartItems"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Carts"); + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.Designer.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.Designer.cs new file mode 100644 index 000000000..f895b71c1 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.Designer.cs @@ -0,0 +1,199 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinimalClean.Architecture.Web.Infrastructure.Data; + +#nullable disable + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251024232246_AddGuestUsersAndOrdersSqlServer")] + partial class AddGuestUsersAndOrdersSqlServer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CartId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.ToTable("CartItems", (string)null); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.GuestUserAggregate.GuestUser", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("GuestUsers"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("DatePaid") + .HasColumnType("datetimeoffset"); + + b.Property("GuestUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentReference") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.OrderItem", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.ProductAggregate.Product", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products"); + + b.HasData( + new + { + Id = 1, + Name = "Coffee Mug", + UnitPrice = 9.99m + }, + new + { + Id = 2, + Name = "T-Shirt", + UnitPrice = 19.99m + }, + new + { + Id = 3, + Name = "Sticker Pack", + UnitPrice = 3.99m + }); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.HasOne("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", null) + .WithMany("Items") + .HasForeignKey("CartId"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.OrderItem", b => + { + b.HasOne("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.cs new file mode 100644 index 000000000..b16dc0344 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/20251024232246_AddGuestUsersAndOrdersSqlServer.cs @@ -0,0 +1,123 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Migrations +{ + /// + public partial class AddGuestUsersAndOrdersSqlServer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GuestUsers", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Email = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GuestUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CreatedOn = table.Column(type: "datetimeoffset", nullable: false), + GuestUserId = table.Column(type: "uniqueidentifier", nullable: false), + DatePaid = table.Column(type: "datetimeoffset", nullable: true), + PaymentReference = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrderItems", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + ProductId = table.Column(type: "int", nullable: false), + Quantity = table.Column(type: "int", nullable: false), + UnitPrice = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderItems", x => x.Id); + table.ForeignKey( + name: "FK_OrderItems_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "Coffee Mug", 9.99m }); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "T-Shirt", 19.99m }); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "Sticker Pack", 3.99m }); + + migrationBuilder.CreateIndex( + name: "IX_OrderItems_OrderId", + table: "OrderItems", + column: "OrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GuestUsers"); + + migrationBuilder.DropTable( + name: "OrderItems"); + + migrationBuilder.DropTable( + name: "Orders"); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "Laptop", 999.99m }); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "Smartphone", 499.99m }); + + migrationBuilder.UpdateData( + table: "Products", + keyColumn: "Id", + keyValue: 3, + columns: new[] { "Name", "UnitPrice" }, + values: new object[] { "Tablet", 299.99m }); + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 000000000..4f37cd441 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,196 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MinimalClean.Architecture.Web.Infrastructure.Data; + +#nullable disable + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetime2"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CartId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.ToTable("CartItems", (string)null); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.GuestUserAggregate.GuestUser", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("GuestUsers"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("DatePaid") + .HasColumnType("datetimeoffset"); + + b.Property("GuestUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("PaymentReference") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.OrderItem", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProductId") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems", (string)null); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.ProductAggregate.Product", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.ToTable("Products"); + + b.HasData( + new + { + Id = 1, + Name = "Coffee Mug", + UnitPrice = 9.99m + }, + new + { + Id = 2, + Name = "T-Shirt", + UnitPrice = 19.99m + }, + new + { + Id = 3, + Name = "Sticker Pack", + UnitPrice = 3.99m + }); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.CartItem", b => + { + b.HasOne("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", null) + .WithMany("Items") + .HasForeignKey("CartId"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.OrderItem", b => + { + b.HasOne("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.CartAggregate.Cart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("MinimalClean.Architecture.Web.Domain.OrderAggregate.Order", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Queries/ListProductsQueryService.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Queries/ListProductsQueryService.cs new file mode 100644 index 000000000..b052ff860 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/Queries/ListProductsQueryService.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using MinimalClean.Architecture.Web.ProductFeatures; +using MinimalClean.Architecture.Web.ProductFeatures.List; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data.Queries; + +public class ListProductsQueryService : IListProductsQueryService +{ + private readonly AppDbContext _db; + + public ListProductsQueryService(AppDbContext db) + { + _db = db; + } + + public async Task> ListAsync(int page, int perPage) + { + var items = await _db.Products + .OrderBy(p => p.Id) + .Skip((page - 1) * perPage) + .Take(perPage) + .Select(p => new ProductDto(p.Id, p.Name, p.UnitPrice)) + .AsNoTracking() + .ToListAsync(); + + int totalCount = await _db.Products.CountAsync(); + int totalPages = (int)Math.Ceiling(totalCount / (double)perPage); + var result = new PagedResult(items, page, perPage, totalCount, totalPages); + + return result; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/SeedData.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/SeedData.cs new file mode 100644 index 000000000..5716221f9 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Data/SeedData.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using Microsoft.Extensions.Logging; + +namespace MinimalClean.Architecture.Web.Infrastructure.Data; + +public static class SeedData +{ + public const int NUMBER_OF_PRODUCTS = 10; + + public static async Task InitializeAsync(AppDbContext dbContext, ILogger logger) + { + if (await dbContext.Products.AnyAsync()) + { + logger.LogInformation("DB has data - seeding not required."); + return; // DB has been seeded + } + await PopulateTestDataAsync(dbContext, logger); + } + + public static async Task PopulateTestDataAsync(AppDbContext dbContext, ILogger logger) + { + logger.LogInformation("Seeding database with sample data."); + + // add more products to support demonstrating paging + for (int i = 1; i <= NUMBER_OF_PRODUCTS; i++) + { + dbContext.Products.Add(new Product(ProductId.From(i), $"Product {i}", 10m + i)); + } + await dbContext.SaveChangesAsync(); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/FakeEmailSender.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/FakeEmailSender.cs new file mode 100644 index 000000000..78ac97853 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/FakeEmailSender.cs @@ -0,0 +1,13 @@ +using MinimalClean.Architecture.Web.Domain.Interfaces; + +namespace MinimalClean.Architecture.Web.Infrastructure.Email; + +public class FakeEmailSender(ILogger logger) : IEmailSender +{ + private readonly ILogger _logger = logger; + public Task SendEmailAsync(string to, string from, string subject, string body) + { + _logger.LogInformation("Not actually sending an email to {to} from {from} with subject {subject}", to, from, subject); + return Task.CompletedTask; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MailserverConfiguration.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MailserverConfiguration.cs new file mode 100644 index 000000000..32e5cc537 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MailserverConfiguration.cs @@ -0,0 +1,7 @@ +namespace MinimalClean.Architecture.Web.Infrastructure.Email; + +public class MailserverConfiguration() +{ + public string Hostname { get; set; } = "localhost"; + public int Port { get; set; } = 25; +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MimeKitEmailSender.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MimeKitEmailSender.cs new file mode 100644 index 000000000..b1e1a007d --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/Email/MimeKitEmailSender.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Options; +using MimeKit; +using MinimalClean.Architecture.Web.Domain.Interfaces; + +namespace MinimalClean.Architecture.Web.Infrastructure.Email; + +public class MimeKitEmailSender(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) + { + _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); + + using var client = new MailKit.Net.Smtp.SmtpClient(); + await client.ConnectAsync(_mailserverConfiguration.Hostname, + _mailserverConfiguration.Port, false); + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(from, from)); + message.To.Add(new MailboxAddress(to, to)); + message.Subject = subject; + message.Body = new TextPart("plain") { Text = body }; + + await client.SendAsync(message); + + await client.DisconnectAsync(true, + new CancellationToken(canceled: true)); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/InfrastructureServiceExtensions.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/InfrastructureServiceExtensions.cs new file mode 100644 index 000000000..e4e42deb9 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Infrastructure/InfrastructureServiceExtensions.cs @@ -0,0 +1,38 @@ +using Ardalis.GuardClauses; +using MinimalClean.Architecture.Web.Infrastructure.Data; +using MinimalClean.Architecture.Web.Infrastructure.Data.Queries; +using MinimalClean.Architecture.Web.ProductFeatures.List; +using Microsoft.EntityFrameworkCore; + +namespace MinimalClean.Architecture.Web.Infrastructure; +public static class InfrastructureServiceExtensions +{ + public static IServiceCollection AddInfrastructureServices( + this IServiceCollection services, + ConfigurationManager config, + ILogger logger) + { + // Always use SQL Server from Aspire + string? connectionString = config.GetConnectionString("AppDb"); + Guard.Against.Null(connectionString, "AppDb connection string is required. Make sure the application is running with Aspire."); + + services.AddScoped(); + services.AddScoped(); + + services.AddDbContext((provider, options) => + { + var eventDispatchInterceptor = provider.GetRequiredService(); + + options.UseSqlServer(connectionString); + options.AddInterceptors(eventDispatchInterceptor); + }); + + services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)) + .AddScoped(typeof(IReadRepository<>), typeof(EfRepository<>)) + .AddScoped(); + + logger.LogInformation("{Project} services registered", "Infrastructure"); + + return services; + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/MinimalClean.Architecture.Web.csproj b/MinimalClean/src/MinimalClean.Architecture.Web/MinimalClean.Architecture.Web.csproj new file mode 100644 index 000000000..376892af6 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/MinimalClean.Architecture.Web.csproj @@ -0,0 +1,62 @@ + + + + + true + Exe + true + True + MinimalClean.Architecture.Web + NSDEPCOP01 + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + compile; runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/PagedResult.cs b/MinimalClean/src/MinimalClean.Architecture.Web/PagedResult.cs new file mode 100644 index 000000000..b62ad22de --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/PagedResult.cs @@ -0,0 +1,8 @@ +namespace MinimalClean.Architecture.Web; + +public record PagedResult( + IReadOnlyList Items, + int Page, + int PerPage, + int TotalCount, + int TotalPages); diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/Create/CreateEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/Create/CreateEndpoint.cs new file mode 100644 index 000000000..05f735c60 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/Create/CreateEndpoint.cs @@ -0,0 +1,38 @@ +using FastEndpoints; +using FluentValidation; +using MinimalClean.Architecture.Web.Infrastructure.Data; +using MinimalClean.Architecture.Web.Products; + +namespace MinimalClean.Architecture.Web.ProductFeatures.List; + +public sealed class CreateProductRequest +{ + public string Name { get; init; } = string.Empty; + public decimal UnitPrice { get; init; } +} + +// TODO: Add DbContext injection +// AppDbContext dbContext +public class CreateEndpoint() : + Endpoint +{ + public override void Configure() + { + Post("/Products"); + AllowAnonymous(); + + Tags("Products"); + + Description(builder => builder + .Accepts() + .Produces(200, "application/json") + .ProducesProblem(400)); + } + + public override async Task HandleAsync(CreateProductRequest request, + CancellationToken cancellationToken) + { + // TODO: Use DbContext to create product + } + +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetByIdEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetByIdEndpoint.cs new file mode 100644 index 000000000..8a0197bcb --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetByIdEndpoint.cs @@ -0,0 +1,75 @@ +using FastEndpoints; +using FluentValidation; +using Microsoft.AspNetCore.Http.HttpResults; +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using MinimalClean.Architecture.Web.Extensions; +using MinimalClean.Architecture.Web.Products; + +namespace MinimalClean.Architecture.Web.ProductFeatures.GetById; + +public sealed class GetProductByIdRequest +{ + public const string Route = "/Products/{ProductId}"; + public int ProductId { get; init; } +} + +public class GetByIdEndpoint(IMediator mediator) + : Endpoint, + NotFound, + ProblemHttpResult>, + GetProductByIdMapper> +{ + public override void Configure() + { + Get(GetProductByIdRequest.Route); + AllowAnonymous(); + + Summary(s => + { + s.Summary = "Get a product by ID"; + s.Description = "Retrieves a specific product by its unique identifier. Returns detailed product information including ID, name, and unit price."; + s.ExampleRequest = new GetProductByIdRequest { ProductId = 1 }; + s.ResponseExamples[200] = new ProductRecord(1, "Laptop", 999.99m); + + // Document possible responses + s.Responses[200] = "Product found and returned successfully"; + s.Responses[404] = "Product with specified ID not found"; + }); + + // Add tags for API grouping + Tags("Products"); + + // Add additional metadata + Description(builder => builder + .Accepts() + .Produces(200, "application/json") + .ProducesProblem(404)); + } + + public override async Task, NotFound, ProblemHttpResult>> + ExecuteAsync(GetProductByIdRequest request, CancellationToken ct) + { + var result = await mediator.Send(new GetProductQuery(ProductId.From(request.ProductId)), ct); + + return result.ToGetByIdResult(Map.FromEntity); + } +} + +public sealed class GetProductByIdValidator : Validator +{ + public GetProductByIdValidator() + { + RuleFor(x => x.ProductId) + .GreaterThan(0) + .WithMessage("Product ID must be greater than 0"); + } +} + + +public sealed class GetProductByIdMapper + : Mapper +{ + public override ProductRecord FromEntity(ProductDto e) + => new(e.Id.Value, e.Name, e.UnitPrice); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetProductHandler.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetProductHandler.cs new file mode 100644 index 000000000..f48501926 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/GetById/GetProductHandler.cs @@ -0,0 +1,19 @@ +using MinimalClean.Architecture.Web.Domain.ProductAggregate; +using MinimalClean.Architecture.Web.Domain.ProductAggregate.Specifications; + +namespace MinimalClean.Architecture.Web.ProductFeatures.GetById; + +public record GetProductQuery(ProductId ProductId) : IQuery>; + +public class GetProductHandler(IReadRepository _repository) + : IQueryHandler> +{ + public async ValueTask> Handle(GetProductQuery request, CancellationToken cancellationToken) + { + var spec = new ProductByIdSpec(request.ProductId); + var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); + if (entity == null) return Result.NotFound(); + + return new ProductDto(entity.Id, entity.Name, entity.UnitPrice); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/IListProductsQueryService.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/IListProductsQueryService.cs new file mode 100644 index 000000000..d33eccb1e --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/IListProductsQueryService.cs @@ -0,0 +1,10 @@ +namespace MinimalClean.Architecture.Web.ProductFeatures.List; + +/// +/// Represents a service that will actually fetch the necessary data +/// Typically implemented in Infrastructure +/// +public interface IListProductsQueryService +{ + Task> ListAsync(int page, int perPage); +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListEndpoint.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListEndpoint.cs new file mode 100644 index 000000000..3a9abaf47 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListEndpoint.cs @@ -0,0 +1,125 @@ +using FastEndpoints; +using FluentValidation; +using MinimalClean.Architecture.Web.Products; + +namespace MinimalClean.Architecture.Web.ProductFeatures.List; + +public sealed class ListProductsRequest +{ + [BindFrom("page")] + public int Page { get; init; } = 1; + + [BindFrom("per_page")] + public int PerPage { get; init; } = Constants.DEFAULT_PAGE_SIZE; +} + +public record ProductListResponse : PagedResult +{ + public ProductListResponse(IReadOnlyList Items, int Page, int PerPage, int TotalCount, int TotalPages) + : base(Items, Page, PerPage, TotalCount, TotalPages) + { + } +} + + +public class ListEndpoint(IMediator mediator) : Endpoint +{ + private readonly IMediator _mediator = mediator; + + public override void Configure() + { + Get("/Products"); + AllowAnonymous(); + + Summary(s => + { + s.Summary = "List products with pagination"; + s.Description = "Retrieves a paginated list of all products. Supports GitHub-style pagination with 1-based page indexing and configurable page size."; + s.ExampleRequest = new ListProductsRequest { Page = 1, PerPage = 10 }; + s.ResponseExamples[200] = new ProductListResponse( + new List + { + new(1, "Sample Product 1", 9.99m), + new(2, "Sample Product 2", 19.99m) + }, + 1, 10, 2, 1); + + s.Params["page"] = "1-based page index (default 1)"; + s.Params["per_page"] = $"Page size 1–{Constants.MAX_PAGE_SIZE} (default {Constants.DEFAULT_PAGE_SIZE})"; + + s.Responses[200] = "Paginated list of products returned successfully"; + s.Responses[400] = "Invalid pagination parameters"; + }); + + Tags("Products"); + + Description(builder => builder + .Accepts() + .Produces(200, "application/json") + .ProducesProblem(400)); + } + + public override async Task HandleAsync(ListProductsRequest request, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new ListProductsQuery(request.Page, request.PerPage)); + if (!result.IsSuccess) + { + await Send.ErrorsAsync(statusCode: 400, cancellationToken); + return; + } + + var pagedResult = result.Value; + AddLinkHeader(pagedResult.Page, pagedResult.PerPage, pagedResult.TotalPages); + + 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) + { + 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 ListProductsValidator : Validator +{ + public ListProductsValidator() + { + RuleFor(x => x.Page) + .GreaterThanOrEqualTo(1) + .WithMessage("page must be >= 1"); + + RuleFor(x => x.PerPage) + .InclusiveBetween(1, Constants.MAX_PAGE_SIZE) + .WithMessage($"per_page must be between 1 and {Constants.MAX_PAGE_SIZE}"); + } +} + +public sealed class ListProductsMapper + : Mapper> +{ + public override ProductListResponse FromEntity(PagedResult e) + { + var items = e.Items + .Select(p => new ProductRecord(p.Id.Value, p.Name, p.UnitPrice)) + .ToList(); + + return new ProductListResponse(items, e.Page, e.PerPage, e.TotalCount, e.TotalPages); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListProductsHandler.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListProductsHandler.cs new file mode 100644 index 000000000..eab202260 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/List/ListProductsHandler.cs @@ -0,0 +1,24 @@ +namespace MinimalClean.Architecture.Web.ProductFeatures.List; + +public record ListProductsQuery(int? Page = 1, + int? PerPage = Constants.DEFAULT_PAGE_SIZE) + : IQuery>>; + +public class ListProductsHandler : IQueryHandler>> +{ + private readonly IListProductsQueryService _query; + + public ListProductsHandler(IListProductsQueryService query) + { + _query = query; + } + + public async ValueTask>> Handle(ListProductsQuery request, + CancellationToken cancellationToken) + { + + var result = await _query.ListAsync(request.Page ?? 1, request.PerPage ?? Constants.DEFAULT_PAGE_SIZE); + + return Result.Success(result); + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductDto.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductDto.cs new file mode 100644 index 000000000..9ad05e68b --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductDto.cs @@ -0,0 +1,4 @@ +using MinimalClean.Architecture.Web.Domain.ProductAggregate; + +namespace MinimalClean.Architecture.Web.ProductFeatures; +public record ProductDto(ProductId Id, string Name, decimal UnitPrice); diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductRecord.cs b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductRecord.cs new file mode 100644 index 000000000..a22155250 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/ProductFeatures/ProductRecord.cs @@ -0,0 +1,3 @@ +namespace MinimalClean.Architecture.Web.Products; + +public record ProductRecord(int Id, string Name, decimal UnitPrice); diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Program.cs b/MinimalClean/src/MinimalClean.Architecture.Web/Program.cs new file mode 100644 index 000000000..1d64b4f53 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Program.cs @@ -0,0 +1,34 @@ +using FastEndpoints; +using FastEndpoints.Swagger; +using MinimalClean.Architecture.Web.Configurations; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults() // This sets up OpenTelemetry logging + .AddLoggerConfigs(); // This adds Serilog for console formatting + +using var loggerFactory = LoggerFactory.Create(config => config.AddConsole()); +var startupLogger = loggerFactory.CreateLogger(); + +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; + }); + +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 +public partial class Program { } diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/Properties/launchSettings.json b/MinimalClean/src/MinimalClean.Architecture.Web/Properties/launchSettings.json new file mode 100644 index 000000000..4139f32be --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchUrl": "scalar", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57379/" + } + } +} \ No newline at end of file diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/api.http b/MinimalClean/src/MinimalClean.Architecture.Web/api.http new file mode 100644 index 000000000..572c19b63 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/api.http @@ -0,0 +1,61 @@ +@OrderDemo.Api_HostAddress = https://localhost:57379 + +@OrderDemo.CartId = 68fe4cfd-84bb-4b8a-bb4e-528c26325c72 +@OrderDemo.OrderId = ec5a03fe-fc8c-4659-9fc7-f46351e9bf6f + +### List Products +GET {{OrderDemo.Api_HostAddress}}/products +Accept: application/json + +### Get Product By ID +GET {{OrderDemo.Api_HostAddress}}/products/1 +Accept: application/json + +### Add to Cart (Anonymous User - creates cart) +POST {{OrderDemo.Api_HostAddress}}/cart +Content-Type: application/json + +{ + "productId": 1, + "quantity": 2 +} + +### Add to Cart (Anonymous User - existing cart) +POST {{OrderDemo.Api_HostAddress}}/cart +Content-Type: application/json + +{ + "cartId": "{{OrderDemo.CartId}}", + "productId": 2, + "quantity": 3 +} + +### View Cart +GET {{OrderDemo.Api_HostAddress}}/cart/{{OrderDemo.CartId}} +Accept: application/json + +### Checkout +POST {{OrderDemo.Api_HostAddress}}/cart/{{OrderDemo.CartId}}/checkout +Content-Type: application/json + +{ + "email": "guest@example.com" +} +### get order id from response and set it to {{OrderDemo.OrderId}} + +### Confirm Purchase +POST {{OrderDemo.Api_HostAddress}}/confirm-purchase +Content-Type: application/json + +{ + "orderId": "{{OrderDemo.OrderId}}", + "paymentIntent": "323r23r2jfsiidjf" +} + +### List Orders +GET {{OrderDemo.Api_HostAddress}}/orders?emailAddress=guest@example.com +Accept: application/json + + + + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.Development.json b/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.Development.json new file mode 100644 index 000000000..303f9278e --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "DatabaseOptions": { + "RecreateOnStartup": true + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Information" + } + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.json b/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.json new file mode 100644 index 000000000..6685c5886 --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/appsettings.json @@ -0,0 +1,30 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Server=(localdb)\\v11.0;Database=cleanarchitecture;Trusted_Connection=True;MultipleActiveResultSets=true", + "AppDb": "Server=(localdb)\\mssqllocaldb;Database=MinimalCleanArchitecture;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ] + }, + "Mailserver": { + "Server": "localhost", + "Port": 25 + } +} diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/config.nsdepcop b/MinimalClean/src/MinimalClean.Architecture.Web/config.nsdepcop new file mode 100644 index 000000000..6136aadde --- /dev/null +++ b/MinimalClean/src/MinimalClean.Architecture.Web/config.nsdepcop @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/design-time.db b/MinimalClean/src/MinimalClean.Architecture.Web/design-time.db new file mode 100644 index 000000000..edc4886cf Binary files /dev/null and b/MinimalClean/src/MinimalClean.Architecture.Web/design-time.db differ diff --git a/MinimalClean/src/MinimalClean.Architecture.Web/wwwroot/.gitkeep b/MinimalClean/src/MinimalClean.Architecture.Web/wwwroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/README.md b/README.md index c38541567..6cec7478b 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,20 @@ This architecture is used in the [DDD Fundamentals course](https://www.pluralsig ## Table Of Contents - [Clean Architecture](#clean-architecture) - - [Troubleshooting Chrome Errors](#troubleshooting-chrome-errors) + - [Take the Course!](#take-the-course) - [Table Of Contents](#table-of-contents) - [Give a Star! :star:](#give-a-star-star) + - [Sponsors](#sponsors) + - [Troubleshooting Chrome Errors](#troubleshooting-chrome-errors) - [Versions](#versions) - [Learn More](#learn-more) - [Getting Started](#getting-started) + - [Template Installation](#template-installation) - [Using the dotnet CLI template](#using-the-dotnet-cli-template) + - [Full Clean Architecture (`clean-arch`)](#full-clean-architecture-clean-arch) + - [Minimal Clean Architecture (`min-clean`)](#minimal-clean-architecture-min-clean) + - [Template Comparison](#template-comparison) + - [Which Template Should I Use?](#which-template-should-i-use) - [What about Controllers and Razor Pages?](#what-about-controllers-and-razor-pages) - [Add Ardalis.ApiEndpoints](#add-ardalisapiendpoints) - [Add Controllers](#add-controllers) @@ -52,11 +59,12 @@ This architecture is used in the [DDD Fundamentals course](https://www.pluralsig - [The Use Cases Project](#the-use-cases-project) - [The Infrastructure Project](#the-infrastructure-project) - [The Web Project](#the-web-project) - - [The SharedKernel Project](#the-sharedkernel-project) + - [The SharedKernel Package](#the-sharedkernel-package) - [The Test Projects](#the-test-projects) - [Patterns Used](#patterns-used) - [Domain Events](#domain-events) - [Related Projects](#related-projects) + - [Presentations and Videos on Clean Architecture](#presentations-and-videos-on-clean-architecture) ## Give a Star! :star: @@ -84,13 +92,29 @@ The main branch is now using **.NET 9**. This corresponds with NuGet package ver # Getting Started -To use this template, there are a few options: +To use this template, there are **two template options**: + +1. **Full Clean Architecture** (`clean-arch`) - Complete multi-project solution with Core, UseCases, Infrastructure, and Web +2. **Minimal Clean Architecture** (`min-clean`) - Simplified single-project vertical slice architecture + +Choose based on your project's complexity and team preferences. See [Template Comparison](#template-comparison) below. -- Install using `dotnet new` (recommended) -- Download this Repository (and modify as needed) +## Template Installation + +Install the templates from NuGet: + +```powershell +# Full Clean Architecture template +dotnet new install Ardalis.CleanArchitecture.Template + +# Minimal Clean Architecture template +dotnet new install Ardalis.MinimalClean.Template +``` ## Using the dotnet CLI template +### Full Clean Architecture (`clean-arch`) + First, install the template from [NuGet (https://www.nuget.org/packages/Ardalis.CleanArchitecture.Template/)](https://www.nuget.org/packages/Ardalis.CleanArchitecture.Template/): ```powershell @@ -131,7 +155,7 @@ Navigate to the parent directory in which you'd like the solution's folder to be Run this command to create the solution structure in a subfolder name `Your.ProjectName`: -``` +```powershell dotnet new clean-arch -o Your.ProjectName ``` @@ -142,11 +166,67 @@ Example: Thanks [@dahlsailrunner](https://github.com/dahlsailrunner) for your help getting this working! -**Known Issues**: +**Known Issues**: - Don't include hyphens in the name. See [#201](https://github.com/ardalis/CleanArchitecture/issues/201). - Don't use 'Ardalis' as your namespace (conflicts with dependencies). +### Minimal Clean Architecture (`min-clean`) + +For a simpler, single-project vertical slice architecture, use the Minimal Clean Architecture template: + +```powershell +dotnet new install Ardalis.MinimalClean.Template +dotnet new min-clean -o Your.ProjectName +``` + +This template provides: + +- **Single Web project** with all code organized by vertical slices (features) +- **Simplified architecture** - no separate Core, UseCases, or Infrastructure projects +- **Domain-Driven Design patterns** - entities, aggregates, but pragmatic +- **FastEndpoints** for clean API endpoints +- **Entity Framework Core** with migrations +- **Aspire** support for cloud-ready development (optional) + +Perfect for MVPs, smaller applications, or teams that want architectural guidance without multi-project complexity. + +## Template Comparison + +| Feature | Full Clean Architecture | Minimal Clean Architecture | +|---------|------------------------|----------------------------| +| **Command** | `clean-arch` | `min-clean` | +| **Projects** | 4+ (Core, UseCases, Infrastructure, Web) | 1 (Web only) | +| **Organization** | By layer (horizontal) | By feature (vertical slices) | +| **DDD Patterns** | Extensive (Aggregates, Value Objects, Domain Events, Specifications) | Pragmatic (simplified domain model) | +| **Repository Pattern** | Yes, with Specifications | Optional (direct DbContext or simple repos) | +| **MediatR/CQRS** | Yes (separate UseCases project) | Optional (can be in endpoints) | +| **Complexity** | Higher - more abstractions | Lower - simpler structure | +| **Best For** | Large enterprise apps, long-term maintenance | MVPs, smaller apps, rapid iteration | +| **Team Size** | Multiple teams, strict boundaries | Small teams, collaborative | +| **Learning Curve** | Steeper - more patterns to learn | Gentler - focused essentials | +| **Migration Path** | Can simplify to minimal | Can grow into full template | + +### Which Template Should I Use? + +**Choose Full Clean Architecture if:** + +- Building large, complex enterprise applications +- Multiple teams working on different layers +- Long-term maintenance and evolution expected +- Need strict separation of concerns and testability +- Domain complexity requires extensive DDD patterns + +**Choose Minimal Clean Architecture if:** + +- Building MVPs or smaller applications +- Want architectural guidance without project overhead +- Prefer vertical slice architecture +- Team values simplicity and fast iteration +- May grow into full Clean Architecture later + +**Not sure?** Start with Minimal Clean and migrate to Full Clean Architecture if your application grows in complexity. + ## What about Controllers and Razor Pages? As of version 9, this solution template only includes support for API Endpoints using the FastEndpoints library. If you want to use my ApiEndpoints library, Razor Pages, and/or Controllers you can use the last template that included them, [version 7.1](https://www.nuget.org/packages/Ardalis.CleanArchitecture.Template/7.1.0). Alternately, they're easily added to this template after installation. diff --git a/docs/minimal-clean-architecture.md b/docs/minimal-clean-architecture.md new file mode 100644 index 000000000..aed787030 --- /dev/null +++ b/docs/minimal-clean-architecture.md @@ -0,0 +1,453 @@ +# Minimal Clean Architecture + +## Overview + +The **Minimal Clean Architecture** template provides a simplified, pragmatic approach to Clean Architecture for ASP.NET Core applications. It maintains the core principles of Clean Architecture—separation of concerns, dependency inversion, and testability—while reducing complexity through a single-project Vertical Slice Architecture (VSA). + +## Philosophy + +### Core Principles + +1. **Simplicity First**: Minimize unnecessary abstractions and project boundaries +2. **Vertical Slices**: Organize by feature rather than technical layer +3. **Pragmatic DDD**: Use domain patterns where they add value, not everywhere +4. **Progressive Enhancement**: Start simple, add complexity only when needed + +### Clean Architecture Principles Maintained + +- ✅ **Dependency Inversion**: Domain doesn't depend on infrastructure +- ✅ **Testability**: Business logic can be tested in isolation +- ✅ **Separation of Concerns**: Clear boundaries between domain, infrastructure, and presentation +- ✅ **Framework Independence**: Domain logic isn't coupled to ASP.NET Core + +### Simplifications from Full Template + +- **Single Project**: All code in one Web project instead of 4+ projects +- **Simplified DDD**: Essential patterns only (entities, aggregates) without extensive value objects, specifications +- **Optional CQRS**: MediatR is optional; logic can live in endpoints +- **Direct Data Access**: Can use DbContext directly or simple repositories instead of complex repository pattern +- **Vertical Organization**: Group by feature (Cart, Order, Product) instead of by layer + +## Architecture + +### Project Structure + +```text +MinimalClean.Architecture.Web/ +├── Domain/ # Domain Layer +│ ├── CartAggregate/ +│ │ ├── Cart.cs # Aggregate root +│ │ ├── CartItem.cs # Entity +│ │ └── Events/ # Domain events (optional) +│ ├── OrderAggregate/ +│ └── ProductAggregate/ +├── Infrastructure/ # Infrastructure Layer +│ ├── Data/ +│ │ ├── AppDbContext.cs # EF Core DbContext +│ │ ├── Config/ # EF configurations +│ │ │ ├── CartConfiguration.cs +│ │ │ └── OrderConfiguration.cs +│ │ └── Migrations/ # EF migrations +│ ├── Email/ # External services +│ └── Services/ # Infrastructure services +├── Endpoints/ # Presentation Layer +│ ├── Cart/ +│ │ ├── Create.cs # Create cart endpoint +│ │ ├── AddItem.cs # Add item to cart +│ │ └── List.cs # List carts +│ ├── Order/ +│ └── Product/ +└── Program.cs # Application startup +``` + +### Vertical Slice Organization + +Each feature (Cart, Order, Product) contains: + +- **Domain**: Entities and business logic +- **Infrastructure**: Data configurations for that feature +- **Endpoints**: API endpoints for that feature + +This keeps related code together, making it easier to understand and modify features independently. + +### Dependency Flow + +```text +Endpoints ──→ Domain + ↓ +Infrastructure ──→ Domain +``` + +- Endpoints can use Domain and Infrastructure +- Infrastructure depends on Domain (for entity configurations) +- Domain has no dependencies on other layers + +## Design Decisions + +### ADR-001: Single Project Architecture + +**Status**: Accepted + +**Context**: Need to balance architectural guidance with simplicity for smaller applications. + +**Decision**: Use a single Web project with clear folder structure instead of multiple projects. + +**Consequences**: +- ✅ Simpler to navigate and understand +- ✅ Faster builds (no project-to-project references) +- ✅ Easier to refactor (no project boundary concerns) +- ✅ Lower initial complexity +- ⚠️ Developers must respect folder boundaries (not enforced by compiler) +- ⚠️ Harder to enforce strict layer separation + +**Migration Path**: Can be extracted into multiple projects later if needed. + +### ADR-002: Vertical Slice Organization + +**Status**: Accepted + +**Context**: Need to organize code in a way that's easy to understand and modify. + +**Decision**: Organize by feature (vertical slices) rather than by layer (horizontal). + +**Consequences**: +- ✅ Related code is colocated (easier to find) +- ✅ Features can be modified independently +- ✅ Natural fit for microservices extraction +- ✅ Aligns with business capabilities +- ⚠️ May have some code duplication across features +- ⚠️ Shared concerns need careful consideration + +### ADR-003: Pragmatic DDD + +**Status**: Accepted + +**Context**: Full DDD patterns can be overkill for simpler domains. + +**Decision**: Use essential DDD patterns (entities, aggregates) but keep it simple. + +**Patterns Included**: +- ✅ Entities with encapsulation +- ✅ Aggregate roots +- ✅ Domain events (optional) + +**Patterns Simplified/Optional**: +- ⚠️ Value Objects (use when valuable, not everywhere) +- ⚠️ Specifications (use LINQ, add if needed) +- ⚠️ Domain Services (add only when needed) + +**Consequences**: +- ✅ Easier to learn and apply +- ✅ Less boilerplate code +- ✅ Faster development +- ⚠️ May need to add patterns as domain complexity grows + +### ADR-004: Optional MediatR/CQRS + +**Status**: Accepted + +**Context**: CQRS with MediatR adds valuable patterns but also complexity. + +**Decision**: Make MediatR optional; allow business logic in endpoints for simple cases. + +**Usage Guidelines**: +- Simple CRUD: Put logic directly in endpoints +- Complex workflows: Use MediatR commands/queries +- Cross-cutting concerns: Use MediatR pipeline behaviors + +**Consequences**: +- ✅ Lower initial complexity +- ✅ Developers choose appropriate level of abstraction +- ⚠️ Inconsistent patterns across codebase possible +- ⚠️ Need clear team guidelines on when to use MediatR + +### ADR-005: Direct DbContext Access + +**Status**: Accepted + +**Context**: Repository pattern with specifications adds abstraction but also complexity. + +**Decision**: Allow direct DbContext usage; add repositories only when beneficial. + +**Usage Guidelines**: +- Simple queries: Use DbContext directly in endpoints +- Complex queries: Extract to query methods or repositories +- Repeated queries: Create reusable query extensions + +**Consequences**: +- ✅ Less boilerplate code +- ✅ Direct access to EF Core features +- ✅ Simpler for common scenarios +- ⚠️ Easier to leak infrastructure concerns +- ⚠️ May need repositories for complex scenarios + +### ADR-006: FastEndpoints for APIs + +**Status**: Accepted + +**Context**: Need clean, testable API endpoints. + +**Decision**: Use FastEndpoints with REPR pattern. + +**Consequences**: +- ✅ One file per endpoint (easy to find) +- ✅ Built-in validation support +- ✅ Clear request/response types +- ✅ Testable without HTTP layer +- ⚠️ Different from standard ASP.NET Core patterns +- ⚠️ Learning curve for team + +## When to Use This Template + +### Ideal Scenarios + +1. **MVPs and Prototypes** + - Need to validate ideas quickly + - Want architectural guidance without overhead + - May grow into larger application + +2. **Small to Medium Applications** + - 5-50 endpoints + - 5-20 domain entities + - 1-5 developers + - Simple to moderate domain complexity + +3. **Learning Clean Architecture** + - Want to understand principles without complexity + - Stepping stone to full Clean Architecture + - Teaching tool for teams + +4. **Vertical Slice Architecture Preference** + - Team prefers feature-based organization + - Planning to extract microservices later + - Value cohesion over layer separation + +### Not Recommended For + +1. **Large Enterprise Applications** + - Complex domain requiring extensive DDD patterns + - Multiple teams needing strict boundaries + - Long-term evolution expected + - → Use Full Clean Architecture instead + +2. **Microservices from Start** + - If you know you'll split into services + - → Consider separate services with minimal template each + +3. **Regulatory/Compliance Heavy** + - Strict audit requirements + - Need enforced layer boundaries + - → Use Full Clean Architecture instead + +## Migration Paths + +### From Minimal to Full Clean Architecture + +As your application grows, you can migrate to the full template: + +#### Step 1: Extract Core Project + +```powershell +# Create new Core project +dotnet new classlib -n YourProject.Core + +# Move domain entities +mv Domain/* ../YourProject.Core/ + +# Update namespaces +# Update project references +``` + +#### Step 2: Extract Infrastructure Project + +```powershell +# Create Infrastructure project +dotnet new classlib -n YourProject.Infrastructure + +# Move infrastructure code +mv Infrastructure/* ../YourProject.Infrastructure/ + +# Add reference to Core +dotnet add YourProject.Infrastructure reference YourProject.Core +``` + +#### Step 3: Extract UseCases (Optional) + +```powershell +# Create UseCases project +dotnet new classlib -n YourProject.UseCases + +# Move business logic from endpoints to use cases +# Add MediatR +# Create command/query handlers +``` + +#### Step 4: Clean Up Web Project + +- Update project references +- Keep only endpoints and startup code +- Reference UseCases or Infrastructure as needed + +### From Full Clean Architecture to Minimal + +If you find the full template too complex: + +#### Step 1: Merge Projects + +```powershell +# Copy all code into Web project +# Organize by vertical slices +``` + +#### Step 2: Simplify Patterns + +- Replace Specifications with LINQ +- Simplify Value Objects to primitives where beneficial +- Remove unnecessary abstractions + +#### Step 3: Organize Vertically + +- Group by feature instead of layer +- Colocate related code + +## Best Practices + +### Domain Layer + +```csharp +// Good: Encapsulated entity +public class Cart +{ + private readonly List _items = new(); + public IReadOnlyCollection Items => _items.AsReadOnly(); + + public void AddItem(Product product, int quantity) + { + // Business logic here + var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id); + if (existingItem != null) + { + existingItem.IncreaseQuantity(quantity); + } + else + { + _items.Add(new CartItem(product, quantity)); + } + } +} + +// Avoid: Anemic domain model +public class Cart +{ + public List Items { get; set; } = new(); +} +``` + +### Endpoint Layer + +```csharp +// Good: Clear, focused endpoint +public class CreateCart : EndpointWithoutRequest +{ + private readonly AppDbContext _db; + + public CreateCart(AppDbContext db) => _db = db; + + public override void Configure() + { + Post("/carts"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var cart = new Cart(guestUserId: Guid.NewGuid()); + _db.Carts.Add(cart); + await _db.SaveChangesAsync(ct); + + await SendAsync(new CartResponse(cart.Id, cart.Items.Count), cancellation: ct); + } +} +``` + +### Infrastructure Layer + +```csharp +// Good: Focused EF configuration +public class CartConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.HasMany(c => c.Items) + .WithOne() + .HasForeignKey("CartId"); + } +} +``` + +## Testing Strategy + +### Unit Tests + +Focus on domain logic: + +```csharp +public class CartTests +{ + [Fact] + public void AddItem_NewProduct_AddsToCart() + { + // Arrange + var cart = new Cart(guestUserId: Guid.NewGuid()); + var product = new Product("Test", 10m); + + // Act + cart.AddItem(product, 2); + + // Assert + Assert.Single(cart.Items); + Assert.Equal(2, cart.Items.First().Quantity); + } +} +``` + +### Functional Tests + +Test endpoints end-to-end: + +```csharp +public class CartEndpointsTests : IClassFixture> +{ + [Fact] + public async Task CreateCart_ReturnsNewCart() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.PostAsync("/carts", null); + + // Assert + response.EnsureSuccessStatusCode(); + var cart = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(cart); + } +} +``` + +## Resources + +- [Main Clean Architecture Template](https://github.com/ardalis/CleanArchitecture) +- [Vertical Slice Architecture](https://jimmybogard.com/vertical-slice-architecture/) +- [FastEndpoints Documentation](https://fast-endpoints.com/) +- [Domain-Driven Design Fundamentals](https://www.pluralsight.com/courses/fundamentals-domain-driven-design) +- [Clean Architecture Course](https://academy.nimblepros.com/p/intro-to-clean-architecture) + +## Contributing + +Contributions are welcome! Please see the main [Contributing Guide](../CONTRIBUTING.md). + +## License + +MIT - see [LICENSE](../LICENSE) diff --git a/src/Clean.Architecture.AspireHost/Program.cs b/src/Clean.Architecture.AspireHost/AppHost.cs similarity index 100% rename from src/Clean.Architecture.AspireHost/Program.cs rename to src/Clean.Architecture.AspireHost/AppHost.cs diff --git a/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj b/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj index 4585016fc..86f5a3421 100644 --- a/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj +++ b/src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj @@ -1,6 +1,4 @@ - - - + Exe diff --git a/src/Clean.Architecture.ServiceDefaults/Extensions.cs b/src/Clean.Architecture.ServiceDefaults/Extensions.cs index 150b65ace..8ffe5aed0 100644 --- a/src/Clean.Architecture.ServiceDefaults/Extensions.cs +++ b/src/Clean.Architecture.ServiceDefaults/Extensions.cs @@ -15,6 +15,9 @@ namespace Microsoft.Extensions.Hosting; // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -58,7 +61,13 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w }) .WithTracing(tracing => { - tracing.AddAspNetCoreInstrumentation() + 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(); @@ -103,14 +112,15 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) // 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(HealthEndpointPath); + // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, 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; }