├── .editorconfig ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build-ramdisk.yml │ ├── codeql-analysis.yml │ ├── dotnetcore.yml │ └── publish.yml ├── .gitignore ├── .template.config └── template.json ├── .vscode ├── launch.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Clean.Architecture.sln ├── Clean.Architecture.slnx ├── CleanArchitecture.nuspec ├── Directory.Build.props ├── Directory.Packages.props ├── LICENSE ├── README.md ├── docs └── architecture-decisions │ ├── README.md │ └── adr-001-dotnet-di-adoption.md ├── global.json ├── icon.png ├── nuget.config ├── sample ├── .editorconfig ├── Directory.Build.props ├── Directory.Packages.props ├── NimblePros.SampleToDo.sln ├── src │ ├── NimblePros.SampleToDo.AspireHost │ │ ├── NimblePros.SampleToDo.AspireHost.csproj │ │ ├── Program.cs │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ ├── NimblePros.SampleToDo.Core │ │ ├── ContributorAggregate │ │ │ ├── Contributor.cs │ │ │ ├── ContributorName.cs │ │ │ ├── Events │ │ │ │ ├── ContributorDeletedEvent.cs │ │ │ │ └── ContributorNameUpdatedEvent.cs │ │ │ ├── Handlers │ │ │ │ ├── ContributorDeletedHandler.cs │ │ │ │ └── ContributorNameUpdatedEventLoggingHandler.cs │ │ │ └── Specifications │ │ │ │ └── ContributorByIdSpec.cs │ │ ├── CoreServiceExtensions.cs │ │ ├── GlobalUsings.cs │ │ ├── Interfaces │ │ │ ├── IDeleteContributorService.cs │ │ │ ├── IEmailSender.cs │ │ │ └── IToDoItemSearchService.cs │ │ ├── NimblePros.SampleToDo.Core.csproj │ │ ├── ProjectAggregate │ │ │ ├── Events │ │ │ │ ├── ContributorAddedToItemEvent.cs │ │ │ │ ├── NewItemAddedEvent.cs │ │ │ │ └── ToDoItemCompletedEvent.cs │ │ │ ├── Handlers │ │ │ │ └── ItemCompletedEmailNotificationHandler.cs │ │ │ ├── Priority.cs │ │ │ ├── Project.cs │ │ │ ├── ProjectId.cs │ │ │ ├── ProjectName.cs │ │ │ ├── ProjectStatus.cs │ │ │ ├── Specifications │ │ │ │ ├── IncompleteItemsSearchSpec.cs │ │ │ │ ├── IncompleteItemsSpec.cs │ │ │ │ ├── ProjectByIdWithItemsSpec.cs │ │ │ │ └── ProjectsWithItemsByContributorId.cs │ │ │ ├── ToDoItem.cs │ │ │ └── ToDoItemId.cs │ │ └── Services │ │ │ ├── DeleteContributorService.cs │ │ │ └── ToDoItemSearchService.cs │ ├── NimblePros.SampleToDo.Infrastructure │ │ ├── Data │ │ │ ├── AppDbContext.cs │ │ │ ├── Config │ │ │ │ ├── ContributorConfiguration.cs │ │ │ │ ├── DataSchemaConstants.cs │ │ │ │ ├── ProjectConfiguration.cs │ │ │ │ ├── ToDoItemConfiguration.cs │ │ │ │ ├── VogenEfCoreConverters.cs │ │ │ │ └── VogenIdValueGenerator.cs │ │ │ ├── EfRepository.cs │ │ │ └── Queries │ │ │ │ ├── FakeListContributorsQueryService.cs │ │ │ │ ├── FakeListIncompleteItemsQueryService.cs │ │ │ │ ├── FakeListProjectsShallowQueryService.cs │ │ │ │ ├── ListContributorsQueryService.cs │ │ │ │ ├── ListIncompleteItemsQueryService.cs │ │ │ │ └── ListProjectsShallowQueryService.cs │ │ ├── Email │ │ │ ├── FakeEmailSender.cs │ │ │ ├── MailserverConfiguration.cs │ │ │ ├── MimeKitEmailSender.cs │ │ │ └── SmtpEmailSender.cs │ │ ├── GlobalUsings.cs │ │ ├── InfrastructureServiceExtensions.cs │ │ └── NimblePros.SampleToDo.Infrastructure.csproj │ ├── NimblePros.SampleToDo.ServiceDefaults │ │ ├── Extensions.cs │ │ └── NimblePros.SampleToDo.ServiceDefaults.csproj │ ├── NimblePros.SampleToDo.UseCases │ │ ├── Contributors │ │ │ ├── Commands │ │ │ │ ├── Create │ │ │ │ │ ├── CreateContributorCommand.cs │ │ │ │ │ └── CreateContributorHandler.cs │ │ │ │ ├── Delete │ │ │ │ │ ├── DeleteContributorCommand.cs │ │ │ │ │ └── DeleteContributorHandler.cs │ │ │ │ └── Update │ │ │ │ │ ├── UpdateContributorCommand.cs │ │ │ │ │ └── UpdateContributorHandler.cs │ │ │ ├── ContributorDTO.cs │ │ │ └── Queries │ │ │ │ ├── Get │ │ │ │ ├── GetContributorHandler.cs │ │ │ │ └── GetContributorQuery.cs │ │ │ │ └── List │ │ │ │ ├── IListContributorsQueryService.cs │ │ │ │ ├── ListContributorsHandler.cs │ │ │ │ └── ListContributorsQuery.cs │ │ ├── GlobalUsings.cs │ │ ├── NimblePros.SampleToDo.UseCases.csproj │ │ ├── Projects │ │ │ ├── AddToDoItem │ │ │ │ ├── AddToDoItemCommand.cs │ │ │ │ └── AddToDoItemHandler.cs │ │ │ ├── Create │ │ │ │ ├── CreateProjectCommand.cs │ │ │ │ └── CreateProjectHandler.cs │ │ │ ├── Delete │ │ │ │ ├── DeleteProjectCommand.cs │ │ │ │ └── DeleteProjectHandler.cs │ │ │ ├── GetWithAllItems │ │ │ │ ├── GetProjectWithAllItemsHandler.cs │ │ │ │ └── GetProjectWithAllItemsQuery.cs │ │ │ ├── ListIncompleteItems │ │ │ │ ├── IListIncompleteItemsQueryService.cs │ │ │ │ ├── ListIncompleteItemsByProjectHandler.cs │ │ │ │ └── ListIncompleteItemsByProjectQuery.cs │ │ │ ├── ListShallow │ │ │ │ ├── IListProjectsShallowQueryService.cs │ │ │ │ ├── ListProjectsShallowHandler.cs │ │ │ │ └── ListProjectsShallowQuery.cs │ │ │ ├── MarkToDoItemComplete │ │ │ │ ├── MarkToDoItemCompleteCommand.cs │ │ │ │ └── MarkToDoItemCompleteHandler.cs │ │ │ ├── ProjectDTO.cs │ │ │ ├── ProjectWithAllItemsDTO.cs │ │ │ ├── ToDoItemDTO.cs │ │ │ └── Update │ │ │ │ ├── UpdateProjectCommand.cs │ │ │ │ └── UpdateProjectHandler.cs │ │ └── README.md │ └── NimblePros.SampleToDo.Web │ │ ├── Configurations │ │ ├── LoggerConfig.cs │ │ ├── MediatrConfig.cs │ │ ├── MiddlewareConfig.cs │ │ ├── OptionConfigs.cs │ │ └── ServiceConfigs.cs │ │ ├── Contributors │ │ ├── ContributorRecord.cs │ │ ├── Create.CreateContributorRequest.cs │ │ ├── Create.CreateContributorResponse.cs │ │ ├── Create.CreateContributorValidator.cs │ │ ├── Create.cs │ │ ├── Delete.DeleteContributorRequest.cs │ │ ├── Delete.DeleteContributorValidator.cs │ │ ├── Delete.cs │ │ ├── GetById.GetContributorByIdRequest.cs │ │ ├── GetById.GetContributorValidator.cs │ │ ├── GetById.cs │ │ ├── List.ContributorListResponse.cs │ │ ├── List.cs │ │ ├── Update.UpdateContributorRequest.cs │ │ ├── Update.UpdateContributorResponse.cs │ │ ├── Update.UpdateContributorValidator.cs │ │ └── Update.cs │ │ ├── GlobalUsings.cs │ │ ├── NimblePros.SampleToDo.Web.csproj │ │ ├── Program.cs │ │ ├── Projects │ │ ├── Create.CreateProjectRequest.cs │ │ ├── Create.CreateProjectResponse.cs │ │ ├── Create.CreateProjectValidator.cs │ │ ├── Create.cs │ │ ├── CreateToDoItem.CreateToDoItemRequest.cs │ │ ├── CreateToDoItem.CreateToDoItemValidator.cs │ │ ├── CreateToDoItem.cs │ │ ├── Delete.DeleteProjectRequest.cs │ │ ├── Delete.cs │ │ ├── GetById.GetProjectByIdRequest.cs │ │ ├── GetById.GetProjectByIdResponse.cs │ │ ├── GetById.cs │ │ ├── List.ProjectListResponse.cs │ │ ├── List.cs │ │ ├── ListIncompleteItems.ListIncompleteItemsRequest.cs │ │ ├── ListIncompleteItems.ListIncompleteItemsResponse.cs │ │ ├── ListIncompleteItems.cs │ │ ├── MarkItemComplete.MarkItemCompleteRequest.cs │ │ ├── MarkItemComplete.cs │ │ ├── ProjectRecord.cs │ │ ├── ToDoItemRecord.cs │ │ ├── Update.UpdateProjectRequest.cs │ │ ├── Update.UpdateProjectRequestValidator.cs │ │ ├── Update.UpdateProjectResponse.cs │ │ └── Update.cs │ │ ├── Properties │ │ └── launchSettings.json │ │ ├── SeedData.cs │ │ ├── api.http │ │ ├── appsettings.json │ │ └── wwwroot │ │ ├── css │ │ └── site.css │ │ ├── favicon.ico │ │ ├── js │ │ └── site.js │ │ └── lib │ │ ├── bootstrap │ │ ├── LICENSE │ │ └── dist │ │ │ ├── css │ │ │ ├── bootstrap-grid.css │ │ │ ├── bootstrap-grid.css.map │ │ │ ├── bootstrap-grid.min.css │ │ │ ├── bootstrap-grid.min.css.map │ │ │ ├── bootstrap-grid.rtl.css │ │ │ ├── bootstrap-grid.rtl.css.map │ │ │ ├── bootstrap-grid.rtl.min.css │ │ │ ├── bootstrap-grid.rtl.min.css.map │ │ │ ├── bootstrap-reboot.css │ │ │ ├── bootstrap-reboot.css.map │ │ │ ├── bootstrap-reboot.min.css │ │ │ ├── bootstrap-reboot.min.css.map │ │ │ ├── bootstrap-reboot.rtl.css │ │ │ ├── bootstrap-reboot.rtl.css.map │ │ │ ├── bootstrap-reboot.rtl.min.css │ │ │ ├── bootstrap-reboot.rtl.min.css.map │ │ │ ├── bootstrap-utilities.css │ │ │ ├── bootstrap-utilities.css.map │ │ │ ├── bootstrap-utilities.min.css │ │ │ ├── bootstrap-utilities.min.css.map │ │ │ ├── bootstrap-utilities.rtl.css │ │ │ ├── bootstrap-utilities.rtl.css.map │ │ │ ├── bootstrap-utilities.rtl.min.css │ │ │ ├── bootstrap-utilities.rtl.min.css.map │ │ │ ├── bootstrap.css │ │ │ ├── bootstrap.css.map │ │ │ ├── bootstrap.min.css │ │ │ ├── bootstrap.min.css.map │ │ │ ├── bootstrap.rtl.css │ │ │ ├── bootstrap.rtl.css.map │ │ │ ├── bootstrap.rtl.min.css │ │ │ └── bootstrap.rtl.min.css.map │ │ │ └── js │ │ │ ├── bootstrap.bundle.js │ │ │ ├── bootstrap.bundle.js.map │ │ │ ├── bootstrap.bundle.min.js │ │ │ ├── bootstrap.bundle.min.js.map │ │ │ ├── bootstrap.esm.js │ │ │ ├── bootstrap.esm.js.map │ │ │ ├── bootstrap.esm.min.js │ │ │ ├── bootstrap.esm.min.js.map │ │ │ ├── bootstrap.js │ │ │ ├── bootstrap.js.map │ │ │ ├── bootstrap.min.js │ │ │ └── bootstrap.min.js.map │ │ ├── jquery-validation-unobtrusive │ │ ├── LICENSE.txt │ │ ├── jquery.validate.unobtrusive.js │ │ └── jquery.validate.unobtrusive.min.js │ │ ├── jquery-validation │ │ ├── LICENSE.md │ │ └── dist │ │ │ ├── additional-methods.js │ │ │ ├── additional-methods.min.js │ │ │ ├── jquery.validate.js │ │ │ └── jquery.validate.min.js │ │ └── jquery │ │ ├── LICENSE.txt │ │ └── dist │ │ ├── jquery.js │ │ ├── jquery.min.js │ │ └── jquery.min.map └── tests │ ├── NimblePros.SampleToDo.FunctionalTests │ ├── Contributors │ │ ├── ContributorCreate.cs │ │ ├── ContributorDelete.cs │ │ ├── ContributorGetById.cs │ │ ├── ContributorList.cs │ │ └── ContributorUpdate.cs │ ├── CustomWebApplicationFactory.cs │ ├── Fixtures │ │ └── SmtpServerFixture.cs │ ├── GlobalUsings.cs │ ├── NimblePros.SampleToDo.FunctionalTests.csproj │ ├── Projects │ │ ├── ProjectAddToDoItem.cs │ │ ├── ProjectCreate.cs │ │ ├── ProjectGetById.cs │ │ ├── ProjectItemMarkComplete.cs │ │ └── ProjectList.cs │ └── xunit.runner.json │ ├── NimblePros.SampleToDo.IntegrationTests │ ├── Data │ │ ├── BaseEfRepoTestFixture.cs │ │ ├── EfRepositoryAdd.cs │ │ ├── EfRepositoryDelete.cs │ │ └── EfRepositoryUpdate.cs │ ├── GlobalUsings.cs │ └── NimblePros.SampleToDo.IntegrationTests.csproj │ └── NimblePros.SampleToDo.UnitTests │ ├── Core │ ├── ContributorAggregate │ │ └── ContributorConstructor.cs │ ├── Handlers │ │ └── ItemCompletedEmailNotificationHandlerHandle.cs │ ├── ProjectAggregate │ │ ├── ProjectConstructor.cs │ │ ├── ProjectNameFrom.cs │ │ ├── Project_AddItem.cs │ │ ├── ToDoItemConstructor.cs │ │ └── ToDoItemMarkComplete.cs │ ├── Services │ │ ├── DeleteContributorSevice_DeleteContributor.cs │ │ ├── ToDoItemSearchServiceTests.cs │ │ ├── ToDoItemSearchService_GetAllIncompleteItems.cs │ │ └── ToDoItemSearchService_GetNextIncompleteItem.cs │ └── Specifications │ │ └── IncompleteItemSpecificationsConstructor.cs │ ├── GlobalUsings.cs │ ├── NimblePros.SampleToDo.UnitTests.csproj │ ├── NoOpMediator.cs │ ├── ToDoItemBuilder.cs │ ├── UseCases │ └── Contributors │ │ ├── CreateContributorHandlerHandle.cs │ │ ├── GetContributorHandlerHandle.cs │ │ └── UpdateContributorHandlerHandle.cs │ └── xunit.runner.json ├── src ├── Clean.Architecture.AspireHost │ ├── Clean.Architecture.AspireHost.csproj │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.Development.json │ └── appsettings.json ├── Clean.Architecture.Core │ ├── Clean.Architecture.Core.csproj │ ├── ContributorAggregate │ │ ├── Contributor.cs │ │ ├── ContributorStatus.cs │ │ ├── Events │ │ │ └── ContributorDeletedEvent.cs │ │ ├── Handlers │ │ │ └── ContributorDeletedHandler.cs │ │ └── Specifications │ │ │ └── ContributorByIdSpec.cs │ ├── GlobalUsings.cs │ ├── Interfaces │ │ ├── IDeleteContributorService.cs │ │ └── IEmailSender.cs │ ├── README.md │ └── Services │ │ └── DeleteContributorService.cs ├── Clean.Architecture.Infrastructure │ ├── Clean.Architecture.Infrastructure.csproj │ ├── Data │ │ ├── AppDbContext.cs │ │ ├── AppDbContextExtensions.cs │ │ ├── Config │ │ │ ├── ContributorConfiguration.cs │ │ │ └── DataSchemaConstants.cs │ │ ├── EfRepository.cs │ │ ├── Migrations │ │ │ ├── 20231218143922_PhoneNumber.Designer.cs │ │ │ ├── 20231218143922_PhoneNumber.cs │ │ │ └── AppDbContextModelSnapshot.cs │ │ ├── Queries │ │ │ ├── FakeListContributorsQueryService.cs │ │ │ └── ListContributorsQueryService.cs │ │ └── SeedData.cs │ ├── Email │ │ ├── FakeEmailSender.cs │ │ ├── MailserverConfiguration.cs │ │ ├── MimeKitEmailSender.cs │ │ └── SmtpEmailSender.cs │ ├── GlobalUsings.cs │ ├── InfrastructureServiceExtensions.cs │ └── README.md ├── Clean.Architecture.ServiceDefaults │ ├── Clean.Architecture.ServiceDefaults.csproj │ └── Extensions.cs ├── Clean.Architecture.UseCases │ ├── Clean.Architecture.UseCases.csproj │ ├── Contributors │ │ ├── ContributorDTO.cs │ │ ├── Create │ │ │ ├── CreateContributorCommand.cs │ │ │ └── CreateContributorHandler.cs │ │ ├── Delete │ │ │ ├── DeleteContributorCommand.cs │ │ │ └── DeleteContributorHandler.cs │ │ ├── Get │ │ │ ├── GetContributorHandler.cs │ │ │ └── GetContributorQuery.cs │ │ ├── List │ │ │ ├── IListContributorsQueryService.cs │ │ │ ├── ListContributorsHandler.cs │ │ │ └── ListContributorsQuery.cs │ │ └── Update │ │ │ ├── UpdateContributorCommand.cs │ │ │ └── UpdateContributorHandler.cs │ ├── GlobalUsings.cs │ └── README.md └── Clean.Architecture.Web │ ├── Clean.Architecture.Web.csproj │ ├── Configurations │ ├── LoggerConfigs.cs │ ├── MediatrConfigs.cs │ ├── MiddlewareConfig.cs │ ├── OptionConfigs.cs │ └── ServiceConfigs.cs │ ├── Contributors │ ├── ContributorRecord.cs │ ├── Create.CreateContributorRequest.cs │ ├── Create.CreateContributorResponse.cs │ ├── Create.CreateContributorValidator.cs │ ├── Create.cs │ ├── Delete.DeleteContributorRequest.cs │ ├── Delete.DeleteContributorValidator.cs │ ├── Delete.cs │ ├── GetById.GetContributorByIdRequest.cs │ ├── GetById.GetContributorValidator.cs │ ├── GetById.cs │ ├── List.ContributorListResponse.cs │ ├── List.cs │ ├── Update.UpdateContributorRequest.cs │ ├── Update.UpdateContributorResponse.cs │ ├── Update.UpdateContributorValidator.cs │ └── Update.cs │ ├── GlobalUsings.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── api.http │ ├── appsettings.json │ └── wwwroot │ └── .gitkeep └── tests ├── Clean.Architecture.AspireTests ├── AspireIntegrationTests.cs └── Clean.Architecture.AspireTests.csproj ├── Clean.Architecture.FunctionalTests ├── ApiEndpoints │ ├── ContributorGetById.cs │ └── ContributorList.cs ├── Clean.Architecture.FunctionalTests.csproj ├── CustomWebApplicationFactory.cs ├── GlobalUsings.cs └── xunit.runner.json ├── Clean.Architecture.IntegrationTests ├── Clean.Architecture.IntegrationTests.csproj ├── Data │ ├── BaseEfRepoTestFixture.cs │ ├── EfRepositoryAdd.cs │ ├── EfRepositoryDelete.cs │ └── EfRepositoryUpdate.cs └── GlobalUsings.cs └── Clean.Architecture.UnitTests ├── Clean.Architecture.UnitTests.csproj ├── Core ├── ContributorAggregate │ └── ContributorConstructor.cs └── Services │ ├── DeleteContributorSevice_DeleteContributor.cs │ ├── ToDoItemSearchService_GetAllIncompleteItems.cs │ └── ToDoItemSearchService_GetNextIncompleteItem.cs ├── GlobalUsings.cs ├── NoOpMediator.cs ├── UseCases └── Contributors │ └── CreateContributorHandlerHandle.cs └── xunit.runner.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ardalis] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 7 | 8 | - .NET SDK Version: 9 | 10 | Steps to Reproduce: 11 | 12 | 1. 13 | 2. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question 4 | url: https://stackoverflow.com/questions/tagged/ardalis-cleanarchitecture 5 | about: Please ask and answer questions on Stack Overflow. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/build-ramdisk.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test with Ramdisk 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build-and-test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Step 1: Checkout the repository 15 | - name: Checkout Code 16 | uses: actions/checkout@v3 17 | 18 | # Step 2: Set up .NET environment 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v3 21 | with: 22 | dotnet-version: 9.0.100 23 | 24 | # Step 3: Prepare a directory in /dev/shm 25 | - name: Set up Ramdisk Directory 26 | run: | 27 | mkdir -p /dev/shm/ramdisk/project 28 | 29 | # Step 4: Copy source code to the ramdisk 30 | - name: Copy Code to Ramdisk 31 | run: | 32 | cp -r $GITHUB_WORKSPACE/* /dev/shm/ramdisk/project 33 | 34 | # Step 5: Debug Directory Contents 35 | - name: Debug Directory 36 | run: | 37 | ls -R /dev/shm/ramdisk/project 38 | 39 | # Step 6: Build and Test from the ramdisk 40 | - name: Build and Test 41 | run: | 42 | cd /dev/shm/ramdisk/project 43 | dotnet build Clean.Architecture.sln --configuration Debug 44 | dotnet test Clean.Architecture.sln -- 45 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | schedule: 8 | - cron: '0 8 * * *' 9 | 10 | jobs: 11 | analyze: 12 | name: CodeQL Analysis 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | id: checkout_repo 17 | uses: actions/checkout@v2 18 | 19 | - name: Initialize CodeQL 20 | id: init_codeql 21 | uses: github/codeql-action/init@v2 22 | with: 23 | queries: security-and-quality 24 | 25 | - name: Autobuild 26 | uses: github/codeql-action/autobuild@v2 27 | 28 | - name: Perform CodeQL Analysis 29 | id: analyze_codeql 30 | uses: github/codeql-action/analyze@v2 31 | 32 | # Built with ❤ by [Pipeline Foundation](https://pipeline.foundation) 33 | -------------------------------------------------------------------------------- /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: '9.0.100-rc.2.24474.11' 20 | - name: Build with dotnet 21 | run: dotnet build --configuration Release 22 | - name: Test with dotnet 23 | run: dotnet test ./Clean.Architecture.sln --configuration Release 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish Ardalis.CleanArchitecture Template to nuget 2 | on: 3 | push: 4 | branches: 5 | - main # Your default release branch 6 | paths: 7 | - 'CleanArchitecture.nuspec' 8 | jobs: 9 | publish: 10 | name: list on nuget 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: nuget/setup-nuget@v1 16 | with: 17 | nuget-version: '5.x' 18 | 19 | - name: Package the template 20 | run: nuget pack CleanArchitecture.nuspec -NoDefaultExcludes 21 | 22 | - name: Publish to nuget.org 23 | run: nuget push Ardalis.CleanArchitecture.Template.*.nupkg -src https://api.nuget.org/v3/index.json ${{secrets.NUGET_API_KEY}} 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/Clean.Architecture.sln", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary;ForceNoAlign" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/Clean.Architecture.sln", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary;ForceNoAlign" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/Clean.Architecture.sln" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project has adopted the code of conduct defined by the Contributor Covenant 4 | to clarify expected behavior in our community. 5 | For more information, see the [.NET Foundation Code of Conduct](https://dotnetfoundation.org/code-of-conduct). 6 | -------------------------------------------------------------------------------- /CleanArchitecture.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ardalis.CleanArchitecture.Template 5 | ASP.NET Core Clean Architecture Solution 6 | 10.0.2 7 | Steve Smith 8 | 9 | The Clean Architecture Solution Template popularized by Steve @ardalis Smith. Provides a great starting point for modern and/or DDD solutions built with .NET 8 and C# 12. 10 | Features zero tight coupling to database or data access technology. 11 | 12 | en-US 13 | MIT 14 | https://github.com/ardalis/CleanArchitecture 15 | 16 | * Fixes project references in the SLN file for Aspire 17 | 18 | 19 | 20 | 21 | Web ASP.NET "Clean Architecture" ddd domain-driven-design clean-architecture clean architecture ardalis SOLID 22 | ./content/icon.png 23 | README.md 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | true 5 | net9.0 6 | enable 7 | enable 8 | 9 | 10 | 1591 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Steve Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100-rc.2.24474.11", 4 | "rollForward": "latestMajor" 5 | } 6 | } -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardalis/CleanArchitecture/7c031c77e6b8db695f3266a5f5873a522bbb238a/icon.png -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /sample/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | net9.0 6 | 7 | 8 | 1591 9 | 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.AspireHost/NimblePros.SampleToDo.AspireHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | net9.0 8 | enable 9 | enable 10 | true 11 | c540eeb6-e06b-4456-a539-be58dd8b88c7 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.AspireHost/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = DistributedApplication.CreateBuilder(args); 2 | 3 | builder.AddProject("web"); 4 | 5 | builder.Build().Run(); 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.AspireHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17143;http://localhost:15258", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21007", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22245" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15258", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19187", 25 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20134" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.AspireHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.AspireHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/Contributor.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate.Events; 2 | 3 | namespace NimblePros.SampleToDo.Core.ContributorAggregate; 4 | 5 | public class Contributor : EntityBase, IAggregateRoot 6 | { 7 | public ContributorName Name { get; private set; } 8 | 9 | public Contributor(ContributorName name) 10 | { 11 | Name = name; 12 | } 13 | 14 | public void UpdateName(ContributorName newName) 15 | { 16 | if (Name.Equals(newName)) return; 17 | Name = newName; 18 | this.RegisterDomainEvent(new ContributorNameUpdatedEvent(this)); 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/ContributorName.cs: -------------------------------------------------------------------------------- 1 | using Vogen; 2 | 3 | namespace NimblePros.SampleToDo.Core.ContributorAggregate; 4 | 5 | [ValueObject(conversions: Conversions.SystemTextJson)] 6 | public partial struct ContributorName 7 | { 8 | private static Validation Validate(in string name) => String.IsNullOrEmpty(name) ? 9 | Validation.Invalid("Name cannot be empty") : 10 | Validation.Ok; 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ContributorAggregate.Events; 2 | 3 | /// 4 | /// A domain event that is dispatched whenever a contributor is deleted. 5 | /// The DeleteContributorService is used to dispatch this event. 6 | /// 7 | internal class ContributorDeletedEvent(int contributorId) : DomainEventBase 8 | { 9 | public int ContributorId { get; set; } = contributorId; 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/Events/ContributorNameUpdatedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ContributorAggregate.Events; 2 | 3 | internal class ContributorNameUpdatedEvent(Contributor contributor) : DomainEventBase 4 | { 5 | public Contributor Contributor { get; private set; } = contributor; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/Handlers/ContributorNameUpdatedEventLoggingHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate.Events; 2 | 3 | namespace NimblePros.SampleToDo.Core.ContributorAggregate.Handlers; 4 | 5 | internal class ContributorNameUpdatedEventLoggingHandler(ILogger logger) : INotificationHandler 6 | { 7 | private readonly ILogger _logger = logger; 8 | 9 | public Task Handle(ContributorNameUpdatedEvent notification, CancellationToken cancellationToken) 10 | { 11 | int contributorId = notification.Contributor.Id; 12 | string newName = notification.Contributor.Name.Value; 13 | _logger.LogInformation("Contributor {contributorId}'s name was updated to {newName}", contributorId, newName); 14 | return Task.CompletedTask; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ContributorAggregate.Specifications; 2 | 3 | public class ContributorByIdSpec : Specification 4 | { 5 | public ContributorByIdSpec(int contributorId) 6 | { 7 | Query 8 | .Where(contributor => contributor.Id == contributorId); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/CoreServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using NimblePros.SampleToDo.Core.Interfaces; 3 | using NimblePros.SampleToDo.Core.Services; 4 | 5 | namespace NimblePros.SampleToDo.Core; 6 | 7 | public static class CoreServiceExtensions 8 | { 9 | public static IServiceCollection AddCoreServices(this IServiceCollection services, ILogger logger) 10 | { 11 | services.AddScoped(); 12 | services.AddScoped(); 13 | 14 | logger.LogInformation("{Project} services registered", "Core"); 15 | 16 | return services; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.GuardClauses; 2 | global using Ardalis.Result; 3 | global using Ardalis.SharedKernel; 4 | global using Ardalis.SmartEnum; 5 | global using Ardalis.Specification; 6 | global using MediatR; 7 | global using Microsoft.Extensions.Logging; 8 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/Interfaces/IDeleteContributorService.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.Interfaces; 2 | 3 | public interface IDeleteContributorService 4 | { 5 | // This service and method exist to provide a place in which to fire domain events 6 | // when deleting this aggregate root entity 7 | public Task DeleteContributor(int contributorId); 8 | } 9 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/Interfaces/IEmailSender.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.Interfaces; 2 | 3 | public interface IEmailSender 4 | { 5 | Task SendEmailAsync(string to, string from, string subject, string body); 6 | } 7 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/Interfaces/IToDoItemSearchService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.Core.Interfaces; 4 | 5 | public interface IToDoItemSearchService 6 | { 7 | Task> GetNextIncompleteItemAsync(ProjectId projectId); 8 | Task>> GetAllIncompleteItemsAsync(ProjectId projectId, string searchString); 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/NimblePros.SampleToDo.Core.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Events/ContributorAddedToItemEvent.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | public class ContributorAddedToItemEvent : DomainEventBase 4 | { 5 | public int ContributorId { get; set; } 6 | public ToDoItem Item { get; set; } 7 | 8 | public ContributorAddedToItemEvent(ToDoItem item, int contributorId) 9 | { 10 | Item = item; 11 | ContributorId = contributorId; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Events/NewItemAddedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | public class NewItemAddedEvent : DomainEventBase 4 | { 5 | public ToDoItem NewItem { get; set; } 6 | public Project Project { get; set; } 7 | 8 | public NewItemAddedEvent(Project project, 9 | ToDoItem newItem) 10 | { 11 | Project = project; 12 | NewItem = newItem; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Events/ToDoItemCompletedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | public class ToDoItemCompletedEvent : DomainEventBase 4 | { 5 | public ToDoItem CompletedItem { get; set; } 6 | 7 | public ToDoItemCompletedEvent(ToDoItem completedItem) 8 | { 9 | CompletedItem = completedItem; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Handlers/ItemCompletedEmailNotificationHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.Interfaces; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate.Events; 3 | 4 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Handlers; 5 | 6 | public class ItemCompletedEmailNotificationHandler : INotificationHandler 7 | { 8 | private readonly IEmailSender _emailSender; 9 | 10 | // In a REAL app you might want to use the Outbox pattern and a command/queue here... 11 | public ItemCompletedEmailNotificationHandler(IEmailSender emailSender) 12 | { 13 | _emailSender = emailSender; 14 | } 15 | 16 | // configure a test email server to demo this works 17 | // https://ardalis.com/configuring-a-local-test-email-server 18 | public Task Handle(ToDoItemCompletedEvent domainEvent, CancellationToken cancellationToken) 19 | { 20 | Guard.Against.Null(domainEvent, nameof(domainEvent)); 21 | 22 | return _emailSender.SendEmailAsync("test@test.com", "test@test.com", $"{domainEvent.CompletedItem.Title} was completed.", domainEvent.CompletedItem.ToString()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Priority.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | public class Priority : SmartEnum 4 | { 5 | public static readonly Priority Backlog = new(nameof(Backlog), 0); 6 | public static readonly Priority Critical = new(nameof(Critical), 1); 7 | 8 | protected Priority(string name, int value) : base(name, value) { } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Project.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 4 | 5 | public class Project : EntityBase, IAggregateRoot 6 | { 7 | public ProjectName Name { get; private set; } 8 | 9 | private readonly List _items = new(); 10 | public IEnumerable Items => _items.AsReadOnly(); 11 | public ProjectStatus Status => _items.All(i => i.IsDone) ? ProjectStatus.Complete : ProjectStatus.InProgress; 12 | 13 | public Project(ProjectName name) 14 | { 15 | Name = name; 16 | } 17 | 18 | public void AddItem(ToDoItem newItem) 19 | { 20 | Guard.Against.Null(newItem); 21 | _items.Add(newItem); 22 | 23 | var newItemAddedEvent = new NewItemAddedEvent(this, newItem); 24 | base.RegisterDomainEvent(newItemAddedEvent); 25 | } 26 | 27 | public void UpdateName(ProjectName newName) 28 | { 29 | Name = newName; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ProjectId.cs: -------------------------------------------------------------------------------- 1 | using Vogen; 2 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 3 | 4 | [ValueObject] 5 | public partial struct ProjectId; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ProjectName.cs: -------------------------------------------------------------------------------- 1 | using Vogen; 2 | 3 | [assembly: VogenDefaults( 4 | staticAbstractsGeneration: StaticAbstractsGeneration.MostCommon | StaticAbstractsGeneration.InstanceMethodsAndProperties)] 5 | 6 | 7 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 8 | 9 | // NOTE: Structs do not require conversion to work with EF Core 10 | [ValueObject(conversions: Conversions.SystemTextJson)] 11 | public partial struct ProjectName 12 | { 13 | private static Validation Validate(in string name) => String.IsNullOrEmpty(name) ? 14 | Validation.Invalid("Name cannot be empty") : 15 | Validation.Ok; 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ProjectStatus.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | public enum ProjectStatus 4 | { 5 | InProgress, // NOTE: Better to use a SmartEnum if you want spaces in your strings e.g. "In Progress" 6 | Complete 7 | } 8 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Specifications/IncompleteItemsSearchSpec.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 2 | 3 | public class IncompleteItemsSearchSpec : Specification 4 | { 5 | public IncompleteItemsSearchSpec(string searchString) 6 | { 7 | Query 8 | .Where(item => !item.IsDone && 9 | (item.Title.Contains(searchString) || 10 | item.Description.Contains(searchString))); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Specifications/IncompleteItemsSpec.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 2 | 3 | public class IncompleteItemsSpec : Specification 4 | { 5 | public IncompleteItemsSpec() 6 | { 7 | Query.Where(item => !item.IsDone); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Specifications/ProjectByIdWithItemsSpec.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 2 | 3 | public class ProjectByIdWithItemsSpec : Specification 4 | { 5 | public ProjectByIdWithItemsSpec(ProjectId projectId) 6 | { 7 | Query 8 | .Where(project => project.Id == projectId) 9 | .Include(project => project.Items); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/Specifications/ProjectsWithItemsByContributorId.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 2 | 3 | public class ProjectsWithItemsByContributorIdSpec : Specification 4 | { 5 | public ProjectsWithItemsByContributorIdSpec(int contributorId) 6 | { 7 | Query 8 | .Where(project => project.Items.Any(item => item.ContributorId == contributorId)) 9 | .Include(project => project.Items); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ToDoItem.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 4 | 5 | public class ToDoItem : EntityBase 6 | { 7 | public ToDoItem() : this(Priority.Backlog) 8 | { 9 | } 10 | 11 | public ToDoItem(Priority priority) 12 | { 13 | Priority = priority; 14 | } 15 | 16 | public string Title { get; set; } = string.Empty; 17 | public string Description { get; set; } = string.Empty; 18 | public int? ContributorId { get; private set; } // tasks don't have anyone assigned when first created 19 | public bool IsDone { get; private set; } 20 | 21 | public Priority Priority { get; private set; } 22 | 23 | 24 | public void MarkComplete() 25 | { 26 | if (!IsDone) 27 | { 28 | IsDone = true; 29 | 30 | RegisterDomainEvent(new ToDoItemCompletedEvent(this)); 31 | } 32 | } 33 | 34 | public void AddContributor(int contributorId) 35 | { 36 | Guard.Against.Null(contributorId); 37 | ContributorId = contributorId; 38 | 39 | var contributorAddedToItem = new ContributorAddedToItemEvent(this, contributorId); 40 | base.RegisterDomainEvent(contributorAddedToItem); 41 | } 42 | 43 | public void RemoveContributor() 44 | { 45 | ContributorId = null; 46 | } 47 | 48 | public override string ToString() 49 | { 50 | string status = IsDone ? "Done!" : "Not done."; 51 | return $"{Id}: Status: {status} - {Title} - {Description}"; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ToDoItemId.cs: -------------------------------------------------------------------------------- 1 | using Vogen; 2 | namespace NimblePros.SampleToDo.Core.ProjectAggregate; 3 | 4 | [ValueObject] 5 | public partial struct ToDoItemId; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Core/Services/DeleteContributorService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.Core.ContributorAggregate.Events; 3 | using NimblePros.SampleToDo.Core.Interfaces; 4 | 5 | namespace NimblePros.SampleToDo.Core.Services; 6 | 7 | public class DeleteContributorService : IDeleteContributorService 8 | { 9 | private readonly IRepository _repository; 10 | private readonly IMediator _mediator; 11 | private readonly ILogger _logger; 12 | 13 | public DeleteContributorService(IRepository repository, 14 | IMediator mediator, 15 | ILogger logger) 16 | { 17 | _repository = repository; 18 | _mediator = mediator; 19 | _logger = logger; 20 | } 21 | 22 | public async Task DeleteContributor(int contributorId) 23 | { 24 | _logger.LogInformation("Deleting Contributor {contributorId}", contributorId); 25 | var aggregateToDelete = await _repository.GetByIdAsync(contributorId); 26 | if (aggregateToDelete == null) return Result.NotFound(); 27 | 28 | await _repository.DeleteAsync(aggregateToDelete); 29 | var domainEvent = new ContributorDeletedEvent(contributorId); 30 | await _mediator.Publish(domainEvent); 31 | return Result.Success(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Config/ContributorConfiguration.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | 3 | namespace NimblePros.SampleToDo.Infrastructure.Data.Config; 4 | 5 | public class ContributorConfiguration : IEntityTypeConfiguration 6 | { 7 | public void Configure(EntityTypeBuilder builder) 8 | { 9 | builder.Property(p => p.Name) 10 | .HasVogenConversion() 11 | .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) 12 | .IsRequired(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Config/DataSchemaConstants.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Infrastructure.Data.Config; 2 | 3 | public static class DataSchemaConstants 4 | { 5 | public const int DEFAULT_NAME_LENGTH = 100; 6 | } 7 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Config/ProjectConfiguration.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.Infrastructure.Data.Config; 4 | 5 | public class ProjectConfiguration : IEntityTypeConfiguration 6 | { 7 | public void Configure(EntityTypeBuilder builder) 8 | { 9 | builder.Property(p => p.Id) 10 | .HasValueGenerator>() 11 | .HasVogenConversion() 12 | .IsRequired(); 13 | builder.Property(p => p.Name) 14 | .HasVogenConversion() 15 | .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) 16 | .IsRequired(); 17 | } 18 | } 19 | 20 | 21 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Config/ToDoItemConfiguration.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.Infrastructure.Data.Config; 4 | 5 | public class ToDoItemConfiguration : IEntityTypeConfiguration 6 | { 7 | public void Configure(EntityTypeBuilder builder) 8 | { 9 | builder.Property(p => p.Id) 10 | .HasValueGenerator>() 11 | .HasVogenConversion() 12 | .IsRequired(); 13 | builder.Property(t => t.Title) 14 | .IsRequired(); 15 | builder.Property(t => t.ContributorId) 16 | .IsRequired(false); 17 | builder.Property(t => t.Priority) 18 | .HasConversion( 19 | p => p.Value, 20 | p => Priority.FromValue(p)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Config/VogenEfCoreConverters.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate; 3 | using Vogen; 4 | 5 | namespace NimblePros.SampleToDo.Infrastructure.Data.Config; 6 | 7 | [EfCoreConverter] 8 | [EfCoreConverter] 9 | [EfCoreConverter] 10 | [EfCoreConverter] 11 | internal partial class VogenEfCoreConverters; 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/EfRepository.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Infrastructure.Data; 2 | 3 | // inherit from Ardalis.Specification type 4 | public class EfRepository : RepositoryBase, IReadRepository, IRepository where T : class, IAggregateRoot 5 | { 6 | public EfRepository(AppDbContext dbContext) : base(dbContext) 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Contributors; 2 | using NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class FakeListContributorsQueryService : IListContributorsQueryService 7 | { 8 | public Task> ListAsync() 9 | { 10 | var result = new List() { new ContributorDTO(1, "Ardalis"), new ContributorDTO(2, "Snowfrog") }; 11 | return Task.FromResult(result.AsEnumerable()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/FakeListIncompleteItemsQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Projects; 2 | using NimblePros.SampleToDo.UseCases.Projects.ListIncompleteItems; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class FakeListIncompleteItemsQueryService : IListIncompleteItemsQueryService 7 | { 8 | public async Task> ListAsync(int projectId) 9 | { 10 | var testItem = new ToDoItemDTO(Id: 1000, Title: "test", Description: "test description", IsComplete: false, null); 11 | return await Task.FromResult(new List() { testItem}); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/FakeListProjectsShallowQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Projects.ListShallow; 2 | using NimblePros.SampleToDo.UseCases.Projects; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class FakeListProjectsShallowQueryService : IListProjectsShallowQueryService 7 | { 8 | public async Task> ListAsync() 9 | { 10 | var testProject = new ProjectDTO(1000, "Test Project", "InProgress"); 11 | return await Task.FromResult(new List { testProject }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/ListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Contributors; 2 | using NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class ListContributorsQueryService : IListContributorsQueryService 7 | { 8 | // You can use EF, Dapper, SqlClient, etc. for queries 9 | private readonly AppDbContext _db; 10 | 11 | public ListContributorsQueryService(AppDbContext db) 12 | { 13 | _db = db; 14 | } 15 | 16 | public async Task> ListAsync() 17 | { 18 | var result = await _db.Contributors.FromSqlRaw("SELECT Id, Name FROM Contributors") // don't fetch other big columns 19 | .Select(c => new ContributorDTO(c.Id, c.Name.Value)) 20 | .ToListAsync(); 21 | 22 | return result; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/ListIncompleteItemsQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Projects; 2 | using NimblePros.SampleToDo.UseCases.Projects.ListIncompleteItems; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class ListIncompleteItemsQueryService : IListIncompleteItemsQueryService 7 | { 8 | private readonly AppDbContext _db; 9 | 10 | public ListIncompleteItemsQueryService(AppDbContext db) 11 | { 12 | _db = db; 13 | } 14 | 15 | public async Task> ListAsync(int projectId) 16 | { 17 | var projectParameter = new SqlParameter("@projectId", System.Data.SqlDbType.Int); 18 | var result = await _db.ToDoItems.FromSqlRaw("SELECT Id, Title, Description, IsDone, ContributorId FROM ToDoItems WHERE ProjectId = @ProjectId", 19 | projectParameter) // don't fetch other big columns 20 | .Select(x => new ToDoItemDTO(x.Id.Value, x.Title, x.Description, x.IsDone, x.ContributorId)) 21 | .ToListAsync(); 22 | 23 | return result; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Data/Queries/ListProjectsShallowQueryService.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Projects; 2 | using NimblePros.SampleToDo.UseCases.Projects.ListShallow; 3 | 4 | namespace NimblePros.SampleToDo.Infrastructure.Data.Queries; 5 | 6 | public class ListProjectsShallowQueryService(AppDbContext db) : 7 | IListProjectsShallowQueryService 8 | { 9 | private readonly AppDbContext _db = db; 10 | 11 | public async Task> ListAsync() 12 | { 13 | var result = await _db.Projects.FromSqlRaw("SELECT Id, Name FROM Projects") // don't fetch other big columns 14 | .Select(x => new ProjectDTO(x.Id.Value, x.Name.Value, x.Status.ToString())) 15 | .ToListAsync(); 16 | 17 | return result; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Email/FakeEmailSender.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.Interfaces; 2 | 3 | namespace NimblePros.SampleToDo.Infrastructure.Email; 4 | 5 | public class FakeEmailSender : IEmailSender 6 | { 7 | private readonly ILogger _logger; 8 | 9 | public FakeEmailSender(ILogger logger) 10 | { 11 | _logger = logger; 12 | } 13 | public Task SendEmailAsync(string to, string from, string subject, string body) 14 | { 15 | _logger.LogInformation("Not actually sending an email to {to} from {from} with subject {subject}", to, from, subject); 16 | return Task.CompletedTask; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Email/MailserverConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Infrastructure.Email; 2 | 3 | public class MailserverConfiguration() 4 | { 5 | public string Hostname { get; set; } = "localhost"; 6 | public int Port { get; set; } = 25; 7 | } 8 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Email/MimeKitEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using MimeKit; 3 | using NimblePros.SampleToDo.Core.Interfaces; 4 | 5 | namespace NimblePros.SampleToDo.Infrastructure.Email; 6 | 7 | public class MimeKitEmailSender(ILogger logger, 8 | IOptions mailserverOptions) : IEmailSender 9 | { 10 | private readonly ILogger _logger = logger; 11 | private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; 12 | 13 | public async Task SendEmailAsync(string to, string from, string subject, string body) 14 | { 15 | _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, ToString()); 16 | 17 | using var client = new MailKit.Net.Smtp.SmtpClient(); 18 | await client.ConnectAsync(_mailserverConfiguration.Hostname, 19 | _mailserverConfiguration.Port, false); 20 | var message = new MimeMessage(); 21 | message.From.Add(new MailboxAddress(from, from)); 22 | message.To.Add(new MailboxAddress(to, to)); 23 | message.Subject = subject; 24 | message.Body = new TextPart("plain") { Text = body }; 25 | 26 | await client.SendAsync(message); 27 | 28 | await client.DisconnectAsync(true, 29 | new CancellationToken(canceled: true)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/Email/SmtpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.Interfaces; 2 | 3 | namespace NimblePros.SampleToDo.Infrastructure.Email; 4 | 5 | public class SmtpEmailSender : IEmailSender 6 | { 7 | private readonly ILogger _logger; 8 | 9 | public SmtpEmailSender(ILogger logger) 10 | { 11 | _logger = logger; 12 | } 13 | 14 | public async Task SendEmailAsync(string to, string from, string subject, string body) 15 | { 16 | var emailClient = new SmtpClient("localhost"); 17 | var message = new MailMessage 18 | { 19 | From = new MailAddress(from), 20 | Subject = subject, 21 | Body = body 22 | }; 23 | message.To.Add(new MailAddress(to)); 24 | await emailClient.SendMailAsync(message); 25 | _logger.LogWarning("Sending email to {to} from {from} with subject {subject}.", to, from, subject); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Net.Mail; 2 | global using System.Reflection; 3 | global using Ardalis.SharedKernel; 4 | global using Ardalis.Specification.EntityFrameworkCore; 5 | global using Microsoft.Data.SqlClient; 6 | global using Microsoft.EntityFrameworkCore; 7 | global using Microsoft.EntityFrameworkCore.Metadata.Builders; 8 | global using Microsoft.Extensions.DependencyInjection; 9 | global using Microsoft.Extensions.Logging; 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Infrastructure/NimblePros.SampleToDo.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.ServiceDefaults/NimblePros.SampleToDo.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enable 5 | enable 6 | true 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Create/CreateContributorCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Create; 4 | 5 | /// 6 | /// Create a new Contributor. 7 | /// 8 | /// 9 | public record CreateContributorCommand(ContributorName Name) : Ardalis.SharedKernel.ICommand>; 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Create/CreateContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Create; 4 | 5 | public class CreateContributorHandler : ICommandHandler> 6 | { 7 | private readonly IRepository _repository; 8 | 9 | public CreateContributorHandler(IRepository repository) 10 | { 11 | _repository = repository; 12 | } 13 | 14 | public async Task> Handle(CreateContributorCommand request, 15 | CancellationToken cancellationToken) 16 | { 17 | var newContributor = new Contributor(request.Name); 18 | var createdItem = await _repository.AddAsync(newContributor, cancellationToken); 19 | 20 | return createdItem.Id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Delete/DeleteContributorCommand.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Delete; 2 | 3 | public record DeleteContributorCommand(int ContributorId) : ICommand; 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Delete/DeleteContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.Interfaces; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Delete; 4 | 5 | public class DeleteContributorHandler : ICommandHandler 6 | { 7 | private readonly IDeleteContributorService _deleteContributorService; 8 | 9 | public DeleteContributorHandler(IDeleteContributorService deleteContributorService) 10 | { 11 | _deleteContributorService = deleteContributorService; 12 | } 13 | 14 | public async Task Handle(DeleteContributorCommand request, CancellationToken cancellationToken) 15 | { 16 | // This Approach: Keep Domain Events in the Domain Model / Core project; this becomes a pass-through 17 | return await _deleteContributorService.DeleteContributor(request.ContributorId); 18 | 19 | // Another Approach: Do the real work here including dispatching domain events - change the event from internal to public 20 | // Ardalis prefers using the service so that "domain event" behavior remains in the domain model / core project 21 | // var aggregateToDelete = await _repository.GetByIdAsync(request.ContributorId); 22 | // if (aggregateToDelete == null) return Result.NotFound(); 23 | 24 | // await _repository.DeleteAsync(aggregateToDelete); 25 | // var domainEvent = new ContributorDeletedEvent(request.ContributorId); 26 | // await _mediator.Publish(domainEvent); 27 | // return Result.Success(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Update/UpdateContributorCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Update; 4 | 5 | public record UpdateContributorCommand(int ContributorId, ContributorName NewName) : ICommand>; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Commands/Update/UpdateContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Contributors.Commands.Update; 4 | 5 | public class UpdateContributorHandler : ICommandHandler> 6 | { 7 | private readonly IRepository _repository; 8 | 9 | public UpdateContributorHandler(IRepository repository) 10 | { 11 | _repository = repository; 12 | } 13 | 14 | public async Task> Handle(UpdateContributorCommand request, CancellationToken cancellationToken) 15 | { 16 | var existingContributor = await _repository.GetByIdAsync(request.ContributorId, cancellationToken); 17 | if (existingContributor == null) 18 | { 19 | return Result.NotFound(); 20 | } 21 | 22 | existingContributor.UpdateName(request.NewName!); 23 | 24 | await _repository.UpdateAsync(existingContributor, cancellationToken); 25 | 26 | return Result.Success(new ContributorDTO(existingContributor.Id, existingContributor.Name.Value)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/ContributorDTO.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors; 2 | public record ContributorDTO(int Id, string Name); 3 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Queries/Get/GetContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.Core.ContributorAggregate.Specifications; 3 | 4 | namespace NimblePros.SampleToDo.UseCases.Contributors.Queries.Get; 5 | 6 | /// 7 | /// Queries don't necessarily need to use repository methods, but they can if it's convenient 8 | /// 9 | public class GetContributorHandler : IQueryHandler> 10 | { 11 | private readonly IReadRepository _repository; 12 | 13 | public GetContributorHandler(IReadRepository repository) 14 | { 15 | _repository = repository; 16 | } 17 | 18 | public async Task> Handle(GetContributorQuery request, CancellationToken cancellationToken) 19 | { 20 | var spec = new ContributorByIdSpec(request.ContributorId); 21 | var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); 22 | if (entity == null) return Result.NotFound(); 23 | 24 | return new ContributorDTO(entity.Id, entity.Name.Value); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Queries/Get/GetContributorQuery.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors.Queries.Get; 2 | 3 | public record GetContributorQuery(int ContributorId) : IQuery>; 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Queries/List/IListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 2 | 3 | /// 4 | /// Represents a service that will actually fetch the necessary data 5 | /// Typically implemented in Infrastructure 6 | /// 7 | public interface IListContributorsQueryService 8 | { 9 | Task> ListAsync(); 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Queries/List/ListContributorsHandler.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 2 | 3 | public class ListContributorsHandler : IQueryHandler>> 4 | { 5 | private readonly IListContributorsQueryService _query; 6 | 7 | public ListContributorsHandler(IListContributorsQueryService query) 8 | { 9 | _query = query; 10 | } 11 | 12 | public async Task>> Handle(ListContributorsQuery request, CancellationToken cancellationToken) 13 | { 14 | var result = await _query.ListAsync(); 15 | 16 | return Result.Success(result); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Contributors/Queries/List/ListContributorsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 2 | 3 | public record ListContributorsQuery(int? Skip, int? Take) : IQuery>>; 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.Result; 2 | global using Ardalis.SharedKernel; 3 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/NimblePros.SampleToDo.UseCases.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/AddToDoItem/AddToDoItemCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.AddToDoItem; 4 | 5 | /// 6 | /// Creates a new ToDoItem and adds it to a Project 7 | /// 8 | /// 9 | /// 10 | /// 11 | /// 12 | public record AddToDoItemCommand(ProjectId ProjectId, 13 | int? ContributorId, 14 | string Title, 15 | string Description) : ICommand>; 16 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/AddToDoItem/AddToDoItemHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 3 | 4 | namespace NimblePros.SampleToDo.UseCases.Projects.AddToDoItem; 5 | 6 | public class AddToDoItemHandler : ICommandHandler> 7 | { 8 | private readonly IRepository _repository; 9 | 10 | public AddToDoItemHandler(IRepository repository) 11 | { 12 | _repository = repository; 13 | } 14 | 15 | public async Task> Handle(AddToDoItemCommand request, 16 | CancellationToken cancellationToken) 17 | { 18 | var spec = new ProjectByIdWithItemsSpec(request.ProjectId); 19 | var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); 20 | if (entity == null) 21 | { 22 | return Result.NotFound(); 23 | } 24 | 25 | var newItem = new ToDoItem() 26 | { 27 | Title = request.Title!, 28 | Description = request.Description! 29 | }; 30 | 31 | if(request.ContributorId.HasValue) 32 | { 33 | newItem.AddContributor(request.ContributorId.Value); 34 | } 35 | entity.AddItem(newItem); 36 | await _repository.UpdateAsync(entity); 37 | 38 | return Result.Success(newItem.Id); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Create/CreateProjectCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Create; 4 | 5 | /// 6 | /// Create a new Project. 7 | /// 8 | /// 9 | public record CreateProjectCommand(string Name) : ICommand>; 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Create/CreateProjectHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Create; 4 | 5 | public class CreateProjectHandler(IRepository repository) : ICommandHandler> 6 | { 7 | private readonly IRepository _repository = repository; 8 | 9 | public async Task> Handle(CreateProjectCommand request, 10 | CancellationToken cancellationToken) 11 | { 12 | var newProject = new Project(ProjectName.From(request.Name)); 13 | // NOTE: This implementation issues 3 queries due to Vogen implementation 14 | var createdItem = await _repository.AddAsync(newProject, cancellationToken); 15 | 16 | return createdItem.Id; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Delete/DeleteProjectCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Delete; 4 | 5 | public record DeleteProjectCommand(ProjectId ProjectId) : ICommand; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Delete/DeleteProjectHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Delete; 4 | 5 | public class DeleteProjectHandler : ICommandHandler 6 | { 7 | private readonly IRepository _repository; 8 | 9 | public DeleteProjectHandler(IRepository repository) 10 | { 11 | _repository = repository; 12 | } 13 | 14 | public async Task Handle(DeleteProjectCommand request, CancellationToken cancellationToken) 15 | { 16 | var aggregateToDelete = await _repository.GetByIdAsync(request.ProjectId, cancellationToken); 17 | if (aggregateToDelete == null) 18 | { 19 | return Result.NotFound(); 20 | } 21 | 22 | await _repository.DeleteAsync(aggregateToDelete, cancellationToken); 23 | 24 | return Result.Success(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/GetWithAllItems/GetProjectWithAllItemsHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 3 | 4 | namespace NimblePros.SampleToDo.UseCases.Projects.GetWithAllItems; 5 | 6 | /// 7 | /// Queries don't necessarily need to use repository methods, but they can if it's convenient 8 | /// 9 | public class GetProjectWithAllItemsHandler : IQueryHandler> 10 | { 11 | private readonly IReadRepository _repository; 12 | 13 | public GetProjectWithAllItemsHandler(IReadRepository repository) 14 | { 15 | _repository = repository; 16 | } 17 | 18 | public async Task> Handle(GetProjectWithAllItemsQuery request, CancellationToken cancellationToken) 19 | { 20 | var spec = new ProjectByIdWithItemsSpec(request.ProjectId); 21 | var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); 22 | if (entity == null) return Result.NotFound(); 23 | 24 | var items = entity.Items 25 | .Select(i => new ToDoItemDTO(i.Id.Value, i.Title, i.Description, i.IsDone, i.ContributorId)).ToList(); 26 | return new ProjectWithAllItemsDTO(entity.Id.Value, entity.Name.Value, items, entity.Status.ToString()) 27 | ; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/GetWithAllItems/GetProjectWithAllItemsQuery.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.GetWithAllItems; 4 | 5 | public record GetProjectWithAllItemsQuery(ProjectId ProjectId) : IQuery>; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListIncompleteItems/IListIncompleteItemsQueryService.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListIncompleteItems; 2 | 3 | /// 4 | /// Represents a service that will actually fetch the necessary data 5 | /// Typically implemented in Infrastructure 6 | /// 7 | public interface IListIncompleteItemsQueryService 8 | { 9 | Task> ListAsync(int projectId); 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListIncompleteItems/ListIncompleteItemsByProjectHandler.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListIncompleteItems; 2 | 3 | public class ListIncompleteItemsByProjectHandler : IQueryHandler>> 4 | { 5 | private readonly IListIncompleteItemsQueryService _query; 6 | 7 | public ListIncompleteItemsByProjectHandler(IListIncompleteItemsQueryService query) 8 | { 9 | _query = query; 10 | } 11 | 12 | public async Task>> Handle(ListIncompleteItemsByProjectQuery request, 13 | CancellationToken cancellationToken) 14 | { 15 | var result = await _query.ListAsync(request.ProjectId); 16 | 17 | return Result.Success(result); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListIncompleteItems/ListIncompleteItemsByProjectQuery.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListIncompleteItems; 2 | 3 | public record ListIncompleteItemsByProjectQuery(int ProjectId) : IQuery>>; 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListShallow/IListProjectsShallowQueryService.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListShallow; 2 | 3 | /// 4 | /// Represents a service that will actually fetch the necessary data 5 | /// Typically implemented in Infrastructure 6 | /// 7 | public interface IListProjectsShallowQueryService 8 | { 9 | Task> ListAsync(); 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListShallow/ListProjectsShallowHandler.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListShallow; 2 | 3 | public class ListProjectsShallowHandler(IListProjectsShallowQueryService query) 4 | : IQueryHandler>> 5 | { 6 | private readonly IListProjectsShallowQueryService _query = query; 7 | 8 | public async Task>> Handle(ListProjectsShallowQuery request, CancellationToken cancellationToken) 9 | { 10 | var result = await _query.ListAsync(); 11 | 12 | return Result.Success(result); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ListShallow/ListProjectsShallowQuery.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects.ListShallow; 2 | 3 | public record ListProjectsShallowQuery(int? Skip, int? Take) : IQuery>>; 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/MarkToDoItemComplete/MarkToDoItemCompleteCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.MarkToDoItemComplete; 4 | 5 | /// 6 | /// Create a new Project. 7 | /// 8 | /// 9 | public record MarkToDoItemCompleteCommand(ProjectId ProjectId, int ToDoItemId) : Ardalis.SharedKernel.ICommand; 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/MarkToDoItemComplete/MarkToDoItemCompleteHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 3 | 4 | namespace NimblePros.SampleToDo.UseCases.Projects.MarkToDoItemComplete; 5 | 6 | public class MarkToDoItemCompleteHandler : ICommandHandler 7 | { 8 | private readonly IRepository _repository; 9 | 10 | public MarkToDoItemCompleteHandler(IRepository repository) 11 | { 12 | _repository = repository; 13 | } 14 | 15 | public async Task Handle(MarkToDoItemCompleteCommand request, 16 | CancellationToken cancellationToken) 17 | { 18 | var spec = new ProjectByIdWithItemsSpec(request.ProjectId); 19 | var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); 20 | if (entity == null) return Result.NotFound("Project not found."); 21 | 22 | var item = entity.Items.FirstOrDefault(i => i.Id == request.ToDoItemId); 23 | if (item == null) return Result.NotFound("Item not found."); 24 | 25 | item.MarkComplete(); 26 | await _repository.UpdateAsync(entity); 27 | 28 | return Result.Success(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ProjectDTO.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects; 2 | 3 | public record ProjectDTO(int Id, string Name, string Status); 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ProjectWithAllItemsDTO.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects; 2 | public record ProjectWithAllItemsDTO(int Id, string Name, List Items, string Status); 3 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/ToDoItemDTO.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UseCases.Projects; 2 | 3 | public record ToDoItemDTO(int Id, string Title, string Description, bool IsComplete, int? ContributorId); 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Update/UpdateProjectCommand.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Update; 4 | 5 | public record UpdateProjectCommand(ProjectId ProjectId, ProjectName NewName) : ICommand>; 6 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/Projects/Update/UpdateProjectHandler.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UseCases.Projects.Update; 4 | 5 | public class UpdateProjectHandler : ICommandHandler> 6 | { 7 | private readonly IRepository _repository; 8 | 9 | public UpdateProjectHandler(IRepository repository) 10 | { 11 | _repository = repository; 12 | } 13 | 14 | public async Task> Handle(UpdateProjectCommand request, CancellationToken cancellationToken) 15 | { 16 | var existingEntity = await _repository.GetByIdAsync(request.ProjectId, cancellationToken); 17 | if (existingEntity == null) 18 | { 19 | return Result.NotFound(); 20 | } 21 | 22 | existingEntity.UpdateName(request.NewName!); 23 | 24 | await _repository.UpdateAsync(existingEntity, cancellationToken); 25 | 26 | return Result.Success(new ProjectDTO(existingEntity.Id.Value, existingEntity.Name.Value, existingEntity.Status.ToString())); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.UseCases/README.md: -------------------------------------------------------------------------------- 1 | ## Use Cases Project 2 | 3 | In Clean Architecture, the Use Cases (or Application Services) project is a relatively thin layer that wraps the domain model. 4 | 5 | Use Cases are typically organized by feature. These may be simple CRUD operations or much more complex activities. 6 | 7 | Use Cases should not depend directly on infrastructure concerns, making them simple to unit test in most cases. 8 | 9 | Use Cases are often grouped into Commands and Queries, following CQRS. 10 | 11 | Having Use Cases as a separate project can reduce the amount of logic in UI and Infrastructure projects. 12 | 13 | For simpler projects, the Use Cases project can be omitted, and its behavior moved into the UI project, either as using separate services or MediatR handlers, or by simply putting the logic into the API endpoints. 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Configurations/LoggerConfig.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | 3 | namespace NimblePros.SampleToDo.Web.Configurations; 4 | 5 | public static class LoggerConfig 6 | { 7 | public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder builder) 8 | { 9 | 10 | builder.Host.UseSerilog((_, config) => config.ReadFrom.Configuration(builder.Configuration)); 11 | 12 | return builder; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Configurations/MediatrConfig.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.Infrastructure; 3 | using NimblePros.SampleToDo.UseCases.Contributors.Commands.Create; 4 | 5 | namespace NimblePros.SampleToDo.Web.Configurations; 6 | 7 | public static class MediatrConfig 8 | { 9 | public static IServiceCollection AddMediatrConfigs(this IServiceCollection services) 10 | { 11 | var mediatRAssemblies = new[] 12 | { 13 | Assembly.GetAssembly(typeof(Contributor)), // Core 14 | Assembly.GetAssembly(typeof(CreateContributorCommand)), // UseCases 15 | Assembly.GetAssembly(typeof(InfrastructureServiceExtensions)) // Infrastructure 16 | }; 17 | 18 | services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(mediatRAssemblies!)) 19 | .AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)) 20 | .AddScoped(); 21 | 22 | return services; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Configurations/ServiceConfigs.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Infrastructure; 2 | using NimblePros.SampleToDo.Core; 3 | using NimblePros.Metronome; 4 | 5 | namespace NimblePros.SampleToDo.Web.Configurations; 6 | 7 | public static class ServiceConfig 8 | { 9 | public static IServiceCollection AddServiceConfigs(this IServiceCollection services, 10 | Microsoft.Extensions.Logging.ILogger logger, 11 | WebApplicationBuilder builder) 12 | { 13 | services.AddCoreServices(logger) 14 | .AddInfrastructureServices(builder.Configuration, logger, builder.Environment.EnvironmentName) 15 | .AddMediatrConfigs(); 16 | 17 | // add a default http client 18 | services.AddHttpClient("Default") 19 | .AddMetronomeHandler(); 20 | 21 | logger.LogInformation("{Project} services registered", "Core and Infrastructure services registered"); 22 | 23 | return services; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/ContributorRecord.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Contributors; 2 | 3 | public record ContributorRecord(int Id, string Name); 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Create.CreateContributorRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | public class CreateContributorRequest 6 | { 7 | public const string Route = "/Contributors"; 8 | 9 | [Required] 10 | public string Name { get; set; } = String.Empty; 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Create.CreateContributorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Contributors; 2 | 3 | public class CreateContributorResponse 4 | { 5 | public CreateContributorResponse(int id, string name) 6 | { 7 | Id = id; 8 | Name = name; 9 | } 10 | public int Id { get; set; } 11 | public string Name { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Create.CreateContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Infrastructure.Data.Config; 2 | using FastEndpoints; 3 | using FluentValidation; 4 | 5 | namespace NimblePros.SampleToDo.Web.Contributors; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class CreateContributorValidator : Validator 11 | { 12 | public CreateContributorValidator() 13 | { 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Name is required.") 17 | .MinimumLength(2) 18 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Delete.DeleteContributorRequest.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Contributors; 2 | 3 | public record DeleteContributorRequest 4 | { 5 | public const string Route = "/Contributors/{ContributorId:int}"; 6 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 7 | 8 | public int ContributorId { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Delete.DeleteContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | 4 | namespace NimblePros.SampleToDo.Web.Contributors; 5 | 6 | /// 7 | /// See: https://fast-endpoints.com/docs/validation 8 | /// 9 | public class DeleteContributorValidator : Validator 10 | { 11 | public DeleteContributorValidator() 12 | { 13 | RuleFor(x => x.ContributorId) 14 | .GreaterThan(0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Delete.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Contributors.Commands.Delete; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | /// 6 | /// Delete a Contributor. 7 | /// 8 | /// 9 | /// Delete a Contributor by providing a valid integer id. 10 | /// 11 | public class Delete : Endpoint 12 | { 13 | private readonly IMediator _mediator; 14 | 15 | public Delete(IMediator mediator) 16 | { 17 | _mediator = mediator; 18 | } 19 | 20 | public override void Configure() 21 | { 22 | Delete(DeleteContributorRequest.Route); 23 | AllowAnonymous(); 24 | } 25 | 26 | public override async Task HandleAsync( 27 | DeleteContributorRequest request, 28 | CancellationToken cancellationToken) 29 | { 30 | var command = new DeleteContributorCommand(request.ContributorId); 31 | 32 | var result = await _mediator.Send(command); 33 | 34 | if (result.Status == ResultStatus.NotFound) 35 | { 36 | await SendNotFoundAsync(cancellationToken); 37 | return; 38 | } 39 | 40 | if (result.IsSuccess) 41 | { 42 | await SendNoContentAsync(cancellationToken); 43 | }; 44 | // TODO: Handle other issues as needed 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/GetById.GetContributorByIdRequest.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Contributors; 2 | 3 | public class GetContributorByIdRequest 4 | { 5 | public const string Route = "/Contributors/{ContributorId:int}"; 6 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 7 | 8 | public int ContributorId { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/GetById.GetContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | 4 | namespace NimblePros.SampleToDo.Web.Contributors; 5 | 6 | /// 7 | /// See: https://fast-endpoints.com/docs/validation 8 | /// 9 | public class GetContributorValidator : Validator 10 | { 11 | public GetContributorValidator() 12 | { 13 | RuleFor(x => x.ContributorId) 14 | .GreaterThan(0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/GetById.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Contributors.Queries.Get; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | /// 6 | /// Get a Contributor by integer ID. 7 | /// 8 | /// 9 | /// Takes a positive integer ID and returns a matching Contributor record. 10 | /// 11 | public class GetById : Endpoint 12 | { 13 | private readonly IMediator _mediator; 14 | 15 | public GetById(IMediator mediator) 16 | { 17 | _mediator = mediator; 18 | } 19 | 20 | public override void Configure() 21 | { 22 | Get(GetContributorByIdRequest.Route); 23 | AllowAnonymous(); 24 | } 25 | 26 | public override async Task HandleAsync(GetContributorByIdRequest request, 27 | CancellationToken cancellationToken) 28 | { 29 | var command = new GetContributorQuery(request.ContributorId); 30 | 31 | var result = await _mediator.Send(command); 32 | 33 | if (result.Status == ResultStatus.NotFound) 34 | { 35 | await SendNotFoundAsync(cancellationToken); 36 | return; 37 | } 38 | 39 | if (result.IsSuccess) 40 | { 41 | Response = new ContributorRecord(result.Value.Id, result.Value.Name); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/List.ContributorListResponse.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web.Contributors; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | public class ContributorListResponse 6 | { 7 | public List Contributors { get; set; } = new(); 8 | } 9 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/List.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.UseCases.Contributors.Queries.List; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | /// 6 | /// List all Contributors 7 | /// 8 | /// 9 | /// List all contributors - returns a ContributorListResponse containing the Contributors. 10 | /// NOTE: In DEV always returns a FAKE set of 2 contributors 11 | /// 12 | public class List : EndpointWithoutRequest 13 | { 14 | private readonly IMediator _mediator; 15 | 16 | public List(IMediator mediator) 17 | { 18 | _mediator = mediator; 19 | } 20 | 21 | public override void Configure() 22 | { 23 | Get("/Contributors"); 24 | AllowAnonymous(); 25 | } 26 | 27 | public override async Task HandleAsync(CancellationToken cancellationToken) 28 | { 29 | var result = await _mediator.Send(new ListContributorsQuery(null, null)); 30 | 31 | if (result.IsSuccess) 32 | { 33 | Response = new ContributorListResponse 34 | { 35 | Contributors = result.Value.Select(c => new ContributorRecord(c.Id, c.Name)).ToList() 36 | }; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Update.UpdateContributorRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace NimblePros.SampleToDo.Web.Contributors; 4 | 5 | public class UpdateContributorRequest 6 | { 7 | public const string Route = "/Contributors/{ContributorId:int}"; 8 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 9 | 10 | public int ContributorId { get; set; } 11 | 12 | [Required] 13 | public int Id { get; set; } 14 | [Required] 15 | public string? Name { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Update.UpdateContributorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Contributors; 2 | 3 | public class UpdateContributorResponse 4 | { 5 | public UpdateContributorResponse(ContributorRecord contributor) 6 | { 7 | Contributor = contributor; 8 | } 9 | public ContributorRecord Contributor { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Contributors/Update.UpdateContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Infrastructure.Data.Config; 2 | using FastEndpoints; 3 | using FluentValidation; 4 | 5 | namespace NimblePros.SampleToDo.Web.Contributors; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class UpdateContributorValidator : Validator 11 | { 12 | public UpdateContributorValidator() 13 | { 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Name is required.") 17 | .MinimumLength(2) 18 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 19 | RuleFor(x => x.ContributorId) 20 | .Must((args, contributorId) => args.Id == contributorId) 21 | .WithMessage("Route and body Ids must match; cannot update Id of an existing resource."); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Reflection; 2 | global using Ardalis.ListStartupServices; 3 | global using Ardalis.Result; 4 | global using Ardalis.SharedKernel; 5 | global using FastEndpoints; 6 | global using FastEndpoints.Swagger; 7 | global using MediatR; 8 | global using Microsoft.EntityFrameworkCore; 9 | global using Serilog; 10 | global using Serilog.Extensions.Logging; 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Create.CreateProjectRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace NimblePros.SampleToDo.Web.Projects; 4 | 5 | public class CreateProjectRequest 6 | { 7 | public const string Route = "/Projects"; 8 | 9 | [Required] 10 | public string? Name { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Create.CreateProjectResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class CreateProjectResponse 4 | { 5 | public CreateProjectResponse(int id, string name) 6 | { 7 | Id = id; 8 | Name = name; 9 | } 10 | 11 | public int Id { get; set; } 12 | public string Name { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Create.CreateProjectValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | using NimblePros.SampleToDo.Infrastructure.Data.Config; 4 | 5 | namespace NimblePros.SampleToDo.Web.Projects; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class CreateProjectValidator : Validator 11 | { 12 | public CreateProjectValidator() 13 | { 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Name is required.") 17 | .MinimumLength(2) 18 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Create.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.Result.AspNetCore; 2 | using NimblePros.SampleToDo.UseCases.Projects.Create; 3 | 4 | namespace NimblePros.SampleToDo.Web.Projects; 5 | 6 | /// 7 | /// Creates a new Project 8 | /// 9 | /// 10 | /// Creates a new project given a name. 11 | /// 12 | public class Create(IMediator mediator) : Endpoint 13 | { 14 | private readonly IMediator _mediator = mediator; 15 | 16 | public override void Configure() 17 | { 18 | Post(CreateProjectRequest.Route); 19 | AllowAnonymous(); 20 | Summary(s => 21 | { 22 | s.ExampleRequest = new CreateProjectRequest { Name = "Project Name" }; 23 | }); 24 | } 25 | 26 | public override async Task HandleAsync( 27 | CreateProjectRequest request, 28 | CancellationToken cancellationToken) 29 | { 30 | var result = await _mediator.Send(new CreateProjectCommand(request.Name!)); 31 | 32 | if (result.IsSuccess) 33 | { 34 | Response = new CreateProjectResponse(result.Value.Value, request.Name!); 35 | return; 36 | } 37 | await SendResultAsync(result.ToMinimalApiResult()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/CreateToDoItem.CreateToDoItemRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace NimblePros.SampleToDo.Web.Projects; 5 | 6 | public class CreateToDoItemRequest 7 | { 8 | public const string Route = "/Projects/{ProjectId:int}/ToDoItems"; 9 | public static string BuildRoute(int projectId) => Route.Replace("{ProjectId:int}", projectId.ToString()); 10 | 11 | [Required] 12 | [FromRoute] 13 | public int ProjectId { get; set; } = 0; 14 | 15 | [Required] 16 | public string Title { get; set; } = string.Empty; 17 | [Required] 18 | public string Description { get; set; } = string.Empty; 19 | 20 | public int? ContributorId { get; set; } 21 | } 22 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/CreateToDoItem.CreateToDoItemValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | using NimblePros.SampleToDo.Infrastructure.Data.Config; 4 | 5 | namespace NimblePros.SampleToDo.Web.Projects; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class CreateToDoItemValidator : Validator 11 | { 12 | public CreateToDoItemValidator() 13 | { 14 | RuleFor(x => x.ProjectId) 15 | .GreaterThan(0); 16 | RuleFor(x => x.Title) 17 | .NotEmpty() 18 | .MinimumLength(2) 19 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 20 | RuleFor(x => x.Description) 21 | .NotEmpty(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Delete.DeleteProjectRequest.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class DeleteProjectRequest 4 | { 5 | public const string Route = "/Projects/{ProjectId:int}"; 6 | public static string BuildRoute(int projectId) => Route.Replace("{ProjectId:int}", projectId.ToString()); 7 | 8 | public int ProjectId { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Delete.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.Result.AspNetCore; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate; 3 | using NimblePros.SampleToDo.UseCases.Projects.Delete; 4 | 5 | namespace NimblePros.SampleToDo.Web.Projects; 6 | 7 | /// 8 | /// Deletes a project 9 | /// 10 | public class Delete(IMediator mediator) : Endpoint 11 | { 12 | private readonly IMediator _mediator = mediator; 13 | 14 | public override void Configure() 15 | { 16 | Delete(DeleteProjectRequest.Route); 17 | AllowAnonymous(); 18 | } 19 | 20 | public override async Task HandleAsync( 21 | DeleteProjectRequest request, 22 | CancellationToken cancellationToken) 23 | { 24 | var command = new DeleteProjectCommand(ProjectId.From(request.ProjectId)); 25 | 26 | var result = await _mediator.Send(command); 27 | 28 | await SendResultAsync(result.ToMinimalApiResult()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/GetById.GetProjectByIdRequest.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace NimblePros.SampleToDo.Web.Endpoints.Projects; 3 | 4 | public class GetProjectByIdRequest 5 | { 6 | public const string Route = "/Projects/{ProjectId:int}"; 7 | public static string BuildRoute(int projectId) => Route.Replace("{ProjectId:int}", projectId.ToString()); 8 | 9 | public int ProjectId { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/GetById.GetProjectByIdResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class GetProjectByIdResponse 4 | { 5 | public GetProjectByIdResponse(int id, string name, List items) 6 | { 7 | Id = id; 8 | Name = name; 9 | Items = items; 10 | } 11 | 12 | public int Id { get; set; } 13 | public string Name { get; set; } 14 | public List Items { get; set; } = new(); 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/GetById.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.UseCases.Projects.GetWithAllItems; 3 | using NimblePros.SampleToDo.Web.Endpoints.Projects; 4 | 5 | namespace NimblePros.SampleToDo.Web.Projects; 6 | 7 | public class GetById(IMediator mediator) : Endpoint 8 | { 9 | private readonly IMediator _mediator = mediator; 10 | 11 | public override void Configure() 12 | { 13 | Get(GetProjectByIdRequest.Route); 14 | AllowAnonymous(); 15 | } 16 | 17 | public override async Task HandleAsync(GetProjectByIdRequest request, 18 | CancellationToken cancellationToken) 19 | { 20 | var command = new GetProjectWithAllItemsQuery(ProjectId.From(request.ProjectId)); 21 | 22 | var result = await _mediator.Send(command); 23 | 24 | if (result.Status == ResultStatus.NotFound) 25 | { 26 | await SendNotFoundAsync(cancellationToken); 27 | return; 28 | } 29 | 30 | if (result.IsSuccess) 31 | { 32 | Response = new GetProjectByIdResponse(result.Value.Id, 33 | result.Value.Name, 34 | items: 35 | result.Value.Items 36 | .Select(item => new ToDoItemRecord( 37 | item.Id, 38 | item.Title, 39 | item.Description, 40 | item.IsComplete, 41 | item.ContributorId 42 | )) 43 | .ToList() 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/List.ProjectListResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class ProjectListResponse 4 | { 5 | public List Projects { get; set; } = new(); 6 | } 7 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/List.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.UseCases.Projects.ListShallow; 3 | 4 | namespace NimblePros.SampleToDo.Web.Projects; 5 | 6 | /// 7 | /// Lists all projects without their sub-properties. 8 | /// 9 | /// 10 | /// Lists all projects without their sub-properties. 11 | /// NOTE: In DEV will always show a FAKE ID 1000 project, not real data 12 | /// 13 | public class List(IMediator mediator) : EndpointWithoutRequest 14 | { 15 | private readonly IMediator _mediator = mediator; 16 | 17 | public override void Configure() 18 | { 19 | Get($"/{nameof(Project)}s"); 20 | AllowAnonymous(); 21 | } 22 | 23 | public override async Task HandleAsync(CancellationToken cancellationToken) 24 | { 25 | var result = await _mediator.Send(new ListProjectsShallowQuery(null, null)); 26 | 27 | if (result.IsSuccess) 28 | { 29 | Response = new ProjectListResponse 30 | { 31 | Projects = result.Value.Select(c => new ProjectRecord(c.Id, c.Name)).ToList() 32 | }; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/ListIncompleteItems.ListIncompleteItemsRequest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace NimblePros.SampleToDo.Web.Endpoints.ProjectEndpoints; 4 | 5 | public class ListIncompleteItemsRequest 6 | { 7 | public const string Route = "/Projects/{ProjectId}/IncompleteItems"; 8 | public static string BuildRoute(int projectId) => Route.Replace("{ProjectId:int}", projectId.ToString()); 9 | 10 | 11 | [FromRoute] 12 | public int ProjectId { get; set; } 13 | //[FromQuery] 14 | //public string? SearchString { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/ListIncompleteItems.ListIncompleteItemsResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class ListIncompleteItemsResponse 4 | { 5 | public ListIncompleteItemsResponse(int projectId, List incompleteItems) 6 | { 7 | ProjectId = projectId; 8 | IncompleteItems = incompleteItems; 9 | } 10 | public int ProjectId { get; set; } 11 | public List IncompleteItems { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/MarkItemComplete.MarkItemCompleteRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace NimblePros.SampleToDo.Web.ProjectEndpoints; 5 | 6 | public class MarkItemCompleteRequest 7 | { 8 | public const string Route = "/Projects/{ProjectId:int}/ToDoItems/{ToDoItemId:int}"; 9 | public static string BuildRoute(int projectId, int toDoItemId) => Route.Replace("{ProjectId:int}", projectId.ToString()) 10 | .Replace("{ToDoItemId:int}", toDoItemId.ToString()); 11 | 12 | [Required] 13 | [FromRoute] 14 | public int ProjectId { get; set; } = 0; 15 | [Required] 16 | [FromRoute] 17 | public int ToDoItemId { get; set; } = 0; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/MarkItemComplete.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.UseCases.Projects.MarkToDoItemComplete; 3 | 4 | namespace NimblePros.SampleToDo.Web.ProjectEndpoints; 5 | 6 | /// 7 | /// Mark an item as complete 8 | /// 9 | public class MarkItemComplete(IMediator mediator) : Endpoint 10 | { 11 | private readonly IMediator _mediator = mediator; 12 | 13 | public override void Configure() 14 | { 15 | Post(MarkItemCompleteRequest.Route); 16 | AllowAnonymous(); 17 | Summary(s => 18 | { 19 | s.ExampleRequest = new MarkItemCompleteRequest 20 | { 21 | ProjectId = 1, 22 | ToDoItemId = 1 23 | }; 24 | }); 25 | } 26 | 27 | public override async Task HandleAsync( 28 | MarkItemCompleteRequest request, 29 | CancellationToken cancellationToken) 30 | { 31 | var command = new MarkToDoItemCompleteCommand(ProjectId.From(request.ProjectId), request.ToDoItemId); 32 | var result = await _mediator.Send(command); 33 | 34 | if (result.Status == Ardalis.Result.ResultStatus.NotFound) 35 | { 36 | await SendNotFoundAsync(cancellationToken); 37 | return; 38 | } 39 | 40 | if (result.IsSuccess) 41 | { 42 | await SendNoContentAsync(cancellationToken); 43 | }; 44 | // TODO: Handle other issues as needed 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/ProjectRecord.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public record ProjectRecord(int Id, string Name); 4 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/ToDoItemRecord.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | public record ToDoItemRecord(int Id, string Title, string Description, bool IsDone, int? ContributorId); 3 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Update.UpdateProjectRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace NimblePros.SampleToDo.Web.Projects; 4 | 5 | public class UpdateProjectRequest 6 | { 7 | public const string Route = "/Projects"; 8 | public int Id { get; set; } 9 | public string? Name { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Update.UpdateProjectRequestValidator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace NimblePros.SampleToDo.Web.Projects; 4 | 5 | public class UpdateProjectRequestValidator : Validator 6 | { 7 | public UpdateProjectRequestValidator() 8 | { 9 | RuleFor(x => x.Id) 10 | .GreaterThan(0).WithMessage("Id must be a positive integer."); 11 | 12 | RuleFor(x => x.Name) 13 | .NotEmpty().WithMessage("Name is required.") 14 | .Must(name => !string.IsNullOrWhiteSpace(name)) 15 | .WithMessage("Name cannot be empty or whitespace."); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Update.UpdateProjectResponse.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.Web.Projects; 2 | 3 | public class UpdateProjectResponse 4 | { 5 | public UpdateProjectResponse(ProjectRecord project) 6 | { 7 | Project = project; 8 | } 9 | public ProjectRecord Project { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Projects/Update.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.Result.AspNetCore; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate; 3 | using NimblePros.SampleToDo.UseCases.Projects.Update; 4 | 5 | namespace NimblePros.SampleToDo.Web.Projects; 6 | 7 | public class Update(IMediator mediator) : Endpoint 8 | { 9 | private readonly IMediator _mediator = mediator; 10 | 11 | public override void Configure() 12 | { 13 | Put(UpdateProjectRequest.Route); 14 | AllowAnonymous(); 15 | } 16 | 17 | public override async Task HandleAsync( 18 | UpdateProjectRequest request, 19 | CancellationToken cancellationToken) 20 | { 21 | var result = await _mediator.Send(new UpdateProjectCommand(ProjectId.From(request.Id), ProjectName.From(request.Name!))); 22 | 23 | await SendResultAsync(result.ToMinimalApiResult()); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:57677/", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "https": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchUrl": "swagger", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | }, 18 | "applicationUrl": "https://localhost:57678/" 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Server=(localdb)\\v11.0;Database=cleanarchitecture;Trusted_Connection=True;MultipleActiveResultSets=true", 4 | "SqliteConnection": "Data Source=database.sqlite" 5 | }, 6 | "Serilog": { 7 | "MinimumLevel": { 8 | "Default": "Information" 9 | }, 10 | "WriteTo": [ 11 | { 12 | "Name": "Console" 13 | }, 14 | { 15 | "Name": "File", 16 | "Args": { 17 | "path": "log.txt", 18 | "rollingInterval": "Day" 19 | } 20 | } 21 | //Uncomment this section if you'd like to push your logs to Azure Application Insights 22 | //Full list of Serilog Sinks can be found here: https://github.com/serilog/serilog/wiki/Provided-Sinks 23 | //{ 24 | // "Name": "ApplicationInsights", 25 | // "Args": { 26 | // "instrumentationKey": "", //Fill in with your ApplicationInsights InstrumentationKey 27 | // "telemetryConverter": "Serilog.Sinks.ApplicationInsights.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" 28 | // } 29 | //} 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/css/site.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 14px; 3 | } 4 | 5 | @media (min-width: 768px) { 6 | html { 7 | font-size: 16px; 8 | } 9 | } 10 | 11 | html { 12 | position: relative; 13 | min-height: 100%; 14 | } 15 | 16 | body { 17 | margin-bottom: 60px; 18 | } -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardalis/CleanArchitecture/7c031c77e6b8db695f3266a5f5873a522bbb238a/sample/src/NimblePros.SampleToDo.Web/wwwroot/favicon.ico -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/js/site.js: -------------------------------------------------------------------------------- 1 | // Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification 2 | // for details on configuring this project to bundle and minify static web assets. 3 | 4 | // Write your JavaScript code. 5 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/lib/bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2021 Twitter, Inc. 4 | Copyright (c) 2011-2021 The Bootstrap Authors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) .NET Foundation. All rights reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 4 | these files except in compliance with the License. You may obtain a copy of the 5 | License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software distributed 10 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 11 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 12 | specific language governing permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /sample/src/NimblePros.SampleToDo.Web/wwwroot/lib/jquery-validation/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright Jörn Zaefferer 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Contributors/ContributorCreate.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web.Contributors; 2 | using Shouldly; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Contributors; 5 | 6 | [Collection("Sequential")] 7 | public class ContributorCreate : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ContributorCreate(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task ReturnsOneContributor() 18 | { 19 | var testName = Guid.NewGuid().ToString(); 20 | var request = new CreateContributorRequest() { Name = testName }; 21 | var content = StringContentHelpers.FromModelAsJson(request); 22 | 23 | var result = await _client.PostAndDeserializeAsync( 24 | CreateContributorRequest.Route, content); 25 | 26 | result.Name.ShouldBe(testName); 27 | result.Id.ShouldBeGreaterThan(0); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Contributors/ContributorDelete.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web; 2 | using NimblePros.SampleToDo.Web.Contributors; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Contributors; 5 | 6 | [Collection("Sequential")] 7 | public class ContributorDelete : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ContributorDelete(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task DeletesExistingContributor() 18 | { 19 | var deleteRoute = DeleteContributorRequest.BuildRoute(SeedData.Contributor1.Id); 20 | _ = await _client.DeleteAndEnsureNoContentAsync(deleteRoute); 21 | 22 | string getRoute = GetContributorByIdRequest.BuildRoute(SeedData.Contributor1.Id); 23 | _ = await _client.GetAndEnsureNotFoundAsync(getRoute); 24 | } 25 | 26 | [Fact] 27 | public async Task ReturnsNotFoundGivenMissingContributorId() 28 | { 29 | int invalidId = 1000; 30 | var deleteRoute = DeleteContributorRequest.BuildRoute(invalidId); 31 | _ = await _client.DeleteAndEnsureNotFoundAsync(deleteRoute); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Contributors/ContributorGetById.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web; 2 | using NimblePros.SampleToDo.Web.Contributors; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Contributors; 5 | 6 | [Collection("Sequential")] 7 | public class ContributorGetById : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ContributorGetById(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task ReturnsSeedContributorGivenId1() 18 | { 19 | var result = await _client.GetAndDeserializeAsync(GetContributorByIdRequest.BuildRoute(1)); 20 | 21 | Assert.Equal(1, result.Id); 22 | Assert.Equal(SeedData.Contributor1.Name.Value, result.Name); 23 | } 24 | 25 | [Fact] 26 | public async Task ReturnsNotFoundGivenInvalidId1000() 27 | { 28 | string route = GetContributorByIdRequest.BuildRoute(1000); 29 | _ = await _client.GetAndEnsureNotFoundAsync(route); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Contributors/ContributorList.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web; 2 | using NimblePros.SampleToDo.Web.Contributors; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Contributors; 5 | 6 | [Collection("Sequential")] 7 | public class ContributorList : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ContributorList(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task ReturnsTwoContributors() 18 | { 19 | var result = await _client.GetAndDeserializeAsync("/Contributors"); 20 | 21 | Assert.Equal(2, result.Contributors.Count); 22 | Assert.Contains(result.Contributors, i => i.Name == SeedData.Contributor1.Name); 23 | Assert.Contains(result.Contributors, i => i.Name == SeedData.Contributor2.Name); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Text; 2 | global using Ardalis.HttpClientTestExtensions; 3 | global using DotNet.Testcontainers.Builders; 4 | global using DotNet.Testcontainers.Containers; 5 | global using Microsoft.AspNetCore.Hosting; 6 | global using Microsoft.AspNetCore.Mvc.Testing; 7 | global using Microsoft.EntityFrameworkCore; 8 | global using Microsoft.Extensions.DependencyInjection; 9 | global using Microsoft.Extensions.Hosting; 10 | global using Microsoft.Extensions.Logging; 11 | global using Newtonsoft.Json; 12 | global using Xunit; 13 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Projects/ProjectCreate.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web.Projects; 2 | using Shouldly; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Projects; 5 | 6 | [Collection("Sequential")] 7 | public class ProjectCreate : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ProjectCreate(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task ReturnsOneProject() 18 | { 19 | var testName = Guid.NewGuid().ToString(); 20 | var request = new CreateProjectRequest() { Name = testName }; 21 | var content = StringContentHelpers.FromModelAsJson(request); 22 | 23 | var result = await _client.PostAndDeserializeAsync( 24 | CreateProjectRequest.Route, content); 25 | 26 | result.Name.ShouldBe(testName); 27 | result.Id.ShouldBeGreaterThan(0); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Projects/ProjectGetById.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web; 2 | using NimblePros.SampleToDo.Web.Endpoints.Projects; 3 | using NimblePros.SampleToDo.Web.Projects; 4 | using Shouldly; 5 | 6 | namespace NimblePros.SampleToDo.FunctionalTests.Projects; 7 | 8 | [Collection("Sequential")] 9 | public class ProjectGetById : IClassFixture> 10 | { 11 | private readonly HttpClient _client; 12 | 13 | public ProjectGetById(CustomWebApplicationFactory factory) 14 | { 15 | _client = factory.CreateClient(); 16 | } 17 | 18 | [Fact] 19 | public async Task ReturnsSeedProjectGivenId1() 20 | { 21 | var result = await _client.GetAndDeserializeAsync(GetProjectByIdRequest.BuildRoute(1)); 22 | 23 | result.Id.ShouldBe(1); 24 | result.Name.ShouldBe(SeedData.TestProject1.Name.Value); 25 | result.Items.Count.ShouldBe(3); 26 | } 27 | 28 | [Fact] 29 | public async Task ReturnsNotFoundGivenId0() 30 | { 31 | var route = GetProjectByIdRequest.BuildRoute(0); 32 | _ = await _client.GetAndEnsureNotFoundAsync(route); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/Projects/ProjectList.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Web; 2 | using NimblePros.SampleToDo.Web.Projects; 3 | 4 | namespace NimblePros.SampleToDo.FunctionalTests.Projects; 5 | 6 | [Collection("Sequential")] 7 | public class ProjectList : IClassFixture> 8 | { 9 | private readonly HttpClient _client; 10 | 11 | public ProjectList(CustomWebApplicationFactory factory) 12 | { 13 | _client = factory.CreateClient(); 14 | } 15 | 16 | [Fact] 17 | public async Task ReturnsOneProject() 18 | { 19 | var result = await _client.GetAndDeserializeAsync("/Projects"); 20 | 21 | Assert.Single(result.Projects); 22 | Assert.Contains(result.Projects, i => i.Name == SeedData.TestProject1.Name); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.FunctionalTests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "shadowCopy": false, 3 | "parallelizeAssembly": false, 4 | "parallelizeTestCollections": false 5 | } -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.IntegrationTests/Data/BaseEfRepoTestFixture.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.Infrastructure.Data; 3 | 4 | namespace NimblePros.SampleToDo.IntegrationTests.Data; 5 | 6 | public abstract class BaseEfRepoTestFixture 7 | { 8 | protected AppDbContext _dbContext; 9 | 10 | protected BaseEfRepoTestFixture() 11 | { 12 | var options = CreateNewContextOptions(); 13 | var _fakeEventDispatcher = Substitute.For(); 14 | 15 | _dbContext = new AppDbContext(options, _fakeEventDispatcher); 16 | } 17 | 18 | protected static DbContextOptions CreateNewContextOptions() 19 | { 20 | // Create a fresh service provider, and therefore a fresh 21 | // InMemory database instance. 22 | var serviceProvider = new ServiceCollection() 23 | .AddEntityFrameworkInMemoryDatabase() 24 | .BuildServiceProvider(); 25 | 26 | // Create a new options instance telling the context to use an 27 | // InMemory database and the new service provider. 28 | var builder = new DbContextOptionsBuilder(); 29 | builder.UseInMemoryDatabase("cleanarchitecture") 30 | .UseInternalServiceProvider(serviceProvider); 31 | 32 | return builder.Options; 33 | } 34 | 35 | protected EfRepository GetRepository() 36 | { 37 | return new EfRepository(_dbContext); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.IntegrationTests/Data/EfRepositoryAdd.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.IntegrationTests.Data; 4 | 5 | public class EfRepositoryAdd : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task AddsProjectAndSetsId() 9 | { 10 | var testProjectName = ProjectName.From("testProject"); 11 | var repository = GetRepository(); 12 | var project = new Project(testProjectName); 13 | 14 | var item = new ToDoItem(); 15 | item.Title = "test item title"; 16 | project.AddItem(item); 17 | 18 | await repository.AddAsync(project); 19 | 20 | var newProject = (await repository.ListAsync()) 21 | .FirstOrDefault(); 22 | 23 | Assert.Equal(testProjectName, newProject?.Name); 24 | Assert.True(newProject?.Id.Value > 0); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.IntegrationTests/Data/EfRepositoryDelete.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.IntegrationTests.Data; 4 | 5 | public class EfRepositoryDelete : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task DeletesItemAfterAddingIt() 9 | { 10 | // add a project 11 | var repository = GetRepository(); 12 | var initialName = Guid.NewGuid().ToString(); 13 | var project = new Project(ProjectName.From(initialName)); 14 | await repository.AddAsync(project); 15 | 16 | // delete the item 17 | await repository.DeleteAsync(project); 18 | 19 | // verify it's no longer there 20 | Assert.DoesNotContain(await repository.ListAsync(), 21 | project => project.Name == initialName); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.IntegrationTests/Data/EfRepositoryUpdate.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.IntegrationTests.Data; 4 | 5 | public class EfRepositoryUpdate : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task UpdatesItemAfterAddingIt() 9 | { 10 | // add a project 11 | var repository = GetRepository(); 12 | var initialName = Guid.NewGuid().ToString(); 13 | var project = new Project(ProjectName.From(initialName)); 14 | 15 | await repository.AddAsync(project); 16 | 17 | // detach the item so we get a different instance 18 | _dbContext.Entry(project).State = EntityState.Detached; 19 | 20 | // fetch the item and update its title 21 | var newProject = (await repository.ListAsync()) 22 | .FirstOrDefault(project => project.Name == initialName); 23 | if (newProject == null) 24 | { 25 | Assert.NotNull(newProject); 26 | return; 27 | } 28 | Assert.NotSame(project, newProject); 29 | var newName = Guid.NewGuid().ToString(); 30 | newProject.UpdateName(ProjectName.From(newName)); 31 | 32 | // Update the item 33 | await repository.UpdateAsync(newProject); 34 | 35 | // Fetch the updated item 36 | var updatedItem = (await repository.ListAsync()) 37 | .FirstOrDefault(project => project.Name == newName); 38 | 39 | Assert.NotNull(updatedItem); 40 | Assert.NotEqual(project.Name, updatedItem?.Name); 41 | Assert.Equal(newProject.Id, updatedItem?.Id); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.IntegrationTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.SharedKernel; 2 | global using Microsoft.EntityFrameworkCore; 3 | global using NSubstitute; 4 | global using Xunit; 5 | global using Microsoft.Extensions.DependencyInjection; 6 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/Handlers/ItemCompletedEmailNotificationHandlerHandle.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.Interfaces; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate; 3 | using NimblePros.SampleToDo.Core.ProjectAggregate.Events; 4 | using NimblePros.SampleToDo.Core.ProjectAggregate.Handlers; 5 | 6 | namespace NimblePros.SampleToDo.UnitTests.Core.Handlers; 7 | 8 | public class ItemCompletedEmailNotificationHandlerHandle 9 | { 10 | private ItemCompletedEmailNotificationHandler _handler; 11 | private IEmailSender _emailSender = Substitute.For(); 12 | 13 | public ItemCompletedEmailNotificationHandlerHandle() 14 | { 15 | _handler = new ItemCompletedEmailNotificationHandler(_emailSender); 16 | } 17 | 18 | [Fact] 19 | public async Task ThrowsExceptionGivenNullEventArgument() 20 | { 21 | #nullable disable 22 | Exception ex = await Assert.ThrowsAsync(() => _handler.Handle(null, CancellationToken.None)); 23 | #nullable enable 24 | } 25 | 26 | [Fact] 27 | public async Task SendsEmailGivenEventInstance() 28 | { 29 | await _handler.Handle(new ToDoItemCompletedEvent(new ToDoItem()), CancellationToken.None); 30 | 31 | await _emailSender.Received().SendEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/ProjectAggregate/ProjectConstructor.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UnitTests.Core.ProjectAggregate; 4 | 5 | public class ProjectConstructor 6 | { 7 | private string _testName = "test name"; 8 | private Priority _testPriority = Priority.Backlog; 9 | private Project? _testProject; 10 | 11 | private Project CreateProject() 12 | { 13 | return new Project(ProjectName.From(_testName)); 14 | } 15 | 16 | [Fact] 17 | public void InitializesName() 18 | { 19 | _testProject = CreateProject(); 20 | 21 | Assert.Equal(_testName, _testProject.Name.Value); 22 | } 23 | 24 | [Fact] 25 | public void InitializesTaskListToEmptyList() 26 | { 27 | _testProject = CreateProject(); 28 | 29 | Assert.NotNull(_testProject.Items); 30 | } 31 | 32 | [Fact] 33 | public void InitializesStatusToInProgress() 34 | { 35 | _testProject = CreateProject(); 36 | 37 | Assert.Equal(ProjectStatus.Complete, _testProject.Status); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/ProjectAggregate/ProjectNameFrom.cs: -------------------------------------------------------------------------------- 1 | using Shouldly; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate; 3 | using Vogen; 4 | 5 | namespace NimblePros.SampleToDo.UnitTests.Core.ProjectAggregate; 6 | 7 | public class ProjectNameFrom 8 | { 9 | [Theory] 10 | [InlineData("")] 11 | [InlineData(null!)] 12 | public void ThrowsGivenNullOrEmpty(string name) 13 | { 14 | Should.Throw(() => ProjectName.From(name)); 15 | } 16 | 17 | [Fact] 18 | public void DoesNotThrowGivenValidData() 19 | { 20 | string validName = "valid name"; 21 | var name = ProjectName.From(validName); 22 | name.Value.ShouldBe(validName); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/ProjectAggregate/Project_AddItem.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UnitTests.Core.ProjectAggregate; 4 | 5 | public class Project_AddItem 6 | { 7 | private Project _testProject = new Project(ProjectName.From("some name")); 8 | 9 | [Fact] 10 | public void AddsItemToItems() 11 | { 12 | var _testItem = new ToDoItem 13 | { 14 | Title = "title", 15 | Description = "description" 16 | }; 17 | 18 | _testProject.AddItem(_testItem); 19 | 20 | Assert.Contains(_testItem, _testProject.Items); 21 | } 22 | 23 | [Fact] 24 | public void ThrowsExceptionGivenNullItem() 25 | { 26 | #nullable disable 27 | Action action = () => _testProject.AddItem(null); 28 | #nullable enable 29 | 30 | var ex = Assert.Throws(action); 31 | Assert.Equal("newItem", ex.ParamName); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/ProjectAggregate/ToDoItemConstructor.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using Xunit; 3 | 4 | namespace NimblePros.SampleToDo.UnitTests.Core.ProjectAggregate; 5 | 6 | public class ToDoItemConstructor 7 | { 8 | [Fact] 9 | public void InitializesPriority() 10 | { 11 | var item = new ToDoItemBuilder() 12 | .WithDefaultValues() 13 | .Build(); 14 | 15 | Assert.Equal(item.Priority, Priority.Backlog); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/ProjectAggregate/ToDoItemMarkComplete.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate.Events; 2 | 3 | namespace NimblePros.SampleToDo.UnitTests.Core.ProjectAggregate; 4 | 5 | public class ToDoItemMarkComplete 6 | { 7 | [Fact] 8 | public void SetsIsDoneToTrue() 9 | { 10 | var item = new ToDoItemBuilder() 11 | .WithDefaultValues() 12 | .Description("") 13 | .Build(); 14 | 15 | item.MarkComplete(); 16 | 17 | Assert.True(item.IsDone); 18 | } 19 | 20 | [Fact] 21 | public void RaisesToDoItemCompletedEvent() 22 | { 23 | var item = new ToDoItemBuilder().Build(); 24 | 25 | item.MarkComplete(); 26 | 27 | Assert.Single(item.DomainEvents); 28 | Assert.IsType(item.DomainEvents.First()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.Core.Services; 3 | 4 | namespace NimblePros.SampleToDo.UnitTests.Core.Services; 5 | 6 | public class DeleteContributorService_DeleteContributor 7 | { 8 | private readonly IRepository _repository = Substitute.For>(); 9 | private readonly IMediator _mediator = Substitute.For(); 10 | private readonly ILogger _logger = Substitute.For>(); 11 | 12 | private readonly DeleteContributorService _service; 13 | 14 | public DeleteContributorService_DeleteContributor() 15 | { 16 | _service = new DeleteContributorService(_repository, _mediator, _logger); 17 | } 18 | 19 | [Fact] 20 | public async Task ReturnsNotFoundGivenCantFindContributor() 21 | { 22 | var result = await _service.DeleteContributor(0); 23 | 24 | Assert.Equal(Ardalis.Result.ResultStatus.NotFound, result.Status); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/Core/Specifications/IncompleteItemSpecificationsConstructor.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | using NimblePros.SampleToDo.Core.ProjectAggregate.Specifications; 3 | 4 | namespace NimblePros.SampleToDo.UnitTests.Core.Specifications; 5 | 6 | public class IncompleteItemsSpecificationConstructor 7 | { 8 | [Fact] 9 | public void FilterCollectionToOnlyReturnItemsWithIsDoneFalse() 10 | { 11 | var item1 = new ToDoItem(); 12 | var item2 = new ToDoItem(); 13 | var item3 = new ToDoItem(); 14 | item3.MarkComplete(); 15 | 16 | var items = new List() { item1, item2, item3 }; 17 | 18 | var spec = new IncompleteItemsSpec(); 19 | 20 | var filteredList = spec.Evaluate(items); 21 | 22 | Assert.Contains(item1, filteredList); 23 | Assert.Contains(item2, filteredList); 24 | Assert.DoesNotContain(item3, filteredList); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Runtime.CompilerServices; 2 | global using Ardalis.Result; 3 | global using Ardalis.SharedKernel; 4 | global using Ardalis.Specification; 5 | global using MediatR; 6 | global using Microsoft.Extensions.Logging; 7 | global using NSubstitute; 8 | global using NSubstitute.ReturnsExtensions; 9 | global using Xunit; 10 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/NoOpMediator.cs: -------------------------------------------------------------------------------- 1 | namespace NimblePros.SampleToDo.UnitTests; 2 | 3 | public class NoOpMediator : IMediator 4 | { 5 | public Task Publish(object notification, CancellationToken cancellationToken = default) 6 | { 7 | return Task.CompletedTask; 8 | } 9 | 10 | public Task Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification 11 | { 12 | return Task.CompletedTask; 13 | } 14 | 15 | public Task Send(IRequest request, CancellationToken cancellationToken = default) 16 | { 17 | return Task.FromResult(default!); 18 | } 19 | 20 | public Task Send(object request, CancellationToken cancellationToken = default) 21 | { 22 | return Task.FromResult(default); 23 | } 24 | 25 | public async IAsyncEnumerable CreateStream(IStreamRequest request, 26 | [EnumeratorCancellation] CancellationToken cancellationToken = default) 27 | { 28 | await Task.CompletedTask; 29 | yield break; 30 | } 31 | 32 | public async IAsyncEnumerable CreateStream(object request, 33 | [EnumeratorCancellation] CancellationToken cancellationToken = default) 34 | { 35 | await Task.CompletedTask; 36 | yield break; 37 | } 38 | 39 | public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest 40 | { 41 | return Task.CompletedTask; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/ToDoItemBuilder.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ProjectAggregate; 2 | 3 | namespace NimblePros.SampleToDo.UnitTests; 4 | 5 | // Learn more about test builders: 6 | // https://ardalis.com/improve-tests-with-the-builder-pattern-for-test-data 7 | public class ToDoItemBuilder 8 | { 9 | private ToDoItem _todo = new ToDoItem(); 10 | 11 | public ToDoItemBuilder Id(int id) 12 | { 13 | _todo.Id = ToDoItemId.From(id); 14 | return this; 15 | } 16 | 17 | public ToDoItemBuilder Title(string title) 18 | { 19 | _todo.Title = title; 20 | return this; 21 | } 22 | 23 | public ToDoItemBuilder Description(string description) 24 | { 25 | _todo.Description = description; 26 | return this; 27 | } 28 | 29 | public ToDoItemBuilder WithDefaultValues() 30 | { 31 | _todo = new ToDoItem() { Id = ToDoItemId.From(1), Title = "Test Item", Description = "Test Description" }; 32 | 33 | return this; 34 | } 35 | 36 | public ToDoItem Build() => _todo; 37 | } 38 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs: -------------------------------------------------------------------------------- 1 | using NimblePros.SampleToDo.Core.ContributorAggregate; 2 | using NimblePros.SampleToDo.UseCases.Contributors.Commands.Create; 3 | using Shouldly; 4 | 5 | namespace NimblePros.SampleToDo.UnitTests.UseCases.Contributors; 6 | public class CreateContributorHandlerHandle 7 | { 8 | private readonly string _testName = "test name"; 9 | private readonly IRepository _repository = Substitute.For>(); 10 | private CreateContributorHandler _handler; 11 | 12 | public CreateContributorHandlerHandle() 13 | { 14 | _handler = new CreateContributorHandler(_repository); 15 | } 16 | 17 | private Contributor CreateContributor() 18 | { 19 | return new Contributor(ContributorName.From(_testName)); 20 | } 21 | 22 | [Fact] 23 | public async Task ReturnsSuccessGivenValidName() 24 | { 25 | _repository.AddAsync(Arg.Any(), Arg.Any()) 26 | .Returns(Task.FromResult(CreateContributor())); 27 | var result = await _handler.Handle(new CreateContributorCommand(ContributorName.From(_testName)), CancellationToken.None); 28 | 29 | result.IsSuccess.ShouldBeTrue(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sample/tests/NimblePros.SampleToDo.UnitTests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "shadowCopy": false, 3 | "parallelizeAssembly": false, 4 | "parallelizeTestCollections": false 5 | } -------------------------------------------------------------------------------- /src/Clean.Architecture.AspireHost/Clean.Architecture.AspireHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | net9.0 8 | enable 9 | enable 10 | true 11 | c540eeb6-e06b-4456-a539-be58dd8b88c7 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Clean.Architecture.AspireHost/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = DistributedApplication.CreateBuilder(args); 2 | 3 | builder.AddProject("web"); 4 | 5 | builder.Build().Run(); 6 | -------------------------------------------------------------------------------- /src/Clean.Architecture.AspireHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17143;http://localhost:15258", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21007", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22245" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15258", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19187", 25 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20134" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Clean.Architecture.AspireHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Clean.Architecture.AspireHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/Clean.Architecture.Core.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/ContributorAggregate/Contributor.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.ContributorAggregate; 2 | 3 | public class Contributor(string name) : EntityBase, IAggregateRoot 4 | { 5 | // Example of validating primary constructor inputs 6 | // See: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/primary-constructors#initialize-base-class 7 | public string Name { get; private set; } = Guard.Against.NullOrEmpty(name, nameof(name)); 8 | public ContributorStatus Status { get; private set; } = ContributorStatus.NotSet; 9 | public PhoneNumber? PhoneNumber { get; private set; } 10 | 11 | public void SetPhoneNumber(string phoneNumber) => PhoneNumber = new PhoneNumber(string.Empty, phoneNumber, string.Empty); 12 | 13 | public void UpdateName(string newName) => Name = Guard.Against.NullOrEmpty(newName, nameof(newName)); 14 | } 15 | 16 | public class PhoneNumber(string countryCode, 17 | string number, 18 | string? extension) : ValueObject 19 | { 20 | public string CountryCode { get; private set; } = countryCode; 21 | public string Number { get; private set; } = number; 22 | public string? Extension { get; private set; } = extension; 23 | 24 | protected override IEnumerable GetEqualityComponents() 25 | { 26 | yield return CountryCode; 27 | yield return Number; 28 | yield return Extension ?? String.Empty; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/ContributorAggregate/ContributorStatus.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.ContributorAggregate; 2 | 3 | public class ContributorStatus : SmartEnum 4 | { 5 | public static readonly ContributorStatus CoreTeam = new(nameof(CoreTeam), 1); 6 | public static readonly ContributorStatus Community = new(nameof(Community), 2); 7 | public static readonly ContributorStatus NotSet = new(nameof(NotSet), 3); 8 | 9 | protected ContributorStatus(string name, int value) : base(name, value) { } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/ContributorAggregate/Events/ContributorDeletedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.ContributorAggregate.Events; 2 | 3 | /// 4 | /// A domain event that is dispatched whenever a contributor is deleted. 5 | /// The DeleteContributorService is used to dispatch this event. 6 | /// 7 | internal sealed class ContributorDeletedEvent(int contributorId) : DomainEventBase 8 | { 9 | public int ContributorId { get; init; } = contributorId; 10 | } 11 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/ContributorAggregate/Handlers/ContributorDeletedHandler.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate.Events; 2 | using Clean.Architecture.Core.Interfaces; 3 | 4 | namespace Clean.Architecture.Core.ContributorAggregate.Handlers; 5 | 6 | /// 7 | /// NOTE: Internal because ContributorDeleted is also marked as internal. 8 | /// 9 | internal class ContributorDeletedHandler(ILogger logger, 10 | IEmailSender emailSender) : INotificationHandler 11 | { 12 | public async Task Handle(ContributorDeletedEvent domainEvent, CancellationToken cancellationToken) 13 | { 14 | logger.LogInformation("Handling Contributed Deleted event for {contributorId}", domainEvent.ContributorId); 15 | 16 | await emailSender.SendEmailAsync("to@test.com", 17 | "from@test.com", 18 | "Contributor Deleted", 19 | $"Contributor with id {domainEvent.ContributorId} was deleted."); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/ContributorAggregate/Specifications/ContributorByIdSpec.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.ContributorAggregate.Specifications; 2 | 3 | public class ContributorByIdSpec : Specification 4 | { 5 | public ContributorByIdSpec(int contributorId) => 6 | Query 7 | .Where(contributor => contributor.Id == contributorId); 8 | } 9 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.GuardClauses; 2 | global using Ardalis.Result; 3 | global using Ardalis.SharedKernel; 4 | global using Ardalis.SmartEnum; 5 | global using Ardalis.Specification; 6 | global using MediatR; 7 | global using Microsoft.Extensions.Logging; 8 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/Interfaces/IDeleteContributorService.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.Interfaces; 2 | 3 | public interface IDeleteContributorService 4 | { 5 | // This service and method exist to provide a place in which to fire domain events 6 | // when deleting this aggregate root entity 7 | public Task DeleteContributor(int contributorId); 8 | } 9 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/Interfaces/IEmailSender.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Core.Interfaces; 2 | 3 | public interface IEmailSender 4 | { 5 | Task SendEmailAsync(string to, string from, string subject, string body); 6 | } 7 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/README.md: -------------------------------------------------------------------------------- 1 | ## Core (Domain Model) Project 2 | 3 | In Clean Architecture, the central focus should be on Entities and business rules. 4 | 5 | In Domain-Driven Design, this is the Domain Model. 6 | 7 | This project should contain all of your Entities, Value Objects, and business logic. 8 | 9 | Entities that are related and should change together should be grouped into an Aggregate. 10 | 11 | Entities should leverage encapsulation and should minimize public setters. 12 | 13 | Entities can leverage Domain Events to communicate changes to other parts of the system. 14 | 15 | Entities can define Specifications that can be used to query for them. 16 | 17 | For mutable access, Entities should be accessed through a Repository interface. 18 | 19 | Read-only ad hoc queries can use separate Query Services that don't use the Domain Model. 20 | 21 | Need help? Check out the sample here: 22 | https://github.com/ardalis/CleanArchitecture/tree/main/sample 23 | 24 | Still need help? 25 | Contact us at https://nimblepros.com 26 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Core/Services/DeleteContributorService.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | using Clean.Architecture.Core.ContributorAggregate.Events; 3 | using Clean.Architecture.Core.Interfaces; 4 | 5 | 6 | namespace Clean.Architecture.Core.Services; 7 | 8 | /// 9 | /// This is here mainly so there's an example of a domain service 10 | /// and also to demonstrate how to fire domain events from a service. 11 | /// 12 | /// 13 | /// 14 | /// 15 | public class DeleteContributorService(IRepository _repository, 16 | IMediator _mediator, 17 | ILogger _logger) : IDeleteContributorService 18 | { 19 | public async Task DeleteContributor(int contributorId) 20 | { 21 | _logger.LogInformation("Deleting Contributor {contributorId}", contributorId); 22 | Contributor? aggregateToDelete = await _repository.GetByIdAsync(contributorId); 23 | if (aggregateToDelete == null) return Result.NotFound(); 24 | 25 | await _repository.DeleteAsync(aggregateToDelete); 26 | var domainEvent = new ContributorDeletedEvent(contributorId); 27 | await _mediator.Publish(domainEvent); 28 | 29 | return Result.Success(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Clean.Architecture.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/AppDbContext.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.Infrastructure.Data; 4 | public class AppDbContext(DbContextOptions options, 5 | IDomainEventDispatcher? dispatcher) : DbContext(options) 6 | { 7 | private readonly IDomainEventDispatcher? _dispatcher = dispatcher; 8 | 9 | public DbSet Contributors => Set(); 10 | 11 | protected override void OnModelCreating(ModelBuilder modelBuilder) 12 | { 13 | base.OnModelCreating(modelBuilder); 14 | modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 15 | } 16 | 17 | public override async Task SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken()) 18 | { 19 | int result = await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false); 20 | 21 | // ignore events if no dispatcher provided 22 | if (_dispatcher == null) return result; 23 | 24 | // dispatch events only if save was successful 25 | var entitiesWithEvents = ChangeTracker.Entries() 26 | .Select(e => e.Entity) 27 | .Where(e => e.DomainEvents.Any()) 28 | .ToArray(); 29 | 30 | await _dispatcher.DispatchAndClearEvents(entitiesWithEvents); 31 | 32 | return result; 33 | } 34 | 35 | public override int SaveChanges() => 36 | SaveChangesAsync().GetAwaiter().GetResult(); 37 | } 38 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/AppDbContextExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Infrastructure.Data; 2 | 3 | public static class AppDbContextExtensions 4 | { 5 | public static void AddApplicationDbContext(this IServiceCollection services, string connectionString) => 6 | services.AddDbContext(options => 7 | options.UseSqlite(connectionString)); 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/Config/ContributorConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.Infrastructure.Data.Config; 4 | 5 | public class ContributorConfiguration : IEntityTypeConfiguration 6 | { 7 | public void Configure(EntityTypeBuilder builder) 8 | { 9 | builder.Property(p => p.Name) 10 | .HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH) 11 | .IsRequired(); 12 | 13 | builder.OwnsOne(builder => builder.PhoneNumber); 14 | 15 | builder.Property(x => x.Status) 16 | .HasConversion( 17 | x => x.Value, 18 | x => ContributorStatus.FromValue(x)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/Config/DataSchemaConstants.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Infrastructure.Data.Config; 2 | 3 | public static class DataSchemaConstants 4 | { 5 | public const int DEFAULT_NAME_LENGTH = 100; 6 | } 7 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/EfRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Infrastructure.Data; 2 | 3 | // inherit from Ardalis.Specification type 4 | public class EfRepository(AppDbContext dbContext) : 5 | RepositoryBase(dbContext), IReadRepository, IRepository where T : class, IAggregateRoot 6 | { 7 | } 8 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/Migrations/20231218143922_PhoneNumber.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Clean.Architecture.Infrastructure.Data.Migrations; 6 | 7 | /// 8 | public partial class PhoneNumber : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.CreateTable( 14 | name: "Contributors", 15 | columns: table => new 16 | { 17 | Id = table.Column(type: "INTEGER", nullable: false) 18 | .Annotation("Sqlite:Autoincrement", true), 19 | Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), 20 | Status = table.Column(type: "INTEGER", nullable: false), 21 | PhoneNumber_CountryCode = table.Column(type: "TEXT", nullable: true), 22 | PhoneNumber_Number = table.Column(type: "TEXT", nullable: true), 23 | PhoneNumber_Extension = table.Column(type: "TEXT", nullable: true) 24 | }, 25 | constraints: table => 26 | { 27 | table.PrimaryKey("PK_Contributors", x => x.Id); 28 | }); 29 | } 30 | 31 | /// 32 | protected override void Down(MigrationBuilder migrationBuilder) 33 | { 34 | migrationBuilder.DropTable( 35 | name: "Contributors"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/Queries/FakeListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors; 2 | using Clean.Architecture.UseCases.Contributors.List; 3 | 4 | namespace Clean.Architecture.Infrastructure.Data.Queries; 5 | 6 | public class FakeListContributorsQueryService : IListContributorsQueryService 7 | { 8 | public Task> ListAsync() 9 | { 10 | IEnumerable result = 11 | [new ContributorDTO(1, "Fake Contributor 1", ""), 12 | new ContributorDTO(2, "Fake Contributor 2", "")]; 13 | 14 | return Task.FromResult(result); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/Queries/ListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors; 2 | using Clean.Architecture.UseCases.Contributors.List; 3 | 4 | namespace Clean.Architecture.Infrastructure.Data.Queries; 5 | 6 | public class ListContributorsQueryService(AppDbContext _db) : IListContributorsQueryService 7 | { 8 | // You can use EF, Dapper, SqlClient, etc. for queries - 9 | // this is just an example 10 | 11 | public async Task> ListAsync() 12 | { 13 | // NOTE: This will fail if testing with EF InMemory provider! 14 | var result = await _db.Database.SqlQuery( 15 | $"SELECT Id, Name, PhoneNumber_Number AS PhoneNumber FROM Contributors") // don't fetch other big columns 16 | .ToListAsync(); 17 | 18 | return result; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Data/SeedData.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.Infrastructure.Data; 4 | 5 | public static class SeedData 6 | { 7 | public static readonly Contributor Contributor1 = new("Ardalis"); 8 | public static readonly Contributor Contributor2 = new("Snowfrog"); 9 | 10 | public static async Task InitializeAsync(AppDbContext dbContext) 11 | { 12 | if (await dbContext.Contributors.AnyAsync()) return; // DB has been seeded 13 | 14 | await PopulateTestDataAsync(dbContext); 15 | } 16 | 17 | public static async Task PopulateTestDataAsync(AppDbContext dbContext) 18 | { 19 | dbContext.Contributors.AddRange([Contributor1, Contributor2]); 20 | await dbContext.SaveChangesAsync(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Email/FakeEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | 3 | namespace Clean.Architecture.Infrastructure.Email; 4 | 5 | public class FakeEmailSender(ILogger logger) : IEmailSender 6 | { 7 | private readonly ILogger _logger = logger; 8 | public Task SendEmailAsync(string to, string from, string subject, string body) 9 | { 10 | _logger.LogInformation("Not actually sending an email to {to} from {from} with subject {subject}", to, from, subject); 11 | return Task.CompletedTask; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Email/MailserverConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Infrastructure.Email; 2 | 3 | public class MailserverConfiguration() 4 | { 5 | public string Hostname { get; set; } = "localhost"; 6 | public int Port { get; set; } = 25; 7 | } 8 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Email/MimeKitEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | 3 | namespace Clean.Architecture.Infrastructure.Email; 4 | 5 | public class MimeKitEmailSender(ILogger logger, 6 | IOptions mailserverOptions) : IEmailSender 7 | { 8 | private readonly ILogger _logger = logger; 9 | private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; 10 | 11 | public async Task SendEmailAsync(string to, string from, string subject, string body) 12 | { 13 | _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); 14 | 15 | using var client = new MailKit.Net.Smtp.SmtpClient(); 16 | await client.ConnectAsync(_mailserverConfiguration.Hostname, 17 | _mailserverConfiguration.Port, false); 18 | var message = new MimeMessage(); 19 | message.From.Add(new MailboxAddress(from, from)); 20 | message.To.Add(new MailboxAddress(to, to)); 21 | message.Subject = subject; 22 | message.Body = new TextPart("plain") { Text = body }; 23 | 24 | await client.SendAsync(message); 25 | 26 | await client.DisconnectAsync(true, 27 | new CancellationToken(canceled: true)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/Email/SmtpEmailSender.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | 3 | namespace Clean.Architecture.Infrastructure.Email; 4 | 5 | /// 6 | /// MimeKit is recommended over this now: 7 | /// https://weblogs.asp.net/sreejukg/system-net-mail-smtpclient-is-not-recommended-anymore-what-is-the-alternative 8 | /// 9 | public class SmtpEmailSender(ILogger logger, 10 | IOptions mailserverOptions) : IEmailSender 11 | { 12 | private readonly ILogger _logger = logger; 13 | private readonly MailserverConfiguration _mailserverConfiguration = mailserverOptions.Value!; 14 | 15 | public async Task SendEmailAsync(string to, string from, string subject, string body) 16 | { 17 | var emailClient = new System.Net.Mail.SmtpClient(_mailserverConfiguration.Hostname, _mailserverConfiguration.Port); 18 | 19 | var message = new MailMessage 20 | { 21 | From = new MailAddress(from), 22 | Subject = subject, 23 | Body = body 24 | }; 25 | message.To.Add(new MailAddress(to)); 26 | await emailClient.SendMailAsync(message); 27 | _logger.LogWarning("Sending email to {to} from {from} with subject {subject} using {type}.", to, from, subject, this.ToString()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Net.Mail; 2 | global using System.Reflection; 3 | global using Ardalis.GuardClauses; 4 | global using Ardalis.SharedKernel; 5 | global using Ardalis.Specification.EntityFrameworkCore; 6 | global using MailKit.Net.Smtp; 7 | global using Microsoft.EntityFrameworkCore; 8 | global using Microsoft.EntityFrameworkCore.Metadata.Builders; 9 | global using Microsoft.Extensions.Configuration; 10 | global using Microsoft.Extensions.DependencyInjection; 11 | global using Microsoft.Extensions.Logging; 12 | global using Microsoft.Extensions.Options; 13 | global using MimeKit; 14 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/InfrastructureServiceExtensions.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | using Clean.Architecture.Core.Services; 3 | using Clean.Architecture.Infrastructure.Data; 4 | using Clean.Architecture.Infrastructure.Data.Queries; 5 | using Clean.Architecture.UseCases.Contributors.List; 6 | 7 | 8 | namespace Clean.Architecture.Infrastructure; 9 | public static class InfrastructureServiceExtensions 10 | { 11 | public static IServiceCollection AddInfrastructureServices( 12 | this IServiceCollection services, 13 | ConfigurationManager config, 14 | ILogger logger) 15 | { 16 | string? connectionString = config.GetConnectionString("SqliteConnection"); 17 | Guard.Against.Null(connectionString); 18 | services.AddDbContext(options => 19 | options.UseSqlite(connectionString)); 20 | 21 | services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)) 22 | .AddScoped(typeof(IReadRepository<>), typeof(EfRepository<>)) 23 | .AddScoped() 24 | .AddScoped(); 25 | 26 | 27 | logger.LogInformation("{Project} services registered", "Infrastructure"); 28 | 29 | return services; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Infrastructure/README.md: -------------------------------------------------------------------------------- 1 | ## Infrastructure Project 2 | 3 | In Clean Architecture, Infrastructure concerns are kept separate from the core business rules (or domain model in DDD). 4 | 5 | The only project that should have code concerned with EF, Files, Email, Web Services, Azure/AWS/GCP, etc is Infrastructure. 6 | 7 | Infrastructure should depend on Core (and, optionally, Use Cases) where abstractions (interfaces) exist. 8 | 9 | Infrastructure classes implement interfaces found in the Core (Use Cases) project(s). 10 | 11 | These implementations are wired up at startup using DI. In this case using `Microsoft.Extensions.DependencyInjection` and extension methods defined in each project. 12 | 13 | Need help? Check out the sample here: 14 | https://github.com/ardalis/CleanArchitecture/tree/main/sample 15 | 16 | Still need help? 17 | Contact us at https://nimblepros.com 18 | -------------------------------------------------------------------------------- /src/Clean.Architecture.ServiceDefaults/Clean.Architecture.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Clean.Architecture.UseCases.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/ContributorDTO.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors; 2 | public record ContributorDTO(int Id, string Name, string? PhoneNumber); 3 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.Create; 2 | 3 | /// 4 | /// Create a new Contributor. 5 | /// 6 | /// 7 | public record CreateContributorCommand(string Name, string? PhoneNumber) : Ardalis.SharedKernel.ICommand>; 8 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Create/CreateContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.UseCases.Contributors.Create; 4 | 5 | public class CreateContributorHandler(IRepository _repository) 6 | : ICommandHandler> 7 | { 8 | public async Task> Handle(CreateContributorCommand request, 9 | CancellationToken cancellationToken) 10 | { 11 | var newContributor = new Contributor(request.Name); 12 | if (!string.IsNullOrEmpty(request.PhoneNumber)) 13 | { 14 | newContributor.SetPhoneNumber(request.PhoneNumber); 15 | } 16 | var createdItem = await _repository.AddAsync(newContributor, cancellationToken); 17 | 18 | return createdItem.Id; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.Delete; 2 | 3 | public record DeleteContributorCommand(int ContributorId) : ICommand; 4 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Delete/DeleteContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | 3 | namespace Clean.Architecture.UseCases.Contributors.Delete; 4 | 5 | public class DeleteContributorHandler(IDeleteContributorService _deleteContributorService) 6 | : ICommandHandler 7 | { 8 | public async Task Handle(DeleteContributorCommand request, CancellationToken cancellationToken) => 9 | // This Approach: Keep Domain Events in the Domain Model / Core project; this becomes a pass-through 10 | // This is @ardalis's preferred approach 11 | await _deleteContributorService.DeleteContributor(request.ContributorId); 12 | // Another Approach: Do the real work here including dispatching domain events - change the event from internal to public 13 | // @ardalis prefers using the service above so that **domain** event behavior remains in the **domain model** (core project) 14 | // var aggregateToDelete = await _repository.GetByIdAsync(request.ContributorId); 15 | // if (aggregateToDelete == null) return Result.NotFound(); 16 | // await _repository.DeleteAsync(aggregateToDelete); 17 | // var domainEvent = new ContributorDeletedEvent(request.ContributorId); 18 | // await _mediator.Publish(domainEvent);// return Result.Success(); 19 | } 20 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Get/GetContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | using Clean.Architecture.Core.ContributorAggregate.Specifications; 3 | 4 | namespace Clean.Architecture.UseCases.Contributors.Get; 5 | 6 | /// 7 | /// Queries don't necessarily need to use repository methods, but they can if it's convenient 8 | /// 9 | public class GetContributorHandler(IReadRepository _repository) 10 | : IQueryHandler> 11 | { 12 | public async Task> Handle(GetContributorQuery request, CancellationToken cancellationToken) 13 | { 14 | var spec = new ContributorByIdSpec(request.ContributorId); 15 | var entity = await _repository.FirstOrDefaultAsync(spec, cancellationToken); 16 | if (entity == null) return Result.NotFound(); 17 | 18 | return new ContributorDTO(entity.Id, entity.Name, entity.PhoneNumber?.Number ?? ""); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Get/GetContributorQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.Get; 2 | 3 | public record GetContributorQuery(int ContributorId) : IQuery>; 4 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/List/IListContributorsQueryService.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.List; 2 | 3 | /// 4 | /// Represents a service that will actually fetch the necessary data 5 | /// Typically implemented in Infrastructure 6 | /// 7 | public interface IListContributorsQueryService 8 | { 9 | Task> ListAsync(); 10 | } 11 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/List/ListContributorsHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.List; 2 | 3 | public class ListContributorsHandler(IListContributorsQueryService _query) 4 | : IQueryHandler>> 5 | { 6 | public async Task>> Handle(ListContributorsQuery request, CancellationToken cancellationToken) 7 | { 8 | var result = await _query.ListAsync(); 9 | 10 | return Result.Success(result); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/List/ListContributorsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.List; 2 | 3 | public record ListContributorsQuery(int? Skip, int? Take) : IQuery>>; 4 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UseCases.Contributors.Update; 2 | 3 | public record UpdateContributorCommand(int ContributorId, string NewName) : ICommand>; 4 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/Contributors/Update/UpdateContributorHandler.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.UseCases.Contributors.Update; 4 | 5 | public class UpdateContributorHandler(IRepository _repository) 6 | : ICommandHandler> 7 | { 8 | public async Task> Handle(UpdateContributorCommand request, CancellationToken cancellationToken) 9 | { 10 | var existingContributor = await _repository.GetByIdAsync(request.ContributorId, cancellationToken); 11 | if (existingContributor == null) 12 | { 13 | return Result.NotFound(); 14 | } 15 | 16 | existingContributor.UpdateName(request.NewName!); 17 | 18 | await _repository.UpdateAsync(existingContributor, cancellationToken); 19 | 20 | return new ContributorDTO(existingContributor.Id, 21 | existingContributor.Name, existingContributor.PhoneNumber?.Number ?? ""); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.Result; 2 | global using Ardalis.SharedKernel; 3 | -------------------------------------------------------------------------------- /src/Clean.Architecture.UseCases/README.md: -------------------------------------------------------------------------------- 1 | ## Use Cases Project 2 | 3 | In Clean Architecture, the Use Cases (or Application Services) project is a relatively thin layer that wraps the domain model. 4 | 5 | Use Cases are typically organized by feature. These may be simple CRUD operations or much more complex activities. 6 | 7 | Use Cases should not depend directly on infrastructure concerns, making them simple to unit test in most cases. 8 | 9 | Use Cases are often grouped into Commands and Queries, following CQRS. 10 | 11 | Having Use Cases as a separate project can reduce the amount of logic in UI and Infrastructure projects. 12 | 13 | For simpler projects, the Use Cases project can be omitted, and its behavior moved into the UI project, either as separate services or MediatR handlers, or by simply putting the logic into the API endpoints. 14 | 15 | For ideas on organizing your Use Case project's folder structure, see this thread: 16 | https://twitter.com/ardalis/status/1686406393018945536 17 | 18 | Need help? Check out the sample here: 19 | https://github.com/ardalis/CleanArchitecture/tree/main/sample 20 | 21 | Still need help? 22 | Contact us at https://nimblepros.com 23 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Configurations/LoggerConfigs.cs: -------------------------------------------------------------------------------- 1 | using Serilog; 2 | 3 | namespace Clean.Architecture.Web.Configurations; 4 | 5 | public static class LoggerConfigs 6 | { 7 | public static WebApplicationBuilder AddLoggerConfigs(this WebApplicationBuilder builder) 8 | { 9 | 10 | builder.Host.UseSerilog((_, config) => config.ReadFrom.Configuration(builder.Configuration)); 11 | 12 | return builder; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Configurations/MediatrConfigs.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.SharedKernel; 2 | using Clean.Architecture.Core.ContributorAggregate; 3 | using Clean.Architecture.UseCases.Contributors.Create; 4 | using MediatR; 5 | using System.Reflection; 6 | 7 | namespace Clean.Architecture.Web.Configurations; 8 | 9 | public static class MediatrConfigs 10 | { 11 | public static IServiceCollection AddMediatrConfigs(this IServiceCollection services) 12 | { 13 | var mediatRAssemblies = new[] 14 | { 15 | Assembly.GetAssembly(typeof(Contributor)), // Core 16 | Assembly.GetAssembly(typeof(CreateContributorCommand)) // UseCases 17 | }; 18 | 19 | services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(mediatRAssemblies!)) 20 | .AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)) 21 | .AddScoped(); 22 | 23 | return services; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Configurations/ServiceConfigs.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Interfaces; 2 | using Clean.Architecture.Infrastructure; 3 | using Clean.Architecture.Infrastructure.Email; 4 | 5 | namespace Clean.Architecture.Web.Configurations; 6 | 7 | public static class ServiceConfigs 8 | { 9 | public static IServiceCollection AddServiceConfigs(this IServiceCollection services, Microsoft.Extensions.Logging.ILogger logger, WebApplicationBuilder builder) 10 | { 11 | services.AddInfrastructureServices(builder.Configuration, logger) 12 | .AddMediatrConfigs(); 13 | 14 | 15 | if (builder.Environment.IsDevelopment()) 16 | { 17 | // Use a local test email server 18 | // See: https://ardalis.com/configuring-a-local-test-email-server/ 19 | services.AddScoped(); 20 | 21 | // Otherwise use this: 22 | //builder.Services.AddScoped(); 23 | 24 | } 25 | else 26 | { 27 | services.AddScoped(); 28 | } 29 | 30 | logger.LogInformation("{Project} services registered", "Mediatr and Email Sender"); 31 | 32 | return services; 33 | } 34 | 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/ContributorRecord.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public record ContributorRecord(int Id, string Name, string? PhoneNumber); 4 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Create.CreateContributorRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Clean.Architecture.Web.Contributors; 4 | 5 | public class CreateContributorRequest 6 | { 7 | public const string Route = "/Contributors"; 8 | 9 | [Required] 10 | public string? Name { get; set; } 11 | public string? PhoneNumber { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Create.CreateContributorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public class CreateContributorResponse(int id, string name) 4 | { 5 | public int Id { get; set; } = id; 6 | public string Name { get; set; } = name; 7 | } 8 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Create.CreateContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Infrastructure.Data.Config; 2 | using FastEndpoints; 3 | using FluentValidation; 4 | 5 | namespace Clean.Architecture.Web.Contributors; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class CreateContributorValidator : Validator 11 | { 12 | public CreateContributorValidator() 13 | { 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Name is required.") 17 | .MinimumLength(2) 18 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Create.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors.Create; 2 | 3 | namespace Clean.Architecture.Web.Contributors; 4 | 5 | /// 6 | /// Create a new Contributor 7 | /// 8 | /// 9 | /// Creates a new Contributor given a name. 10 | /// 11 | public class Create(IMediator _mediator) 12 | : Endpoint 13 | { 14 | public override void Configure() 15 | { 16 | Post(CreateContributorRequest.Route); 17 | AllowAnonymous(); 18 | Summary(s => 19 | { 20 | // XML Docs are used by default but are overridden by these properties: 21 | //s.Summary = "Create a new Contributor."; 22 | //s.Description = "Create a new Contributor. A valid name is required."; 23 | s.ExampleRequest = new CreateContributorRequest { Name = "Contributor Name" }; 24 | }); 25 | } 26 | 27 | public override async Task HandleAsync( 28 | CreateContributorRequest request, 29 | CancellationToken cancellationToken) 30 | { 31 | var result = await _mediator.Send(new CreateContributorCommand(request.Name!, 32 | request.PhoneNumber), cancellationToken); 33 | 34 | if (result.IsSuccess) 35 | { 36 | Response = new CreateContributorResponse(result.Value, request.Name!); 37 | return; 38 | } 39 | // TODO: Handle other cases as necessary 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public record DeleteContributorRequest 4 | { 5 | public const string Route = "/Contributors/{ContributorId:int}"; 6 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 7 | 8 | public int ContributorId { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Delete.DeleteContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | 4 | namespace Clean.Architecture.Web.Contributors; 5 | 6 | /// 7 | /// See: https://fast-endpoints.com/docs/validation 8 | /// 9 | public class DeleteContributorValidator : Validator 10 | { 11 | public DeleteContributorValidator() 12 | { 13 | RuleFor(x => x.ContributorId) 14 | .GreaterThan(0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Delete.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors.Delete; 2 | 3 | namespace Clean.Architecture.Web.Contributors; 4 | 5 | /// 6 | /// Delete a Contributor. 7 | /// 8 | /// 9 | /// Delete a Contributor by providing a valid integer id. 10 | /// 11 | public class Delete(IMediator _mediator) 12 | : Endpoint 13 | { 14 | public override void Configure() 15 | { 16 | Delete(DeleteContributorRequest.Route); 17 | AllowAnonymous(); 18 | } 19 | 20 | public override async Task HandleAsync( 21 | DeleteContributorRequest request, 22 | CancellationToken cancellationToken) 23 | { 24 | var command = new DeleteContributorCommand(request.ContributorId); 25 | 26 | var result = await _mediator.Send(command, cancellationToken); 27 | 28 | if (result.Status == ResultStatus.NotFound) 29 | { 30 | await SendNotFoundAsync(cancellationToken); 31 | return; 32 | } 33 | 34 | if (result.IsSuccess) 35 | { 36 | await SendNoContentAsync(cancellationToken); 37 | }; 38 | // TODO: Handle other issues as needed 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/GetById.GetContributorByIdRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public class GetContributorByIdRequest 4 | { 5 | public const string Route = "/Contributors/{ContributorId:int}"; 6 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 7 | 8 | public int ContributorId { get; set; } 9 | } 10 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/GetById.GetContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using FastEndpoints; 2 | using FluentValidation; 3 | 4 | namespace Clean.Architecture.Web.Contributors; 5 | 6 | /// 7 | /// See: https://fast-endpoints.com/docs/validation 8 | /// 9 | public class GetContributorValidator : Validator 10 | { 11 | public GetContributorValidator() 12 | { 13 | RuleFor(x => x.ContributorId) 14 | .GreaterThan(0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/GetById.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors.Get; 2 | 3 | namespace Clean.Architecture.Web.Contributors; 4 | 5 | /// 6 | /// Get a Contributor by integer ID. 7 | /// 8 | /// 9 | /// Takes a positive integer ID and returns a matching Contributor record. 10 | /// 11 | public class GetById(IMediator _mediator) 12 | : Endpoint 13 | { 14 | public override void Configure() 15 | { 16 | Get(GetContributorByIdRequest.Route); 17 | AllowAnonymous(); 18 | } 19 | 20 | public override async Task HandleAsync(GetContributorByIdRequest request, 21 | CancellationToken cancellationToken) 22 | { 23 | var query = new GetContributorQuery(request.ContributorId); 24 | 25 | var result = await _mediator.Send(query, cancellationToken); 26 | 27 | if (result.Status == ResultStatus.NotFound) 28 | { 29 | await SendNotFoundAsync(cancellationToken); 30 | return; 31 | } 32 | 33 | if (result.IsSuccess) 34 | { 35 | Response = new ContributorRecord(result.Value.Id, result.Value.Name, result.Value.PhoneNumber); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/List.ContributorListResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public class ContributorListResponse 4 | { 5 | public List Contributors { get; set; } = []; 6 | } 7 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/List.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.UseCases.Contributors; 2 | using Clean.Architecture.UseCases.Contributors.List; 3 | 4 | namespace Clean.Architecture.Web.Contributors; 5 | 6 | /// 7 | /// List all Contributors 8 | /// 9 | /// 10 | /// List all contributors - returns a ContributorListResponse containing the Contributors. 11 | /// 12 | public class List(IMediator _mediator) : EndpointWithoutRequest 13 | { 14 | public override void Configure() 15 | { 16 | Get("/Contributors"); 17 | AllowAnonymous(); 18 | } 19 | 20 | public override async Task HandleAsync(CancellationToken cancellationToken) 21 | { 22 | Result> result = await _mediator.Send(new ListContributorsQuery(null, null), cancellationToken); 23 | 24 | if (result.IsSuccess) 25 | { 26 | Response = new ContributorListResponse 27 | { 28 | Contributors = result.Value.Select(c => new ContributorRecord(c.Id, c.Name, c.PhoneNumber)).ToList() 29 | }; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Update.UpdateContributorRequest.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Clean.Architecture.Web.Contributors; 4 | 5 | public class UpdateContributorRequest 6 | { 7 | public const string Route = "/Contributors/{ContributorId:int}"; 8 | public static string BuildRoute(int contributorId) => Route.Replace("{ContributorId:int}", contributorId.ToString()); 9 | 10 | public int ContributorId { get; set; } 11 | 12 | [Required] 13 | public int Id { get; set; } 14 | [Required] 15 | public string? Name { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Update.UpdateContributorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.Web.Contributors; 2 | 3 | public class UpdateContributorResponse(ContributorRecord contributor) 4 | { 5 | public ContributorRecord Contributor { get; set; } = contributor; 6 | } 7 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Contributors/Update.UpdateContributorValidator.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Infrastructure.Data.Config; 2 | using FastEndpoints; 3 | using FluentValidation; 4 | 5 | namespace Clean.Architecture.Web.Contributors; 6 | 7 | /// 8 | /// See: https://fast-endpoints.com/docs/validation 9 | /// 10 | public class UpdateContributorValidator : Validator 11 | { 12 | public UpdateContributorValidator() 13 | { 14 | RuleFor(x => x.Name) 15 | .NotEmpty() 16 | .WithMessage("Name is required.") 17 | .MinimumLength(2) 18 | .MaximumLength(DataSchemaConstants.DEFAULT_NAME_LENGTH); 19 | RuleFor(x => x.ContributorId) 20 | .Must((args, contributorId) => args.Id == contributorId) 21 | .WithMessage("Route and body Ids must match; cannot update Id of an existing resource."); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using FastEndpoints; 2 | global using FastEndpoints.Swagger; 3 | global using MediatR; 4 | global using Serilog; 5 | global using Serilog.Extensions.Logging; 6 | global using Ardalis.Result; 7 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Program.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Web.Configurations; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | var logger = Log.Logger = new LoggerConfiguration() 6 | .Enrich.FromLogContext() 7 | .WriteTo.Console() 8 | .CreateLogger(); 9 | 10 | logger.Information("Starting web host"); 11 | 12 | builder.AddLoggerConfigs(); 13 | 14 | var appLogger = new SerilogLoggerFactory(logger) 15 | .CreateLogger(); 16 | 17 | builder.Services.AddOptionConfigs(builder.Configuration, appLogger, builder); 18 | builder.Services.AddServiceConfigs(appLogger, builder); 19 | 20 | builder.Services.AddFastEndpoints() 21 | .SwaggerDocument(o => 22 | { 23 | o.ShortSchemaNames = true; 24 | }); 25 | 26 | #if (aspire) 27 | builder.AddServiceDefaults(); 28 | #endif 29 | 30 | var app = builder.Build(); 31 | 32 | await app.UseAppMiddlewareAndSeedDatabase(); 33 | 34 | app.Run(); 35 | 36 | // Make the implicit Program.cs class public, so integration tests can reference the correct assembly for host building 37 | public partial class Program { } 38 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:57678/", 7 | "sslPort": 0 8 | } 9 | }, 10 | "profiles": { 11 | "https": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchUrl": "swagger", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | }, 18 | "applicationUrl": "https://localhost:57679/" 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/api.http: -------------------------------------------------------------------------------- 1 | # For more info on HTTP files go to https://aka.ms/vs/httpfile 2 | @host=https://localhost 3 | @port=57679 4 | 5 | // List all contributors 6 | GET {{host}}:{{port}}/Contributors 7 | 8 | ### 9 | 10 | // Get a specific contributor 11 | @id_to_get=1 12 | GET {{host}}:{{port}}/Contributors/{{id_to_get}} 13 | 14 | ### 15 | 16 | // Add a new contributor 17 | POST {{host}}:{{port}}/Contributors 18 | Content-Type: application/json 19 | 20 | { 21 | "name": "John Doe 2", 22 | "email": "test@test.com", 23 | "phoneNumber": "1234567890" 24 | } 25 | 26 | ### 27 | 28 | // Update a contributor 29 | @id_to_update=1 30 | PUT {{host}}:{{port}}/Contributors/{{id_to_update}} 31 | Content-Type: application/json 32 | 33 | { 34 | "id": {{id_to_update}}, 35 | "name": "ardalis2" 36 | } 37 | 38 | ### 39 | 40 | // Delete a contributor 41 | @id_to_delete=1 42 | DELETE {{host}}:{{port}}/Contributors/{{id_to_delete}} 43 | 44 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Server=(localdb)\\v11.0;Database=cleanarchitecture;Trusted_Connection=True;MultipleActiveResultSets=true", 4 | "SqliteConnection": "Data Source=database.sqlite" 5 | }, 6 | "Serilog": { 7 | "MinimumLevel": { 8 | "Default": "Information" 9 | }, 10 | "WriteTo": [ 11 | { 12 | "Name": "Console" 13 | }, 14 | { 15 | "Name": "File", 16 | "Args": { 17 | "path": "log.txt", 18 | "rollingInterval": "Day" 19 | } 20 | } 21 | //Uncomment this section if you'd like to push your logs to Azure Application Insights 22 | //Full list of Serilog Sinks can be found here: https://github.com/serilog/serilog/wiki/Provided-Sinks 23 | //{ 24 | // "Name": "ApplicationInsights", 25 | // "Args": { 26 | // "instrumentationKey": "", //Fill in with your ApplicationInsights InstrumentationKey 27 | // "telemetryConverter": "Serilog.Sinks.ApplicationInsights.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" 28 | // } 29 | //} 30 | ] 31 | }, 32 | "Mailserver": { 33 | "Server": "localhost", 34 | "Port": 25 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Clean.Architecture.Web/wwwroot/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardalis/CleanArchitecture/7c031c77e6b8db695f3266a5f5873a522bbb238a/src/Clean.Architecture.Web/wwwroot/.gitkeep -------------------------------------------------------------------------------- /tests/Clean.Architecture.AspireTests/AspireIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.AspireTests.Tests; 2 | 3 | public class AspireIntegrationTests 4 | { 5 | // Follow the link below to write you tests with Aspire 6 | // https://learn.microsoft.com/en-us/dotnet/aspire/testing/write-your-first-test?pivots=xunit 7 | } 8 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.AspireTests/Clean.Architecture.AspireTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorGetById.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Infrastructure.Data; 2 | using Clean.Architecture.Web.Contributors; 3 | 4 | 5 | namespace Clean.Architecture.FunctionalTests.ApiEndpoints; 6 | 7 | [Collection("Sequential")] 8 | public class ContributorGetById(CustomWebApplicationFactory factory) : IClassFixture> 9 | { 10 | private readonly HttpClient _client = factory.CreateClient(); 11 | 12 | [Fact] 13 | public async Task ReturnsSeedContributorGivenId1() 14 | { 15 | var result = await _client.GetAndDeserializeAsync(GetContributorByIdRequest.BuildRoute(1)); 16 | 17 | result.Id.ShouldBe(1); 18 | result.Name.ShouldBe(SeedData.Contributor1.Name); 19 | } 20 | 21 | [Fact] 22 | public async Task ReturnsNotFoundGivenId1000() 23 | { 24 | string route = GetContributorByIdRequest.BuildRoute(1000); 25 | _ = await _client.GetAndEnsureNotFoundAsync(route); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.FunctionalTests/ApiEndpoints/ContributorList.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Infrastructure.Data; 2 | using Clean.Architecture.Web.Contributors; 3 | 4 | namespace Clean.Architecture.FunctionalTests.ApiEndpoints; 5 | 6 | [Collection("Sequential")] 7 | public class ContributorList(CustomWebApplicationFactory factory) : IClassFixture> 8 | { 9 | private readonly HttpClient _client = factory.CreateClient(); 10 | 11 | [Fact] 12 | public async Task ReturnsTwoContributors() 13 | { 14 | var result = await _client.GetAndDeserializeAsync("/Contributors"); 15 | 16 | result.Contributors.Count.ShouldBe(2); 17 | result.Contributors.ShouldContain(contributor => contributor.Name == SeedData.Contributor1.Name); 18 | result.Contributors.ShouldContain(contributor => contributor.Name == SeedData.Contributor2.Name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.FunctionalTests/Clean.Architecture.FunctionalTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | all 10 | runtime; build; native; contentfiles; analyzers; buildtransitive 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.FunctionalTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.HttpClientTestExtensions; 2 | global using Microsoft.AspNetCore.Hosting; 3 | global using Microsoft.AspNetCore.Mvc.Testing; 4 | global using Microsoft.Extensions.DependencyInjection; 5 | global using Microsoft.Extensions.Hosting; 6 | global using Microsoft.Extensions.Logging; 7 | global using Shouldly; 8 | global using Xunit; 9 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.FunctionalTests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "shadowCopy": false, 3 | "parallelizeAssembly": false, 4 | "parallelizeTestCollections": false 5 | } -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/Clean.Architecture.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | all 8 | runtime; build; native; contentfiles; analyzers; buildtransitive 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | PreserveNewest 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/Data/BaseEfRepoTestFixture.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | using Clean.Architecture.Infrastructure.Data; 3 | 4 | namespace Clean.Architecture.IntegrationTests.Data; 5 | 6 | public abstract class BaseEfRepoTestFixture 7 | { 8 | protected AppDbContext _dbContext; 9 | 10 | protected BaseEfRepoTestFixture() 11 | { 12 | var options = CreateNewContextOptions(); 13 | var _fakeEventDispatcher = Substitute.For(); 14 | 15 | _dbContext = new AppDbContext(options, _fakeEventDispatcher); 16 | } 17 | 18 | protected static DbContextOptions CreateNewContextOptions() 19 | { 20 | // Create a fresh service provider, and therefore a fresh 21 | // InMemory database instance. 22 | var serviceProvider = new ServiceCollection() 23 | .AddEntityFrameworkInMemoryDatabase() 24 | .BuildServiceProvider(); 25 | 26 | // Create a new options instance telling the context to use an 27 | // InMemory database and the new service provider. 28 | var builder = new DbContextOptionsBuilder(); 29 | builder.UseInMemoryDatabase("cleanarchitecture") 30 | .UseInternalServiceProvider(serviceProvider); 31 | 32 | return builder.Options; 33 | } 34 | 35 | protected EfRepository GetRepository() 36 | { 37 | return new EfRepository(_dbContext); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryAdd.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.IntegrationTests.Data; 4 | 5 | public class EfRepositoryAdd : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task AddsContributorAndSetsId() 9 | { 10 | var testContributorName = "testContributor"; 11 | var testContributorStatus = ContributorStatus.NotSet; 12 | var repository = GetRepository(); 13 | var Contributor = new Contributor(testContributorName); 14 | 15 | await repository.AddAsync(Contributor); 16 | 17 | var newContributor = (await repository.ListAsync()) 18 | .FirstOrDefault(); 19 | 20 | newContributor.ShouldNotBeNull(); 21 | testContributorName.ShouldBe(newContributor.Name); 22 | testContributorStatus.ShouldBe(newContributor.Status); 23 | newContributor.Id.ShouldBeGreaterThan(0); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryDelete.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.IntegrationTests.Data; 4 | 5 | public class EfRepositoryDelete : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task DeletesItemAfterAddingIt() 9 | { 10 | // add a Contributor 11 | var repository = GetRepository(); 12 | var initialName = Guid.NewGuid().ToString(); 13 | var Contributor = new Contributor(initialName); 14 | await repository.AddAsync(Contributor); 15 | 16 | // delete the item 17 | await repository.DeleteAsync(Contributor); 18 | 19 | // verify it's no longer there 20 | (await repository.ListAsync()).ShouldNotContain(Contributor => Contributor.Name == initialName); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/Data/EfRepositoryUpdate.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.ContributorAggregate; 2 | 3 | namespace Clean.Architecture.IntegrationTests.Data; 4 | 5 | public class EfRepositoryUpdate : BaseEfRepoTestFixture 6 | { 7 | [Fact] 8 | public async Task UpdatesItemAfterAddingIt() 9 | { 10 | // add a Contributor 11 | var repository = GetRepository(); 12 | var initialName = Guid.NewGuid().ToString(); 13 | var Contributor = new Contributor(initialName); 14 | 15 | await repository.AddAsync(Contributor); 16 | 17 | // detach the item so we get a different instance 18 | _dbContext.Entry(Contributor).State = EntityState.Detached; 19 | 20 | // fetch the item and update its title 21 | var newContributor = (await repository.ListAsync()) 22 | .FirstOrDefault(Contributor => Contributor.Name == initialName); 23 | newContributor.ShouldNotBeNull(); 24 | 25 | Contributor.ShouldNotBeSameAs(newContributor); 26 | var newName = Guid.NewGuid().ToString(); 27 | newContributor.UpdateName(newName); 28 | 29 | // Update the item 30 | await repository.UpdateAsync(newContributor); 31 | 32 | // Fetch the updated item 33 | var updatedItem = (await repository.ListAsync()) 34 | .FirstOrDefault(Contributor => Contributor.Name == newName); 35 | 36 | updatedItem.ShouldNotBeNull(); 37 | Contributor.Name.ShouldNotBe(updatedItem.Name); 38 | Contributor.Status.ShouldBe(updatedItem.Status); 39 | newContributor.Id.ShouldBe(updatedItem.Id); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.IntegrationTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Ardalis.SharedKernel; 2 | global using Microsoft.EntityFrameworkCore; 3 | global using Microsoft.Extensions.DependencyInjection; 4 | global using NSubstitute; 5 | global using Shouldly; 6 | global using Xunit; 7 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/Core/ContributorAggregate/ContributorConstructor.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UnitTests.Core.ContributorAggregate; 2 | 3 | public class ContributorConstructor 4 | { 5 | private readonly string _testName = "test name"; 6 | private Contributor? _testContributor; 7 | 8 | private Contributor CreateContributor() 9 | { 10 | return new Contributor(_testName); 11 | } 12 | 13 | [Fact] 14 | public void InitializesName() 15 | { 16 | _testContributor = CreateContributor(); 17 | 18 | _testContributor.Name.ShouldBe(_testName); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/Core/Services/DeleteContributorSevice_DeleteContributor.cs: -------------------------------------------------------------------------------- 1 | using Clean.Architecture.Core.Services; 2 | 3 | namespace Clean.Architecture.UnitTests.Core.Services; 4 | 5 | public class DeleteContributorService_DeleteContributor 6 | { 7 | private readonly IRepository _repository = Substitute.For>(); 8 | private readonly IMediator _mediator = Substitute.For(); 9 | private readonly ILogger _logger = Substitute.For>(); 10 | 11 | private readonly DeleteContributorService _service; 12 | 13 | public DeleteContributorService_DeleteContributor() 14 | { 15 | _service = new DeleteContributorService(_repository, _mediator, _logger); 16 | } 17 | 18 | [Fact] 19 | public async Task ReturnsNotFoundGivenCantFindContributor() 20 | { 21 | var result = await _service.DeleteContributor(0); 22 | 23 | result.Status.ShouldBe(Ardalis.Result.ResultStatus.NotFound); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Runtime.CompilerServices; 2 | global using Ardalis.SharedKernel; 3 | global using Clean.Architecture.Core.ContributorAggregate; 4 | global using Clean.Architecture.UseCases.Contributors.Create; 5 | global using Shouldly; 6 | global using MediatR; 7 | global using Microsoft.Extensions.Logging; 8 | global using NSubstitute; 9 | global using Xunit; 10 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/NoOpMediator.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UnitTests; 2 | 3 | public class NoOpMediator : IMediator 4 | { 5 | public Task Publish(object notification, CancellationToken cancellationToken = default) 6 | { 7 | return Task.CompletedTask; 8 | } 9 | 10 | public Task Publish(TNotification notification, CancellationToken cancellationToken = default) where TNotification : INotification 11 | { 12 | return Task.CompletedTask; 13 | } 14 | 15 | public Task Send(IRequest request, CancellationToken cancellationToken = default) 16 | { 17 | return Task.FromResult(default!); 18 | } 19 | 20 | public Task Send(object request, CancellationToken cancellationToken = default) 21 | { 22 | return Task.FromResult(default); 23 | } 24 | 25 | public async IAsyncEnumerable CreateStream(IStreamRequest request, 26 | [EnumeratorCancellation] CancellationToken cancellationToken = default) 27 | { 28 | await Task.CompletedTask; 29 | yield break; 30 | } 31 | 32 | public async IAsyncEnumerable CreateStream(object request, 33 | [EnumeratorCancellation] CancellationToken cancellationToken = default) 34 | { 35 | await Task.CompletedTask; 36 | yield break; 37 | } 38 | 39 | public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest 40 | { 41 | return Task.CompletedTask; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/UseCases/Contributors/CreateContributorHandlerHandle.cs: -------------------------------------------------------------------------------- 1 | namespace Clean.Architecture.UnitTests.UseCases.Contributors; 2 | 3 | public class CreateContributorHandlerHandle 4 | { 5 | private readonly string _testName = "test name"; 6 | private readonly IRepository _repository = Substitute.For>(); 7 | private CreateContributorHandler _handler; 8 | 9 | public CreateContributorHandlerHandle() 10 | { 11 | _handler = new CreateContributorHandler(_repository); 12 | } 13 | 14 | private Contributor CreateContributor() 15 | { 16 | return new Contributor(_testName); 17 | } 18 | 19 | [Fact] 20 | public async Task ReturnsSuccessGivenValidName() 21 | { 22 | _repository.AddAsync(Arg.Any(), Arg.Any()) 23 | .Returns(Task.FromResult(CreateContributor())); 24 | var result = await _handler.Handle(new CreateContributorCommand(_testName, null), CancellationToken.None); 25 | 26 | result.IsSuccess.ShouldBeTrue(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Clean.Architecture.UnitTests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "shadowCopy": false, 3 | "parallelizeAssembly": false, 4 | "parallelizeTestCollections": false 5 | } --------------------------------------------------------------------------------