├── .dockerignore ├── .editorconfig ├── .gitignore ├── .template.config ├── icon.png └── template.json ├── CleanArchitecture.sln ├── LICENSE ├── README.md ├── charts ├── api │ ├── Chart.yaml │ ├── templates │ │ ├── api-deployment.yaml │ │ ├── api-hpa.yaml │ │ ├── database-migration-job.yaml │ │ └── web-service.yaml │ └── values.yaml ├── charts.projitems ├── charts.shproj └── web │ ├── Chart.yaml │ ├── templates │ ├── deployment.yaml │ └── web-service.yaml │ └── values.yaml ├── docker-compose.dcproj ├── docker-compose.yml ├── pipelines ├── README.md ├── pipelines.projitems └── pipelines.shproj ├── src ├── CleanArchitecture.Api │ ├── CleanArchitecture.Api.csproj │ ├── Controllers │ │ ├── LocationsController.cs │ │ └── WeatherForecastsController.cs │ ├── Dockerfile │ ├── Infrastructure │ │ ├── ActionResults │ │ │ ├── Envelope.cs │ │ │ └── EnvelopeObjectResult.cs │ │ └── Filters │ │ │ └── HttpGlobalExceptionFilter.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.Development.json │ └── appsettings.json ├── CleanArchitecture.Application │ ├── Abstractions │ │ ├── Commands │ │ │ ├── Command.cs │ │ │ └── CommandHandler.cs │ │ ├── DomainEventHandlers │ │ │ └── DomainEventHandler.cs │ │ ├── IntegrationEvents │ │ │ └── IntegrationEvent.cs │ │ ├── Queries │ │ │ ├── Query.cs │ │ │ └── QueryHandler.cs │ │ ├── Repositories │ │ │ ├── IRepository.cs │ │ │ └── IUnitOfWork.cs │ │ └── Services │ │ │ └── INotificationsService.cs │ ├── AutofacModules │ │ └── ApplicationModule.cs │ ├── CleanArchitecture.Application.csproj │ ├── Locations │ │ ├── MappingProfiles │ │ │ └── LocationProfile.cs │ │ ├── Models │ │ │ └── LocationDto.cs │ │ └── Queries │ │ │ └── GetLocationsQuery.cs │ └── Weather │ │ ├── Commands │ │ ├── CreateWeatherForecastCommand.cs │ │ ├── DeleteWeatherForecastCommand.cs │ │ └── UpdateWeatherForecastCommand.cs │ │ ├── DomainEventHandlers │ │ └── WeatherForecastCreatedDomainEventHandler.cs │ │ ├── IntegrationEvents │ │ └── WeatherForecastCreatedEvent.cs │ │ ├── MappingProfiles │ │ └── WeatherForecastProfile.cs │ │ ├── Models │ │ ├── WeatherForecastCreateDto.cs │ │ ├── WeatherForecastDto.cs │ │ └── WeatherForecastUpdateDto.cs │ │ └── Queries │ │ ├── GetWeatherForecastQuery.cs │ │ └── GetWeatherForecastsQuery.cs ├── CleanArchitecture.Core │ ├── Abstractions │ │ ├── DomainEvents │ │ │ └── DomainEvent.cs │ │ ├── Entities │ │ │ ├── AggregateRoot.cs │ │ │ └── EntityBase.cs │ │ ├── Exceptions │ │ │ ├── DomainException.cs │ │ │ └── NotFoundException.cs │ │ └── Guards │ │ │ ├── Guard.cs │ │ │ ├── GuardAgainstNotFoundExtensions.cs │ │ │ ├── GuardAgainstNullExtensions.cs │ │ │ ├── GuardAgainstNumberExtensions.cs │ │ │ └── GuardClauseExtensions.cs │ ├── CleanArchitecture.Core.csproj │ ├── IsExternalInit.cs │ ├── Locations │ │ ├── Entities │ │ │ └── Location.cs │ │ └── ValueObjects │ │ │ └── Coordinates.cs │ └── Weather │ │ ├── DomainEvents │ │ └── WeatherForecastCreatedDomainEvent.cs │ │ ├── Entities │ │ └── WeatherForecast.cs │ │ └── ValueObjects │ │ └── Temperature.cs ├── CleanArchitecture.Hosting │ ├── Application.cs │ ├── CleanArchitecture.Hosting.csproj │ ├── HostBuilderExtensions.cs │ ├── Job.cs │ └── Worker.cs ├── CleanArchitecture.Infrastructure │ ├── AutofacModules │ │ └── InfrastructureModule.cs │ ├── CleanArchitecture.Infrastructure.csproj │ ├── Configurations │ │ ├── LocationConfiguration.cs │ │ └── WeatherForecastConfiguration.cs │ ├── Extensions │ │ └── MediatorExtensions.cs │ ├── Repositories │ │ ├── Repository.cs │ │ └── UnitOfWork.cs │ ├── Services │ │ └── NotificationsService.cs │ ├── Settings │ │ └── DatabaseSettings.cs │ └── WeatherContext.cs ├── CleanArchitecture.Integration │ ├── CleanArchitecture.Integration.csproj │ ├── Program.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── CleanArchitecture.Migrations │ ├── CleanArchitecture.Migrations.csproj │ ├── Dockerfile │ ├── Factories │ │ ├── DbContextOptionsFactory.cs │ │ └── WeatherContextFactory.cs │ ├── MigrationJob.cs │ ├── Migrations │ │ ├── 20230903093623_Initial.Designer.cs │ │ ├── 20230903093623_Initial.cs │ │ └── WeatherContextModelSnapshot.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ └── appsettings.json └── CleanArchitecture.Web │ ├── .gitignore │ ├── CleanArchitecture.Web.csproj │ ├── ClientApp │ ├── .editorconfig │ ├── .gitignore │ ├── README.md │ ├── angular.json │ ├── aspnetcore-https.js │ ├── karma.conf.js │ ├── package-lock.json │ ├── package.json │ ├── proxy.conf.js │ ├── src │ │ ├── app │ │ │ ├── _shared │ │ │ │ ├── models │ │ │ │ │ ├── location.model.ts │ │ │ │ │ ├── results.model.ts │ │ │ │ │ └── weather.model.ts │ │ │ │ └── services │ │ │ │ │ ├── locations.service.ts │ │ │ │ │ └── weather.service.ts │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ ├── app.server.module.ts │ │ │ ├── home │ │ │ │ ├── home.component.html │ │ │ │ └── home.component.ts │ │ │ ├── nav-menu │ │ │ │ ├── nav-menu.component.css │ │ │ │ ├── nav-menu.component.html │ │ │ │ └── nav-menu.component.ts │ │ │ └── weather-forecasts │ │ │ │ ├── weather-forecasts.component.html │ │ │ │ └── weather-forecasts.component.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.css │ │ └── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json │ ├── Dockerfile │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── appsettings.Development.json │ ├── appsettings.json │ └── wwwroot │ └── favicon.ico └── tests ├── CleanArchitecture.AcceptanceTests ├── CleanArchitecture.AcceptanceTests.csproj ├── Dockerfile ├── Features │ ├── weather-forecast.feature │ └── weather-forecast.feature.cs ├── Hooks │ ├── GlobalHooks.cs │ └── WeatherForecastHooks.cs ├── Pages │ ├── Abstract │ │ └── PageObject.cs │ ├── HomePage.cs │ ├── NavBar.cs │ └── WeatherForecastPage.cs ├── Settings │ └── BrowserSettings.cs ├── Steps │ ├── Abstract │ │ └── BaseSteps.cs │ ├── HomeSteps.cs │ └── WeatherForecastSteps.cs ├── TestHarness.cs ├── TestHostEnvironment.cs ├── Usings.cs └── appsettings.json ├── CleanArchitecture.Api.Tests ├── CleanArchitecture.Api.Tests.csproj ├── Controllers │ ├── ErrorsControllerTests.cs │ ├── HealthControllerTests.cs │ ├── LocationsControllerTests.cs │ └── WeatherForecastsControllerTests.cs ├── Extensions │ └── HttpResponseMessageExtensions.cs ├── TestWebApplication.cs ├── Usings.cs └── appsettings.test.json ├── CleanArchitecture.Application.Tests ├── CleanArchitecture.Application.Tests.csproj ├── Usings.cs └── Weather │ ├── DomainEventHandlers │ └── WeatherForecastCreatedDomainEventHandlerTests.cs │ └── IntegrationEvents │ └── WeatherForecastCreatedEventTests.cs ├── CleanArchitecture.Arch.Tests ├── ApiLayerTests.cs ├── ApplicationLayerTests.cs ├── BaseTests.cs ├── CleanArchitecture.Arch.Tests.csproj ├── CleanArchitectureTests.cs ├── DomainDrivenDesignTests.cs ├── Extensions │ └── ConditionListExtensions.cs └── Usings.cs ├── CleanArchitecture.Core.Tests ├── Builders │ ├── LocationBuilder.cs │ └── WeatherForecastBuilder.cs ├── CleanArchitecture.Core.Tests.csproj ├── Factories │ └── MockRepositoryFactory.cs ├── Locations │ └── Entities │ │ └── LocationTests.cs ├── Usings.cs └── Weather │ └── Entities │ └── WeatherForecastTests.cs └── CleanArchitecture.Infrastructure.Tests ├── CleanArchitecture.Infrastructure.Tests.csproj ├── Repositories ├── Abstract │ └── BaseRepositoryTests.cs └── RepositoryTests.cs └── Usings.cs /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.vs 6 | **/.vscode 7 | **/*.*proj.user 8 | **/azds.yaml 9 | **/charts 10 | **/bin 11 | **/obj 12 | **/Dockerfile 13 | **/Dockerfile.develop 14 | **/docker-compose.yml 15 | **/docker-compose.*.yml 16 | **/*.dbmdl 17 | **/*.jfm 18 | **/secrets.dev.yaml 19 | **/values.dev.yaml 20 | **/.toolstarget 21 | **/node_modules 22 | **/appsettings.Local.json -------------------------------------------------------------------------------- /.template.config/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-bentley/CleanArchitecture/e92a0bb774b33c9e581ac3168507c8d4090466e5/.template.config/icon.png -------------------------------------------------------------------------------- /.template.config/template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/template", 3 | "author": "Matt Bentley", 4 | "name": "Clean Architecture API", 5 | "classifications": [ "Clean Architecture", "API", "Angular" ], 6 | "identity": "MattBentley.CleanArchitecture", 7 | "shortName": "cleanarchitecture", 8 | "tags": { 9 | "language": "C#", 10 | "type": "solution" 11 | }, 12 | "sourceName": "CleanArchitecture", 13 | "preferNameDirectory": true, 14 | "symbols": { 15 | "AuthoringMode": { 16 | "type": "generated", 17 | "generator": "constant", 18 | "parameters": { 19 | "value": "false" 20 | } 21 | }, 22 | "IncludeTests": { 23 | "type": "parameter", 24 | "datatype": "bool", 25 | "defaultValue": "true", 26 | "displayName": "Include Tests", 27 | "description": "Include test projects. This will include Unit, API and UI tests." 28 | }, 29 | "IncludeWeb": { 30 | "type": "parameter", 31 | "datatype": "bool", 32 | "defaultValue": "false", 33 | "displayName": "Use Web Application", 34 | "description": "Create an Angular Web Application." 35 | }, 36 | "DatabaseType": { 37 | "type": "parameter", 38 | "datatype": "choice", 39 | "choices": [ 40 | { 41 | "choice": "SQL Server", 42 | "description": "Adds the SQL Server Entity Framework provider." 43 | }, 44 | { 45 | "choice": "PostgreSQL", 46 | "description": "Adds the PostgreSQL Entity Framework provider." 47 | } 48 | ], 49 | "defaultValue": "SQL Server", 50 | "displayName": "Database Type", 51 | "description": "Configure which Entity Framework provider should be used for the Infrastructure layer when connecting to the Database." 52 | }, 53 | "UseSqlServer": { 54 | "type": "computed", 55 | "value": "(DatabaseType == \"SQL Server\")" 56 | } 57 | }, 58 | "sources": [ 59 | { 60 | "modifiers": [ 61 | { 62 | "condition": "(!IncludeTests)", 63 | "exclude": [ "tests/**" ] 64 | }, 65 | { 66 | "condition": "(!IncludeWeb)", 67 | "exclude": [ 68 | "src/CleanArchitecture.Web/**", 69 | "tests/CleanArchitecture.AcceptanceTests/**", 70 | "**/charts/web/**" 71 | ] 72 | }, 73 | { 74 | "exclude": [ 75 | "**/node_modules/**", 76 | "**/.angular/**" 77 | ] 78 | } 79 | ] 80 | } 81 | ] 82 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 matt-bentley 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 | -------------------------------------------------------------------------------- /charts/api/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: api 3 | version: 1.0.0 4 | description: Clean Architecture API 5 | 6 | -------------------------------------------------------------------------------- /charts/api/templates/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: 'api' 5 | spec: 6 | replicas: {{ default 1 .Values.deploy.minReplicas }} 7 | selector: 8 | matchLabels: 9 | app: api 10 | template: 11 | metadata: 12 | labels: 13 | app: api 14 | spec: 15 | containers: 16 | - image: "{{.Values.deploy.registry}}/cleanarchitecture/api:{{.Values.deploy.imageTag}}" 17 | imagePullPolicy: Always 18 | name: api 19 | resources: 20 | requests: 21 | memory: "512Mi" 22 | cpu: "250m" 23 | limits: 24 | memory: "1Gi" 25 | cpu: "500m" 26 | env: 27 | - name: ASPNETCORE_URLS 28 | value: "http://+:{{.Values.deploy.containerPort}};" 29 | readinessProbe: 30 | httpGet: 31 | path: /liveness 32 | port: http 33 | periodSeconds: 30 34 | livenessProbe: 35 | httpGet: 36 | path: /liveness 37 | port: http 38 | periodSeconds: 30 39 | failureThreshold: 5 40 | startupProbe: 41 | httpGet: 42 | path: /liveness 43 | port: http 44 | periodSeconds: 2 45 | failureThreshold: 60 46 | securityContext: 47 | runAsUser: 1000 48 | privileged: false 49 | allowPrivilegeEscalation: false 50 | ports: 51 | - name: http 52 | containerPort: {{.Values.deploy.containerPort}} 53 | protocol: TCP 54 | restartPolicy: Always -------------------------------------------------------------------------------- /charts/api/templates/api-hpa.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: autoscaling/v1 2 | kind: HorizontalPodAutoscaler 3 | metadata: 4 | name: 'api' 5 | spec: 6 | maxReplicas: {{ default 2 .Values.deploy.maxReplicas }} 7 | minReplicas: {{ default 1 .Values.deploy.minReplicas }} 8 | scaleTargetRef: 9 | apiVersion: apps/v1 10 | kind: Deployment 11 | name: 'api' 12 | targetCPUUtilizationPercentage: 60 -------------------------------------------------------------------------------- /charts/api/templates/database-migration-job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: 'database-migration' 5 | annotations: 6 | # This is what defines this resource as a hook. Without this line, the 7 | # job is considered part of the release. 8 | "helm.sh/hook": pre-install, pre-upgrade 9 | "helm.sh/hook-weight": "-5" 10 | "helm.sh/hook-delete-policy": before-hook-creation 11 | spec: 12 | template: 13 | metadata: 14 | name: 'database-migration' 15 | spec: 16 | restartPolicy: Never 17 | containers: 18 | - name: database-migration-job 19 | image: "{{.Values.deploy.registry}}/cleanarchitecture/migrations:{{.Values.deploy.imageTag}}" 20 | resources: 21 | requests: 22 | memory: "128Mi" 23 | cpu: "10m" 24 | limits: 25 | memory: "256Mi" 26 | cpu: "100m" 27 | securityContext: 28 | runAsUser: 1000 29 | privileged: false 30 | allowPrivilegeEscalation: false 31 | backoffLimit: 0 -------------------------------------------------------------------------------- /charts/api/templates/web-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: 'api' 5 | labels: 6 | app: api 7 | name: 'api' 8 | spec: 9 | type: "{{.Values.service.type}}" 10 | ports: 11 | - name: http 12 | port: {{.Values.service.port}} 13 | protocol: TCP 14 | targetPort: {{.Values.deploy.containerPort}} 15 | selector: 16 | app: "api" -------------------------------------------------------------------------------- /charts/api/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for chart 2 | 3 | service: 4 | type: ClusterIP 5 | port: 80 6 | 7 | deploy: 8 | minReplicas: 1 9 | maxReplicas: 5 10 | registry: "docker.io" 11 | imageTag: "latest" 12 | containerPort: 8080 13 | -------------------------------------------------------------------------------- /charts/charts.projitems: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 5 | true 6 | 0a906579-dee5-4ef0-bac9-7496400b1c1e 7 | 8 | 9 | charts 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /charts/charts.shproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0a906579-dee5-4ef0-bac9-7496400b1c1e 5 | 14.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /charts/web/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: web 3 | version: 1.0.0 4 | description: Clean Architecture Web Application 5 | -------------------------------------------------------------------------------- /charts/web/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: 'web' 5 | spec: 6 | replicas: {{ default 1 .Values.deploy.replicas }} 7 | selector: 8 | matchLabels: 9 | app: web 10 | template: 11 | metadata: 12 | labels: 13 | app: web 14 | spec: 15 | containers: 16 | - image: "{{.Values.deploy.registry}}/cleanarchitecture/web:{{.Values.deploy.imageTag}}" 17 | imagePullPolicy: Always 18 | name: web 19 | env: 20 | - name: ASPNETCORE_URLS 21 | value: "http://+:{{.Values.deploy.containerPort}};" 22 | resources: 23 | requests: 24 | memory: "128Mi" 25 | cpu: "10m" 26 | limits: 27 | memory: "256Mi" 28 | cpu: "100m" 29 | securityContext: 30 | runAsUser: 1000 31 | privileged: false 32 | allowPrivilegeEscalation: false 33 | readinessProbe: 34 | httpGet: 35 | path: /liveness 36 | port: http 37 | periodSeconds: 30 38 | livenessProbe: 39 | httpGet: 40 | path: /liveness 41 | port: http 42 | periodSeconds: 30 43 | failureThreshold: 5 44 | startupProbe: 45 | httpGet: 46 | path: /liveness 47 | port: http 48 | periodSeconds: 2 49 | failureThreshold: 60 50 | ports: 51 | - name: http 52 | containerPort: {{.Values.deploy.containerPort}} 53 | protocol: TCP 54 | restartPolicy: Always -------------------------------------------------------------------------------- /charts/web/templates/web-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: 'web' 5 | labels: 6 | app: web 7 | name: 'web' 8 | spec: 9 | type: "{{.Values.service.type}}" 10 | ports: 11 | - name: http 12 | port: {{.Values.service.port}} 13 | protocol: TCP 14 | targetPort: {{.Values.deploy.containerPort}} 15 | selector: 16 | app: web -------------------------------------------------------------------------------- /charts/web/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for chart 2 | 3 | service: 4 | type: ClusterIP 5 | port: 80 6 | 7 | deploy: 8 | replicas: 1 9 | registry: "docker.io" 10 | imageTag: "latest" 11 | containerPort: 8080 12 | 13 | -------------------------------------------------------------------------------- /docker-compose.dcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 2.1 5 | Linux 6 | 90523849-0a00-444d-bc6d-a8bf11b839b6 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.4" 2 | services: 3 | 4 | api: 5 | build: 6 | context: . 7 | dockerfile: src/CleanArchitecture.Api/Dockerfile 8 | image: ${DOCKER_REGISTRY}cleanarchitecture/api:${TAG:-latest} 9 | profiles: ["server", "cd"] 10 | 11 | web: 12 | build: 13 | context: . 14 | dockerfile: src/CleanArchitecture.Web/Dockerfile 15 | image: ${DOCKER_REGISTRY}cleanarchitecture/web:${TAG:-latest} 16 | ports: 17 | - 8080:8080 18 | profiles: ["web", "cd"] 19 | 20 | migrations: 21 | build: 22 | context: . 23 | dockerfile: src/CleanArchitecture.Migrations/Dockerfile 24 | image: ${DOCKER_REGISTRY}cleanarchitecture/migrations:${TAG:-latest} 25 | profiles: ["server", "cd"] 26 | 27 | rabbitmq: 28 | image: masstransit/rabbitmq 29 | profiles: ["dev"] 30 | container_name: cleanarchitecture-rabbitmq 31 | ports: 32 | - "5672:5672" # AMQP protocol port 33 | - "15672:15672" # Management web UI port 34 | environment: 35 | RABBITMQ_DEFAULT_USER: "guest" 36 | RABBITMQ_DEFAULT_PASS: "guest" 37 | 38 | #if (UseSqlServer) 39 | sql: 40 | image: mcr.microsoft.com/mssql/server:2019-latest 41 | profiles: ["dev"] 42 | container_name: cleanarchitecture-sql 43 | user: root 44 | ports: 45 | - 1433:1433 46 | environment: 47 | - ACCEPT_EULA=Y 48 | - "MSSQL_SA_PASSWORD=Admin1234!" 49 | volumes: 50 | - cleanarchitecture-sql:/var/opt/mssql/data 51 | 52 | #else 53 | postgres: 54 | image: postgres:latest 55 | profiles: ["dev"] 56 | container_name: cleanarchitecture-postgres 57 | ports: 58 | - 5432:5432 59 | environment: 60 | - POSTGRES_USER=postgres 61 | - "POSTGRES_PASSWORD=Admin1234!" 62 | - POSTGRES_DB=Weather 63 | volumes: 64 | - cleanarchitecture-postgres:/var/lib/postgresql 65 | 66 | #endif 67 | ui-tests: 68 | build: 69 | context: . 70 | dockerfile: tests/CleanArchitecture.AcceptanceTests/Dockerfile 71 | image: ${DOCKER_REGISTRY}cleanarchitecture/acceptancetests:${TAG:-latest} 72 | container_name: weather-acceptancetests${TAG:-dev} 73 | environment: 74 | - Browser__Headless=true 75 | - Browser__SlowMoMilliseconds=150 76 | - Browser__BaseUrl=${BaseUrl} 77 | profiles: ["ui-tests"] 78 | 79 | 80 | volumes: # this volume ensures that data is persisted when the container is deleted 81 | #if (UseSqlServer) 82 | cleanarchitecture-sql: 83 | #else 84 | cleanarchitecture-postgres: 85 | #endif -------------------------------------------------------------------------------- /pipelines/README.md: -------------------------------------------------------------------------------- 1 | Infrastructure-as-code and CI/CD pipeline code should go here -------------------------------------------------------------------------------- /pipelines/pipelines.projitems: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | $(MSBuildAllProjects);$(MSBuildThisFileFullPath) 5 | true 6 | f518fac2-b2fc-4438-b5b1-2b001404ba43 7 | 8 | 9 | pipelines 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pipelines/pipelines.shproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | f518fac2-b2fc-4438-b5b1-2b001404ba43 5 | 14.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/CleanArchitecture.Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | $(DefineConstants);UseSqlServer 7 | 8 | true 9 | 10 | 11 | 12 | 13 | net8.0 14 | enable 15 | enable 16 | 17 | 18 | 19 | 20 | <_Parameter1>$(MSBuildProjectName).Tests 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Controllers/LocationsController.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using CleanArchitecture.Application.Locations.Queries; 3 | using CleanArchitecture.Application.Locations.Models; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace CleanArchitecture.Api.Controllers 7 | { 8 | [ApiController] 9 | [Route("api/locations")] 10 | [Produces("application/json")] 11 | public sealed class LocationsController : ControllerBase 12 | { 13 | private readonly IMediator _mediator; 14 | 15 | public LocationsController(IMediator mediator) 16 | { 17 | _mediator = mediator; 18 | } 19 | 20 | [HttpGet] 21 | [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] 22 | public async Task Get() 23 | { 24 | var locations = await _mediator.Send(new GetLocationsQuery()); 25 | return Ok(locations); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Controllers/WeatherForecastsController.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using CleanArchitecture.Application.Weather.Commands; 3 | using CleanArchitecture.Application.Weather.Queries; 4 | using CleanArchitecture.Api.Infrastructure.ActionResults; 5 | using CleanArchitecture.Application.Weather.Models; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace CleanArchitecture.Api.Controllers 9 | { 10 | [ApiController] 11 | [Route("api/weather-forecasts")] 12 | [Produces("application/json")] 13 | public sealed class WeatherForecastsController : ControllerBase 14 | { 15 | private readonly IMediator _mediator; 16 | 17 | public WeatherForecastsController(IMediator mediator) 18 | { 19 | _mediator = mediator; 20 | } 21 | 22 | [HttpGet("{id}")] 23 | [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] 24 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status404NotFound)] 25 | public async Task Get(Guid id) 26 | { 27 | var forecast = await _mediator.Send(new GetWeatherForecastQuery(id)); 28 | return Ok(forecast); 29 | } 30 | 31 | [HttpGet] 32 | [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] 33 | public async Task Get([FromQuery] Guid? locationId) 34 | { 35 | var forecasts = await _mediator.Send(new GetWeatherForecastsQuery(locationId)); 36 | return Ok(forecasts); 37 | } 38 | 39 | [HttpPost] 40 | [ProducesResponseType(typeof(CreatedResultEnvelope), StatusCodes.Status201Created)] 41 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status400BadRequest)] 42 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status404NotFound)] 43 | public async Task Post([FromBody] WeatherForecastCreateDto forecast) 44 | { 45 | var id = await _mediator.Send(new CreateWeatherForecastCommand(forecast.TemperatureC, forecast.Date, forecast.Summary, forecast.LocationId)); 46 | return CreatedAtAction(nameof(Get), new { id }, new CreatedResultEnvelope(id)); 47 | } 48 | 49 | [HttpPut("{id}")] 50 | [ProducesResponseType(StatusCodes.Status204NoContent)] 51 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status400BadRequest)] 52 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status404NotFound)] 53 | public async Task Put(Guid id, [FromBody] WeatherForecastUpdateDto forecast) 54 | { 55 | await _mediator.Send(new UpdateWeatherForecastCommand(id, forecast.Date)); 56 | return NoContent(); 57 | } 58 | 59 | [HttpDelete("{id}")] 60 | [ProducesResponseType(StatusCodes.Status204NoContent)] 61 | [ProducesResponseType(typeof(Envelope), StatusCodes.Status400BadRequest)] 62 | public async Task Delete(Guid id) 63 | { 64 | await _mediator.Send(new DeleteWeatherForecastCommand(id)); 65 | return NoContent(); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extra AS base 2 | 3 | WORKDIR /app 4 | EXPOSE 8080 5 | 6 | ENV ASPNETCORE_URLS=http://+:8080; 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build 9 | 10 | COPY ["src/", "/src/"] 11 | 12 | WORKDIR /src/CleanArchitecture.Api 13 | RUN dotnet restore "CleanArchitecture.Api.csproj" && \ 14 | dotnet publish "CleanArchitecture.Api.csproj" --no-restore -c Release -o /app/publish 15 | 16 | FROM base AS final 17 | COPY --from=build /app/publish . 18 | USER 1000 19 | ENTRYPOINT ["dotnet", "CleanArchitecture.Api.dll"] -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Infrastructure/ActionResults/Envelope.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Net; 3 | 4 | namespace CleanArchitecture.Api.Infrastructure.ActionResults 5 | { 6 | public class Envelope 7 | { 8 | public int Status { get; set; } 9 | public string? ErrorMessage { get; set; } 10 | public DateTime Timestamp { get; set; } 11 | public string? TraceId { get; set; } 12 | 13 | protected Envelope(int status, string? errorMessage, DateTime timestamp, string? traceId) 14 | { 15 | Status = status; 16 | ErrorMessage = errorMessage; 17 | Timestamp = timestamp; 18 | TraceId = traceId; 19 | } 20 | 21 | protected Envelope() 22 | { 23 | 24 | } 25 | 26 | public static Envelope Create(string error, HttpStatusCode statusCode) 27 | { 28 | return new Envelope((int)statusCode, error, DateTime.UtcNow, Activity.Current?.Id); 29 | } 30 | 31 | public EnvelopeObjectResult ToActionResult() 32 | { 33 | return new EnvelopeObjectResult(this); 34 | } 35 | } 36 | 37 | public class CreatedResultEnvelope 38 | { 39 | public Guid Id { get; set; } 40 | 41 | public CreatedResultEnvelope(Guid id) 42 | { 43 | Id = id; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Infrastructure/ActionResults/EnvelopeObjectResult.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace CleanArchitecture.Api.Infrastructure.ActionResults 4 | { 5 | public class EnvelopeObjectResult : ObjectResult 6 | { 7 | public EnvelopeObjectResult(Envelope envelope) 8 | : base(envelope) 9 | { 10 | StatusCode = envelope.Status; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Infrastructure/Filters/HttpGlobalExceptionFilter.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Exceptions; 2 | using CleanArchitecture.Api.Infrastructure.ActionResults; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | using System.Net; 5 | 6 | namespace CleanArchitecture.Api.Infrastructure.Filters 7 | { 8 | public sealed class HttpGlobalExceptionFilter : IExceptionFilter 9 | { 10 | private readonly IWebHostEnvironment _env; 11 | private readonly ILogger _logger; 12 | 13 | public HttpGlobalExceptionFilter(IWebHostEnvironment env, ILogger logger) 14 | { 15 | _env = env; 16 | _logger = logger; 17 | } 18 | 19 | public void OnException(ExceptionContext context) 20 | { 21 | _logger.LogError(new EventId(context.Exception.HResult), 22 | context.Exception, 23 | context.Exception.Message); 24 | 25 | Envelope envelope; 26 | if (context.Exception.GetType() == typeof(DomainException)) 27 | { 28 | envelope = Envelope.Create(context.Exception.Message, HttpStatusCode.BadRequest); 29 | } 30 | else if (context.Exception.GetType() == typeof(UnauthorizedAccessException)) 31 | { 32 | envelope = Envelope.Create("Access denied", HttpStatusCode.Forbidden); 33 | } 34 | else if (context.Exception.GetType() == typeof(NotFoundException)) 35 | { 36 | envelope = Envelope.Create(context.Exception.Message, HttpStatusCode.NotFound); 37 | } 38 | else 39 | { 40 | var message = _env.IsDevelopment() ? context.Exception.ToString() : "Sorry an error occured, please try again."; 41 | envelope = Envelope.Create(message, HttpStatusCode.InternalServerError); 42 | } 43 | 44 | context.Result = envelope.ToActionResult(); 45 | context.HttpContext.Response.StatusCode = envelope.Status; 46 | context.ExceptionHandled = true; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using CleanArchitecture.Api.Infrastructure.Filters; 3 | using CleanArchitecture.Application.AutofacModules; 4 | using CleanArchitecture.Infrastructure.AutofacModules; 5 | using Microsoft.AspNetCore.Diagnostics.HealthChecks; 6 | using Microsoft.Extensions.Diagnostics.HealthChecks; 7 | using Microsoft.OpenApi.Models; 8 | 9 | var builder = WebApplication.CreateBuilder(args); 10 | 11 | builder.Host.RegisterDefaults(); 12 | 13 | // Add services to the container. 14 | builder.Services.AddControllers(options => 15 | { 16 | options.Filters.Add(typeof(HttpGlobalExceptionFilter)); 17 | }); 18 | 19 | // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle 20 | builder.Services.AddEndpointsApiExplorer(); 21 | builder.Services.AddSwaggerGen(options => 22 | { 23 | options.SwaggerDoc("v1", 24 | new OpenApiInfo 25 | { 26 | Title = "CleanArchitecture API", 27 | Version = "v1", 28 | Description = "HTTP API for accessing CleanArchitecture data" 29 | }); 30 | options.DescribeAllParametersInCamelCase(); 31 | }); 32 | builder.Services.AddCors(); 33 | builder.Services.AddHealthChecks() 34 | .AddCheck("self", () => HealthCheckResult.Healthy("Application is running")) 35 | #if (UseSqlServer) 36 | .AddSqlServer(builder.Configuration["Database:SqlConnectionString"]!); 37 | #else 38 | .AddNpgSql(builder.Configuration["Database:PostgresConnectionString"]!); 39 | #endif 40 | 41 | //Add HSTS 42 | builder.Services.AddHsts(options => 43 | { 44 | options.Preload = true; 45 | options.IncludeSubDomains = true; 46 | options.MaxAge = TimeSpan.FromDays(365); 47 | }); 48 | 49 | builder.Services.AddMiniTransit((_, configure) => 50 | { 51 | configure.UseRabbitMQ(options => 52 | { 53 | builder.Configuration.GetSection("EventBus").Bind(options); 54 | }); 55 | }); 56 | 57 | builder.Host.ConfigureContainer(container => 58 | { 59 | container.RegisterModule(new ApplicationModule()); 60 | container.RegisterModule(new InfrastructureModule(builder.Configuration)); 61 | }); 62 | 63 | var app = builder.Build(); 64 | 65 | app.UseSwagger(); 66 | app.UseSwaggerUI(); 67 | 68 | if (app.Environment.IsProduction()) 69 | { 70 | // Required to forward headers from load balancers and reverse proxies 71 | app.UseForwardedHeaders(); 72 | app.UseHttpsRedirection(); 73 | 74 | //Add security response headers 75 | app.UseHsts(); 76 | app.Use((context, next) => 77 | { 78 | context.Response.Headers.Append("X-Xss-Protection", "1; mode=block"); 79 | context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); 80 | context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN"); 81 | return next.Invoke(); 82 | }); 83 | } 84 | 85 | app.UseCors(options => 86 | { 87 | options.AllowAnyMethod() 88 | .AllowAnyHeader() 89 | .AllowAnyOrigin() 90 | .WithExposedHeaders("Content-Disposition"); 91 | }); 92 | 93 | app.UseAuthorization(); 94 | 95 | app.MapHealthChecks("healthz"); 96 | app.MapHealthChecks("liveness", new HealthCheckOptions 97 | { 98 | Predicate = r => r.Name.Contains("self") 99 | }); 100 | 101 | app.MapControllers(); 102 | 103 | app.Run(); 104 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/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 | "launchUrl": "swagger", 9 | "applicationUrl": "https://localhost:7283;http://localhost:5164", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information", 5 | "Override": { 6 | "Microsoft": "Warning", 7 | "System": "Warning" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information", 5 | "Override": { 6 | "Microsoft": "Warning", 7 | "System": "Warning", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | } 11 | }, 12 | "Database": { 13 | //#if( UseSqlServer ) 14 | "SqlConnectionString": "Server=127.0.0.1, 1433; Database=Weather; Integrated Security=False; User Id = SA; Password=Admin1234!; MultipleActiveResultSets=False;TrustServerCertificate=True", 15 | //#else 16 | "PostgresConnectionString": "Host=127.0.0.1;Database=Weather;Username=postgres;Password=Admin1234!" 17 | //#endif 18 | }, 19 | "EventBus": { 20 | "UserName": "guest", 21 | "Password": "guest", 22 | "HostName": "localhost", 23 | "Port": 5672 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Commands/Command.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace CleanArchitecture.Application.Abstractions.Commands 4 | { 5 | public abstract record CommandBase : IRequest; 6 | public abstract record Command : CommandBase; 7 | public abstract record CreateCommand : IRequest; 8 | } 9 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Commands/CommandHandler.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Repositories; 2 | using MediatR; 3 | 4 | namespace CleanArchitecture.Application.Abstractions.Commands 5 | { 6 | public abstract class CommandHandler 7 | { 8 | protected readonly IUnitOfWork UnitOfWork; 9 | 10 | protected CommandHandler(IUnitOfWork unitOfWork) 11 | { 12 | UnitOfWork = unitOfWork; 13 | } 14 | } 15 | 16 | public abstract class CommandHandler : CommandHandler, IRequestHandler where TCommand : Command 17 | { 18 | protected CommandHandler(IUnitOfWork unitOfWork) : base(unitOfWork) 19 | { 20 | 21 | } 22 | 23 | public async Task Handle(TCommand request, CancellationToken cancellationToken) 24 | { 25 | await HandleAsync(request); 26 | return Unit.Value; 27 | } 28 | 29 | protected abstract Task HandleAsync(TCommand request); 30 | } 31 | 32 | public abstract class CreateCommandHandler : CommandHandler, IRequestHandler where TCommand : CreateCommand 33 | { 34 | protected CreateCommandHandler(IUnitOfWork unitOfWork) : base(unitOfWork) 35 | { 36 | 37 | } 38 | 39 | public async Task Handle(TCommand request, CancellationToken cancellationToken) 40 | { 41 | return await HandleAsync(request); 42 | } 43 | 44 | protected abstract Task HandleAsync(TCommand request); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/DomainEventHandlers/DomainEventHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using CleanArchitecture.Core.Abstractions.DomainEvents; 3 | using Microsoft.Extensions.Logging; 4 | using System.Diagnostics; 5 | 6 | namespace CleanArchitecture.Application.Abstractions.DomainEventHandlers 7 | { 8 | public abstract class DomainEventHandler : INotificationHandler where T : DomainEvent 9 | { 10 | protected readonly ILogger> Logger; 11 | 12 | protected DomainEventHandler(ILogger> logger) 13 | { 14 | Logger = logger; 15 | } 16 | 17 | public async Task Handle(T notification, CancellationToken cancellationToken) 18 | { 19 | Logger.LogInformation("Processing domain event: {type}", this.GetType().Name); 20 | await OnHandleAsync(notification); 21 | Logger.LogInformation("Completed processing domain event: {type}", this.GetType().Name); 22 | } 23 | 24 | protected abstract Task OnHandleAsync(T @event); 25 | 26 | protected static string CorrelationId => Activity.Current?.Id ?? Guid.NewGuid().ToString(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/IntegrationEvents/IntegrationEvent.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Abstractions.IntegrationEvents 3 | { 4 | public abstract record IntegrationEvent(string CorrelationId); 5 | } 6 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Queries/Query.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace CleanArchitecture.Application.Abstractions.Queries 4 | { 5 | public abstract record Query : IRequest; 6 | } 7 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Queries/QueryHandler.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using MediatR; 3 | 4 | namespace CleanArchitecture.Application.Abstractions.Queries 5 | { 6 | public abstract class QueryHandler : IRequestHandler where TQuery : Query 7 | { 8 | protected readonly IMapper Mapper; 9 | 10 | public QueryHandler(IMapper mapper) 11 | { 12 | Mapper = mapper; 13 | } 14 | 15 | public async Task Handle(TQuery request, CancellationToken cancellationToken) 16 | { 17 | return await HandleAsync(request); 18 | } 19 | 20 | protected abstract Task HandleAsync(TQuery request); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Repositories/IRepository.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Entities; 2 | 3 | namespace CleanArchitecture.Application.Abstractions.Repositories 4 | { 5 | public interface IRepository where T : AggregateRoot 6 | { 7 | IQueryable GetAll(bool noTracking = true); 8 | Task GetByIdAsync(Guid id); 9 | void Insert(T entity); 10 | void Insert(List entities); 11 | void Delete(T entity); 12 | void Remove(IEnumerable entitiesToRemove); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Repositories/IUnitOfWork.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Abstractions.Repositories 3 | { 4 | public interface IUnitOfWork : IDisposable 5 | { 6 | Task CommitAsync(CancellationToken cancellationToken = default); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Abstractions/Services/INotificationsService.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Services 3 | { 4 | public interface INotificationsService 5 | { 6 | Task WeatherAlertAsync(string summary, int temperatureC, DateTime date); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/AutofacModules/ApplicationModule.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Autofac; 3 | using AutoMapper; 4 | using System.Reflection; 5 | using MediatR.NotificationPublishers; 6 | 7 | namespace CleanArchitecture.Application.AutofacModules 8 | { 9 | public sealed class ApplicationModule : Autofac.Module 10 | { 11 | protected override void Load(ContainerBuilder builder) 12 | { 13 | builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly) 14 | // this publisher causes problems with the EF Core DbContext 15 | // as it is not thread safe 16 | .Where(e => e != typeof(TaskWhenAllPublisher)) 17 | .AsImplementedInterfaces(); 18 | 19 | // Register the DomainEventHandler classes (they implement INotificationHandler<>) in assembly 20 | builder.RegisterAssemblyTypes(ThisAssembly) 21 | .AsClosedTypesOf(typeof(INotificationHandler<>)); 22 | 23 | // Register the Command and Query handler classes (they implement IRequestHandler<>) 24 | builder.RegisterAssemblyTypes(ThisAssembly) 25 | .AsClosedTypesOf(typeof(IRequestHandler<,>)); 26 | 27 | // Register Automapper profiles 28 | var config = new MapperConfiguration(cfg => { cfg.AddMaps(ThisAssembly); }); 29 | config.AssertConfigurationIsValid(); 30 | 31 | builder.Register(c => config) 32 | .AsSelf() 33 | .SingleInstance(); 34 | 35 | builder.Register(c => 36 | { 37 | var ctx = c.Resolve(); 38 | var mapperConfig = c.Resolve(); 39 | return mapperConfig.CreateMapper(ctx.Resolve); 40 | }).As() 41 | .SingleInstance(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/CleanArchitecture.Application.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Locations/MappingProfiles/LocationProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CleanArchitecture.Application.Locations.Models; 3 | using CleanArchitecture.Core.Locations.Entities; 4 | 5 | namespace CleanArchitecture.Application.Locations.MappingProfiles 6 | { 7 | public class LocationProfile : Profile 8 | { 9 | public LocationProfile() 10 | { 11 | 12 | CreateMap() 13 | .ForMember(dest => dest.Latitude, 14 | e => e.MapFrom(src => src.Coordinates.Latitude)) 15 | .ForMember(dest => dest.Longitude, 16 | e => e.MapFrom(src => src.Coordinates.Longitude)); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Locations/Models/LocationDto.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Locations.Models 3 | { 4 | public sealed class LocationDto 5 | { 6 | public Guid Id { get; set; } 7 | public string? Country { get; set; } 8 | public string? City { get; set; } 9 | public decimal Latitude { get; set; } 10 | public decimal Longitude { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Locations/Queries/GetLocationsQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CleanArchitecture.Application.Abstractions.Queries; 3 | using CleanArchitecture.Application.Abstractions.Repositories; 4 | using CleanArchitecture.Application.Locations.Models; 5 | using CleanArchitecture.Core.Locations.Entities; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace CleanArchitecture.Application.Locations.Queries 9 | { 10 | public sealed record GetLocationsQuery() : Query>; 11 | 12 | public sealed class GetLocationsQueryHandler : QueryHandler> 13 | { 14 | private readonly IRepository _repository; 15 | 16 | public GetLocationsQueryHandler(IMapper mapper, 17 | IRepository repository) : base(mapper) 18 | { 19 | _repository = repository; 20 | } 21 | 22 | protected override async Task> HandleAsync(GetLocationsQuery request) 23 | { 24 | var locations = await _repository.GetAll() 25 | .OrderBy(e => e.City) 26 | .ToListAsync(); 27 | return Mapper.Map>(locations); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Commands/CreateWeatherForecastCommand.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Commands; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using CleanArchitecture.Core.Abstractions.Guards; 4 | using CleanArchitecture.Core.Locations.Entities; 5 | using CleanArchitecture.Core.Weather.Entities; 6 | using CleanArchitecture.Core.Weather.ValueObjects; 7 | 8 | namespace CleanArchitecture.Application.Weather.Commands 9 | { 10 | public sealed record CreateWeatherForecastCommand(int Temperature, DateTime Date, string? Summary, Guid LocationId) : CreateCommand; 11 | 12 | public sealed class CreateWeatherForecastCommandHandler : CreateCommandHandler 13 | { 14 | private readonly IRepository _repository; 15 | private readonly IRepository _locationsRepository; 16 | 17 | public CreateWeatherForecastCommandHandler(IRepository repository, 18 | IRepository locationsRepository, 19 | IUnitOfWork unitOfWork) : base(unitOfWork) 20 | { 21 | _repository = repository; 22 | _locationsRepository = locationsRepository; 23 | } 24 | 25 | protected override async Task HandleAsync(CreateWeatherForecastCommand request) 26 | { 27 | var location = await _locationsRepository.GetByIdAsync(request.LocationId); 28 | location = Guard.Against.NotFound(location, $"Location not found: {request.LocationId}"); 29 | 30 | var created = WeatherForecast.Create(request.Date, 31 | Temperature.FromCelcius(request.Temperature), 32 | request.Summary, 33 | location.Id); 34 | _repository.Insert(created); 35 | await UnitOfWork.CommitAsync(); 36 | return created.Id; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Commands/DeleteWeatherForecastCommand.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Commands; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using CleanArchitecture.Core.Weather.Entities; 4 | using CleanArchitecture.Core.Abstractions.Guards; 5 | 6 | namespace CleanArchitecture.Application.Weather.Commands 7 | { 8 | public sealed record DeleteWeatherForecastCommand(Guid Id) : Command; 9 | 10 | public sealed class DeleteWeatherForecastCommandHandler : CommandHandler 11 | { 12 | private readonly IRepository _repository; 13 | 14 | public DeleteWeatherForecastCommandHandler(IRepository repository, 15 | IUnitOfWork unitOfWork) : base(unitOfWork) 16 | { 17 | _repository = repository; 18 | } 19 | 20 | protected override async Task HandleAsync(DeleteWeatherForecastCommand request) 21 | { 22 | var forecast = await _repository.GetByIdAsync(request.Id); 23 | forecast = Guard.Against.NotFound(forecast); 24 | _repository.Delete(forecast); 25 | await UnitOfWork.CommitAsync(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Commands/UpdateWeatherForecastCommand.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Commands; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using CleanArchitecture.Core.Weather.Entities; 4 | using CleanArchitecture.Core.Abstractions.Guards; 5 | 6 | namespace CleanArchitecture.Application.Weather.Commands 7 | { 8 | public sealed record UpdateWeatherForecastCommand(Guid Id, DateTime Date) : Command; 9 | 10 | public sealed class UpdateWeatherForecastCommandHandler : CommandHandler 11 | { 12 | private readonly IRepository _repository; 13 | 14 | public UpdateWeatherForecastCommandHandler(IRepository repository, 15 | IUnitOfWork unitOfWork) : base(unitOfWork) 16 | { 17 | _repository = repository; 18 | } 19 | 20 | protected override async Task HandleAsync(UpdateWeatherForecastCommand request) 21 | { 22 | var forecast = await _repository.GetByIdAsync(request.Id); 23 | forecast = Guard.Against.NotFound(forecast); 24 | forecast.UpdateDate(request.Date); 25 | await UnitOfWork.CommitAsync(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/DomainEventHandlers/WeatherForecastCreatedDomainEventHandler.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.DomainEventHandlers; 2 | using CleanArchitecture.Application.Weather.IntegrationEvents; 3 | using CleanArchitecture.Core.Weather.DomainEvents; 4 | using Microsoft.Extensions.Logging; 5 | using MiniTransit; 6 | 7 | namespace CleanArchitecture.Application.Weather.DomainEventHandlers 8 | { 9 | public sealed class WeatherForecastCreatedDomainEventHandler : DomainEventHandler 10 | { 11 | private readonly IBus _eventBus; 12 | 13 | public WeatherForecastCreatedDomainEventHandler(ILogger> logger, 14 | IBus eventBus) : base(logger) 15 | { 16 | _eventBus = eventBus; 17 | } 18 | 19 | protected override async Task OnHandleAsync(WeatherForecastCreatedDomainEvent @event) 20 | { 21 | await _eventBus.PublishAsync(new WeatherForecastCreatedEvent(@event.Id, @event.Temperature, @event.Summary, @event.Date, CorrelationId)); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/IntegrationEvents/WeatherForecastCreatedEvent.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.IntegrationEvents; 2 | using CleanArchitecture.Core.Abstractions.Services; 3 | using Microsoft.Extensions.Logging; 4 | using MiniTransit; 5 | 6 | namespace CleanArchitecture.Application.Weather.IntegrationEvents 7 | { 8 | public sealed record WeatherForecastCreatedEvent(Guid WeatherForecastId, int Temperature, string Summary, DateTime Date, string CorrelationId) : IntegrationEvent(CorrelationId); 9 | 10 | public sealed class WeatherForecastCreatedEventHandler : IConsumer 11 | { 12 | private readonly INotificationsService _notificationsService; 13 | private readonly ILogger _logger; 14 | 15 | public WeatherForecastCreatedEventHandler( 16 | INotificationsService notificationsService, 17 | ILogger logger) 18 | { 19 | _notificationsService = notificationsService; 20 | _logger = logger; 21 | } 22 | 23 | public async Task ConsumeAsync(ConsumeContext context) 24 | { 25 | var @event = context.Message; 26 | _logger.LogInformation("Processing Weather Forecast: {id}", @event.WeatherForecastId); 27 | if (IsExtremeTemperature(@event.Temperature)) 28 | { 29 | _logger.LogWarning("{summary} temperature alert - {temperature}C", @event.Summary, @event.Temperature); 30 | await _notificationsService.WeatherAlertAsync(@event.Summary, @event.Temperature, @event.Date); 31 | } 32 | } 33 | 34 | private static bool IsExtremeTemperature(int temperatureC) 35 | { 36 | return temperatureC < 0 || temperatureC > 40; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/MappingProfiles/WeatherForecastProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CleanArchitecture.Application.Weather.Models; 3 | using CleanArchitecture.Core.Weather.Entities; 4 | 5 | namespace CleanArchitecture.Application.Weather.MappingProfiles 6 | { 7 | public sealed class WeatherForecastProfile : Profile 8 | { 9 | public WeatherForecastProfile() 10 | { 11 | CreateMap() 12 | .ForMember(dest => dest.TemperatureF, 13 | e => e.MapFrom(src => src.Temperature.Farenheit)) 14 | .ForMember(dest => dest.TemperatureC, 15 | e => e.MapFrom(src => src.Temperature.Celcius)) 16 | .ForMember(dest => dest.Current, 17 | e => e.MapFrom(src => src.Date.Day == DateTime.UtcNow.Day 18 | && src.Date.Month == DateTime.UtcNow.Month 19 | && src.Date.Year == DateTime.UtcNow.Year)); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Models/WeatherForecastCreateDto.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Weather.Models 3 | { 4 | public sealed class WeatherForecastCreateDto 5 | { 6 | public DateTime Date { get; set; } 7 | public int TemperatureC { get; set; } 8 | public string? Summary { get; set; } 9 | public Guid LocationId { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Models/WeatherForecastDto.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Weather.Models 3 | { 4 | public sealed class WeatherForecastDto 5 | { 6 | public Guid Id { get; set; } 7 | public DateTime Date { get; set; } 8 | public int TemperatureC { get; set; } 9 | public int TemperatureF { get; set; } 10 | public string? Summary { get; set; } 11 | public bool Current { get; set; } 12 | public Guid LocationId { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Models/WeatherForecastUpdateDto.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Application.Weather.Models 3 | { 4 | public sealed class WeatherForecastUpdateDto 5 | { 6 | public DateTime Date { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Queries/GetWeatherForecastQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CleanArchitecture.Application.Abstractions.Queries; 3 | using CleanArchitecture.Application.Weather.Models; 4 | using CleanArchitecture.Application.Abstractions.Repositories; 5 | using CleanArchitecture.Core.Weather.Entities; 6 | using CleanArchitecture.Core.Abstractions.Guards; 7 | 8 | namespace CleanArchitecture.Application.Weather.Queries 9 | { 10 | public sealed record GetWeatherForecastQuery(Guid Id) : Query; 11 | 12 | public sealed class GetWeatherForecastQueryHandler : QueryHandler 13 | { 14 | private readonly IRepository _repository; 15 | 16 | public GetWeatherForecastQueryHandler(IMapper mapper, 17 | IRepository repository) : base(mapper) 18 | { 19 | _repository = repository; 20 | } 21 | 22 | protected override async Task HandleAsync(GetWeatherForecastQuery request) 23 | { 24 | var forecast = await _repository.GetByIdAsync(request.Id); 25 | Guard.Against.NotFound(forecast); 26 | return Mapper.Map(forecast); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Application/Weather/Queries/GetWeatherForecastsQuery.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CleanArchitecture.Application.Abstractions.Queries; 3 | using CleanArchitecture.Application.Weather.Models; 4 | using CleanArchitecture.Application.Abstractions.Repositories; 5 | using CleanArchitecture.Core.Weather.Entities; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace CleanArchitecture.Application.Weather.Queries 9 | { 10 | public sealed record GetWeatherForecastsQuery(Guid? LocationId) : Query>; 11 | 12 | public sealed class GetWeatherForecastsQueryHandler : QueryHandler> 13 | { 14 | private readonly IRepository _repository; 15 | 16 | public GetWeatherForecastsQueryHandler(IMapper mapper, 17 | IRepository repository) : base(mapper) 18 | { 19 | _repository = repository; 20 | } 21 | 22 | protected override async Task> HandleAsync(GetWeatherForecastsQuery request) 23 | { 24 | var forecastsQuery = _repository.GetAll(); 25 | 26 | if (request.LocationId.HasValue) 27 | { 28 | forecastsQuery = forecastsQuery.Where(e => e.LocationId == request.LocationId.Value); 29 | } 30 | 31 | var forecasts = await forecastsQuery.OrderBy(e => e.Date) 32 | .ToListAsync(); 33 | 34 | return Mapper.Map>(forecasts); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/DomainEvents/DomainEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace CleanArchitecture.Core.Abstractions.DomainEvents 4 | { 5 | public abstract record DomainEvent : INotification; 6 | } 7 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Entities/AggregateRoot.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.DomainEvents; 2 | 3 | namespace CleanArchitecture.Core.Abstractions.Entities 4 | { 5 | public abstract class AggregateRoot : EntityBase 6 | { 7 | protected AggregateRoot() : this(Guid.NewGuid()) 8 | { 9 | 10 | } 11 | 12 | protected AggregateRoot(Guid id) 13 | { 14 | Id = id; 15 | } 16 | 17 | private readonly List _domainEvents = new List(); 18 | public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); 19 | 20 | public void AddDomainEvent(DomainEvent eventItem) 21 | { 22 | _domainEvents.Add(eventItem); 23 | } 24 | 25 | public void ClearDomainEvents() 26 | { 27 | _domainEvents.Clear(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Entities/EntityBase.cs: -------------------------------------------------------------------------------- 1 | using CSharpFunctionalExtensions; 2 | using System; 3 | 4 | namespace CleanArchitecture.Core.Abstractions.Entities 5 | { 6 | public abstract class EntityBase : Entity 7 | { 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Exceptions/DomainException.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Exceptions 3 | { 4 | public class DomainException : Exception 5 | { 6 | public DomainException(string message) : base(message) 7 | { 8 | 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Exceptions/NotFoundException.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Exceptions 3 | { 4 | public sealed class NotFoundException : Exception 5 | { 6 | public NotFoundException() : this("Not found") 7 | { 8 | 9 | } 10 | 11 | public NotFoundException(string message) : base(message) 12 | { 13 | 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Guards/Guard.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Guards 3 | { 4 | /// 5 | /// Simple interface to provide a generic mechanism to build guard clause extension methods from. 6 | /// 7 | public interface IGuardClause 8 | { 9 | } 10 | 11 | /// 12 | /// An entry point to a set of Guard Clauses defined as extension methods on IGuardClause. 13 | /// 14 | public sealed class Guard : IGuardClause 15 | { 16 | /// 17 | /// An entry point to a set of Guard Clauses. 18 | /// 19 | public static IGuardClause Against { get; } = new Guard(); 20 | 21 | private Guard() { } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Guards/GuardAgainstNotFoundExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Guards 3 | { 4 | public static partial class GuardClauseExtensions 5 | { 6 | public static T NotFound(this IGuardClause guardClause, T? aggregate, string? message = null) where T : class 7 | { 8 | if (aggregate == null) 9 | { 10 | NotFound(message ?? "Not found"); 11 | } 12 | return aggregate!; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Guards/GuardAgainstNullExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Guards 3 | { 4 | public static partial class GuardClauseExtensions 5 | { 6 | public static string NullOrEmpty(this IGuardClause guardClause, string input, string parameterName = "value", string? message = null) 7 | { 8 | if (string.IsNullOrEmpty(input)) 9 | { 10 | Error(message ?? $"Required input '{parameterName}' is missing."); 11 | } 12 | return input; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Guards/GuardAgainstNumberExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Core.Abstractions.Guards 3 | { 4 | public static partial class GuardClauseExtensions 5 | { 6 | public static void LessThan(this IGuardClause guardClause, decimal input, decimal minValue, string parameterName = "Value", string units = "", string? message = null) 7 | { 8 | if (input < minValue) 9 | { 10 | Error(message ?? $"'{parameterName}' must be greater than {minValue}{units}."); 11 | } 12 | } 13 | 14 | public static void ValueOutOfRange(this IGuardClause guardClause, decimal input, decimal minValue, decimal maxValue, string parameterName = "Value", string units = "", string? message = null) 15 | { 16 | if (input < minValue || input > maxValue) 17 | { 18 | Error(message ?? $"'{parameterName}' must be between {minValue}{units} and {maxValue}{units}."); 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Abstractions/Guards/GuardClauseExtensions.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Exceptions; 2 | 3 | namespace CleanArchitecture.Core.Abstractions.Guards 4 | { 5 | public static partial class GuardClauseExtensions 6 | { 7 | private static void Error(string message) 8 | { 9 | throw new DomainException(message); 10 | } 11 | 12 | private static void NotFound(string message) 13 | { 14 | throw new NotFoundException(message); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/CleanArchitecture.Core.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | 13.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/IsExternalInit.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace System.Runtime.CompilerServices 4 | { 5 | /// 6 | /// Reserved to be used by the compiler for tracking metadata. 7 | /// This class should not be used by developers in source code. 8 | /// This is used to allow the use of records in C# 9.0 9 | /// 10 | [EditorBrowsable(EditorBrowsableState.Never)] 11 | public static class IsExternalInit 12 | { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Locations/Entities/Location.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Entities; 2 | using CleanArchitecture.Core.Abstractions.Guards; 3 | using CleanArchitecture.Core.Locations.ValueObjects; 4 | 5 | namespace CleanArchitecture.Core.Locations.Entities 6 | { 7 | public sealed class Location : AggregateRoot 8 | { 9 | private Location(string country, string city, Coordinates coordinates) 10 | { 11 | Country = country; 12 | City = city; 13 | Coordinates = coordinates; 14 | } 15 | 16 | #pragma warning disable CS8618 // this is needed for the ORM for serializing Value Objects 17 | private Location() 18 | #pragma warning restore CS8618 19 | { 20 | 21 | } 22 | 23 | public static Location Create(string country, string city, Coordinates coordinates) 24 | { 25 | // validation should go here before the aggregate is created 26 | // an aggregate should never be in an invalid state 27 | // the coordinates are validated in the Coordinates ValueObject and is always valid 28 | country = (country ?? string.Empty).Trim(); 29 | Guard.Against.NullOrEmpty(country, nameof(Country)); 30 | city = (city ?? string.Empty).Trim(); 31 | Guard.Against.NullOrEmpty(city, nameof(City)); 32 | 33 | return new Location(country, city, coordinates); 34 | } 35 | 36 | public string Country { get; private set; } 37 | public string City { get; private set; } 38 | public Coordinates Coordinates { get; private set; } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Locations/ValueObjects/Coordinates.cs: -------------------------------------------------------------------------------- 1 | using CSharpFunctionalExtensions; 2 | using CleanArchitecture.Core.Abstractions.Guards; 3 | 4 | namespace CleanArchitecture.Core.Locations.ValueObjects 5 | { 6 | public sealed class Coordinates : ValueObject 7 | { 8 | private const int MaxLatitude = 90; 9 | private const int MaxLongitude = 180; 10 | 11 | private Coordinates(decimal latitude, decimal longitude) 12 | { 13 | Latitude = latitude; 14 | Longitude = longitude; 15 | } 16 | 17 | public static Coordinates Create(decimal latitude, decimal longitude) 18 | { 19 | Guard.Against.ValueOutOfRange(latitude, -MaxLatitude, MaxLatitude, nameof(Latitude), "°"); 20 | Guard.Against.ValueOutOfRange(longitude, -MaxLongitude, MaxLongitude, nameof(Longitude), "°"); 21 | return new Coordinates(latitude, longitude); 22 | } 23 | 24 | protected override IEnumerable GetEqualityComponents() 25 | { 26 | yield return Latitude; 27 | yield return Longitude; 28 | } 29 | 30 | public decimal Latitude { get; private set; } 31 | public decimal Longitude { get; private set; } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Weather/DomainEvents/WeatherForecastCreatedDomainEvent.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.DomainEvents; 2 | 3 | namespace CleanArchitecture.Core.Weather.DomainEvents 4 | { 5 | public sealed record WeatherForecastCreatedDomainEvent(Guid Id, int Temperature, string Summary, DateTime Date) : DomainEvent; 6 | } 7 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Weather/Entities/WeatherForecast.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Entities; 2 | using CleanArchitecture.Core.Abstractions.Guards; 3 | using CleanArchitecture.Core.Weather.DomainEvents; 4 | using CleanArchitecture.Core.Weather.ValueObjects; 5 | 6 | namespace CleanArchitecture.Core.Weather.Entities 7 | { 8 | public sealed class WeatherForecast : AggregateRoot 9 | { 10 | private WeatherForecast(DateTime date, Temperature temperature, string summary, Guid locationId) 11 | { 12 | Date = date; 13 | Temperature = temperature; 14 | Summary = summary; 15 | LocationId = locationId; 16 | } 17 | 18 | private WeatherForecast() 19 | { 20 | 21 | } 22 | 23 | public static WeatherForecast Create(DateTime date, Temperature temperature, string? summary, Guid locationId) 24 | { 25 | // validation should go here before the aggregate is created 26 | // an aggregate should never be in an invalid state 27 | // the temperature is validated in the Temperature ValueObject and is always valid 28 | var forecast = new WeatherForecast(date, temperature, ValidateSummary(summary), locationId); 29 | forecast.PublishCreated(); 30 | return forecast; 31 | } 32 | 33 | private void PublishCreated() 34 | { 35 | AddDomainEvent(new WeatherForecastCreatedDomainEvent(Id, Temperature.Celcius, Summary, Date)); 36 | } 37 | 38 | public DateTime Date { get; private set; } 39 | public Temperature Temperature { get; private set; } 40 | public string Summary { get; private set; } 41 | public Guid LocationId { get; private set; } 42 | 43 | public void UpdateDate(DateTime date) 44 | { 45 | Date = date; 46 | } 47 | 48 | public void Update(Temperature temperature, string summary) 49 | { 50 | Temperature = temperature; 51 | Summary = ValidateSummary(summary); 52 | } 53 | 54 | private static string ValidateSummary(string? summary) 55 | { 56 | summary = (summary ?? string.Empty).Trim(); 57 | Guard.Against.NullOrEmpty(summary, nameof(Summary)); 58 | return summary; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Core/Weather/ValueObjects/Temperature.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Guards; 2 | using CSharpFunctionalExtensions; 3 | 4 | namespace CleanArchitecture.Core.Weather.ValueObjects 5 | { 6 | public sealed class Temperature : ValueObject 7 | { 8 | private const int AbsoluteZero = -273; 9 | 10 | private Temperature(int celcius) 11 | { 12 | Celcius = celcius; 13 | } 14 | 15 | public static Temperature FromCelcius(int celcius) 16 | { 17 | Guard.Against.LessThan(celcius, AbsoluteZero, message: "Temperature cannot be below Absolute Zero"); 18 | return new Temperature(celcius); 19 | } 20 | 21 | public static Temperature FromFarenheit(int farenheit) 22 | { 23 | return FromCelcius(ConvertToCelcius(farenheit)); 24 | } 25 | 26 | public int Celcius { get; private set; } 27 | public int Farenheit => ConvertToFarenheit(Celcius); 28 | 29 | public static int ConvertToCelcius(int farenheit) => (int)Math.Round((farenheit - 32) * (5.0 / 9.0), 0); 30 | public static int ConvertToFarenheit(int celcius) => 32 + (int)Math.Round((celcius / 0.5556), 0); 31 | 32 | protected override IEnumerable GetEqualityComponents() 33 | { 34 | yield return Celcius; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Hosting/Application.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | 3 | namespace CleanArchitecture.Hosting 4 | { 5 | public static class Application 6 | { 7 | public static IHostBuilder CreateBuilder(string[] args) 8 | { 9 | return Host.CreateDefaultBuilder(args) 10 | .RegisterDefaults(); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Hosting/CleanArchitecture.Hosting.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Hosting/HostBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Autofac.Extensions.DependencyInjection; 2 | using Serilog; 3 | using Serilog.Events; 4 | 5 | namespace Microsoft.Extensions.Hosting 6 | { 7 | public static class HostBuilderExtensions 8 | { 9 | public static async Task BuildAndRunAsync(this IHostBuilder hostBuilder) 10 | { 11 | try 12 | { 13 | var host = hostBuilder.Build(); 14 | await host.RunAsync(); 15 | } 16 | catch (Exception ex) 17 | { 18 | // This is needed for EF Migrations to work 19 | // https://github.com/dotnet/runtime/issues/60600 20 | var type = ex.GetType().Name; 21 | if (type.Equals("StopTheHostException", StringComparison.Ordinal)) 22 | { 23 | throw; 24 | } 25 | Console.WriteLine(ex.ToString()); 26 | // a non-zero exit code must be returned if there's a failure 27 | // so that any hosting process can tell that the application has failed 28 | Environment.Exit(1); 29 | } 30 | } 31 | 32 | public static IHostBuilder RegisterDefaults(this IHostBuilder hostBuilder) 33 | { 34 | return hostBuilder.UseServiceProviderFactory(new AutofacServiceProviderFactory()) 35 | .UseSerilog((hostContext, serviceProvider, loggingBuilder) => 36 | { 37 | loggingBuilder 38 | .Enrich.FromLogContext() 39 | .MinimumLevel.Information() 40 | .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) 41 | .ReadFrom.Configuration(hostContext.Configuration) 42 | .WriteTo.Console(); 43 | }); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Hosting/Job.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace CleanArchitecture.Hosting 5 | { 6 | public abstract class Job : BackgroundService 7 | { 8 | protected readonly ILogger Logger; 9 | private readonly IHostApplicationLifetime _hostApplicationLifetime; 10 | 11 | protected Job(ILogger logger, IHostApplicationLifetime hostApplicationLifetime) 12 | { 13 | Logger = logger; 14 | _hostApplicationLifetime = hostApplicationLifetime; 15 | } 16 | 17 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 18 | { 19 | try 20 | { 21 | Logger.LogInformation("Starting Job: {type}", this.GetType().Name); 22 | 23 | await RunAsync(stoppingToken); 24 | 25 | Logger.LogInformation("Completed Job: {type}", this.GetType().Name); 26 | } 27 | catch (Exception ex) 28 | { 29 | Logger.LogError(ex, "Error running job - {ex}", ex.ToString()); 30 | Environment.ExitCode = 1; 31 | throw; 32 | } 33 | _hostApplicationLifetime.StopApplication(); 34 | } 35 | 36 | protected abstract Task RunAsync(CancellationToken cancellationToken); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Hosting/Worker.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | 3 | namespace CleanArchitecture.Hosting 4 | { 5 | public static class Worker 6 | { 7 | public static IHostBuilder CreateBuilder(string[] args) 8 | { 9 | return Application.CreateBuilder(args) 10 | .UseEnvironment(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production") 11 | .UseConsoleLifetime(); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/AutofacModules/InfrastructureModule.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using CleanArchitecture.Infrastructure.Repositories; 4 | using CleanArchitecture.Infrastructure.Services; 5 | using CleanArchitecture.Infrastructure.Settings; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.Configuration; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace CleanArchitecture.Infrastructure.AutofacModules 11 | { 12 | public sealed class InfrastructureModule : Module 13 | { 14 | private readonly DbContextOptions _options; 15 | private readonly IConfiguration Configuration; 16 | 17 | public InfrastructureModule(IConfiguration configuration) : this(CreateDbOptions(configuration), configuration) 18 | { 19 | 20 | } 21 | 22 | public InfrastructureModule(DbContextOptions options, IConfiguration configuration) 23 | { 24 | Configuration = configuration; 25 | _options = options; 26 | } 27 | 28 | protected override void Load(ContainerBuilder builder) 29 | { 30 | builder.RegisterInstance(Options.Create(DatabaseSettings.Create(Configuration))); 31 | builder.RegisterType() 32 | .AsSelf() 33 | .InstancePerRequest() 34 | .InstancePerLifetimeScope() 35 | .WithParameter(new NamedParameter("options", _options)); 36 | 37 | builder.RegisterType() 38 | .AsImplementedInterfaces() 39 | .InstancePerRequest() 40 | .InstancePerLifetimeScope(); 41 | 42 | builder.RegisterGeneric(typeof(Repository<>)) 43 | .As(typeof(IRepository<>)); 44 | 45 | builder.RegisterType() 46 | .AsImplementedInterfaces() 47 | .SingleInstance(); 48 | } 49 | 50 | private static DbContextOptions CreateDbOptions(IConfiguration configuration) 51 | { 52 | var databaseSettings = DatabaseSettings.Create(configuration); 53 | var builder = new DbContextOptionsBuilder(); 54 | #if (UseSqlServer) 55 | builder.UseSqlServer(databaseSettings.SqlConnectionString); 56 | #else 57 | builder.UseNpgsql(databaseSettings.PostgresConnectionString); 58 | #endif 59 | return builder.Options; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | $(DefineConstants);UseSqlServer 7 | 8 | true 9 | 10 | 11 | 12 | 13 | net8.0 14 | enable 15 | enable 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Configurations/LocationConfiguration.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Locations.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 4 | 5 | namespace CleanArchitecture.Infrastructure.Configurations 6 | { 7 | internal class LocationConfiguration : IEntityTypeConfiguration 8 | { 9 | public void Configure(EntityTypeBuilder builder) 10 | { 11 | builder.Property(e => e.Country) 12 | .HasColumnType("varchar(64)") 13 | .IsRequired(); 14 | 15 | builder.Property(e => e.City) 16 | .HasColumnType("varchar(64)") 17 | .IsRequired(); 18 | 19 | 20 | builder.OwnsOne(e => e.Coordinates, coordinatesBuilder => 21 | { 22 | coordinatesBuilder.Property(e => e.Latitude) 23 | .HasColumnName("Latitude") 24 | .IsRequired(); 25 | 26 | coordinatesBuilder.Property(e => e.Longitude) 27 | .HasColumnName("Longitude") 28 | .IsRequired(); 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Configurations/WeatherForecastConfiguration.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Locations.Entities; 2 | using CleanArchitecture.Core.Weather.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 5 | 6 | namespace CleanArchitecture.Infrastructure.Configurations 7 | { 8 | internal class WeatherForecastConfiguration : IEntityTypeConfiguration 9 | { 10 | public void Configure(EntityTypeBuilder builder) 11 | { 12 | builder.Property(e => e.Summary) 13 | .HasColumnType("varchar(64)") 14 | .IsRequired(); 15 | 16 | builder.OwnsOne(e => e.Temperature, tempBuilder => 17 | { 18 | tempBuilder.Property(e => e.Celcius) 19 | .HasColumnName("Temperature") 20 | .IsRequired(); 21 | }); 22 | 23 | builder.HasOne() 24 | .WithMany() 25 | .HasForeignKey(e => e.LocationId) 26 | .IsRequired(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Extensions/MediatorExtensions.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.DomainEvents; 2 | using CleanArchitecture.Core.Abstractions.Entities; 3 | using CleanArchitecture.Infrastructure; 4 | 5 | namespace MediatR 6 | { 7 | internal static class MediatorExtensions 8 | { 9 | public static async Task DispatchEventsAsync(this IMediator mediator, WeatherContext context) 10 | { 11 | var aggregateRoots = context.ChangeTracker 12 | .Entries() 13 | .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any()) 14 | .Select(e => e.Entity) 15 | .ToList(); 16 | 17 | var domainEvents = aggregateRoots 18 | .SelectMany(x => x.DomainEvents) 19 | .ToList(); 20 | 21 | await mediator.DispatchDomainEventsAsync(domainEvents); 22 | 23 | ClearDomainEvents(aggregateRoots); 24 | } 25 | 26 | private static async Task DispatchDomainEventsAsync(this IMediator mediator, List domainEvents) 27 | { 28 | foreach (var domainEvent in domainEvents) 29 | { 30 | await mediator.Publish(domainEvent); 31 | } 32 | } 33 | 34 | private static void ClearDomainEvents(List aggregateRoots) 35 | { 36 | aggregateRoots.ForEach(aggregate => aggregate.ClearDomainEvents()); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Repositories/Repository.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Entities; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace CleanArchitecture.Infrastructure.Repositories 6 | { 7 | internal class Repository : IRepository where T : AggregateRoot 8 | { 9 | private readonly WeatherContext _context; 10 | private readonly DbSet _entitySet; 11 | 12 | public Repository(WeatherContext context) 13 | { 14 | _context = context; 15 | _entitySet = _context.Set(); 16 | } 17 | 18 | public IQueryable GetAll(bool noTracking = true) 19 | { 20 | var set = _entitySet; 21 | if (noTracking) 22 | { 23 | return set.AsNoTracking(); 24 | } 25 | return set; 26 | } 27 | 28 | public async Task GetByIdAsync(Guid id) 29 | { 30 | return await _entitySet.FindAsync(id); 31 | } 32 | 33 | public void Insert(T entity) 34 | { 35 | _entitySet.Add(entity); 36 | } 37 | 38 | public void Insert(List entities) 39 | { 40 | _entitySet.AddRange(entities); 41 | } 42 | 43 | public void Delete(T entity) 44 | { 45 | _entitySet.Remove(entity); 46 | } 47 | 48 | public void Remove(IEnumerable entitiesToRemove) 49 | { 50 | _entitySet.RemoveRange(entitiesToRemove); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Repositories/UnitOfWork.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Repositories; 2 | using CleanArchitecture.Core.Abstractions.DomainEvents; 3 | using CleanArchitecture.Core.Abstractions.Entities; 4 | using MediatR; 5 | 6 | namespace CleanArchitecture.Infrastructure.Repositories 7 | { 8 | internal sealed class UnitOfWork : IUnitOfWork 9 | { 10 | private readonly WeatherContext _context; 11 | private readonly IMediator _mediator; 12 | 13 | public UnitOfWork(WeatherContext context, 14 | IMediator mediator) 15 | { 16 | _context = context; 17 | _mediator = mediator; 18 | } 19 | 20 | public async Task CommitAsync(CancellationToken cancellationToken = default) 21 | { 22 | // Dispatch Domain Events collection. 23 | // Right BEFORE committing data (EF SaveChanges) into the DB will make a single transaction including 24 | // side effects from the domain event handlers which are using the same DbContext with "InstancePerLifetimeScope" or "scoped" lifetime 25 | // Integration Events will be stored in the IntegrationEventOutbox ready to be published later 26 | await DispatchEventsAsync(); 27 | 28 | // After executing this line all the changes (from any Command Handler and Domain Event Handlers) 29 | // performed through the DbContext will be committed 30 | await _context.SaveChangesAsync(cancellationToken); 31 | 32 | return true; 33 | } 34 | 35 | private async Task DispatchEventsAsync() 36 | { 37 | var processedDomainEvents = new List(); 38 | var unprocessedDomainEvents = GetDomainEvents(); 39 | // this is needed incase another DomainEvent is published from a DomainEventHandler 40 | while (unprocessedDomainEvents.Any()) 41 | { 42 | await DispatchDomainEventsAsync(unprocessedDomainEvents); 43 | processedDomainEvents.AddRange(unprocessedDomainEvents); 44 | unprocessedDomainEvents = GetDomainEvents() 45 | .Where(e => !processedDomainEvents.Contains(e)) 46 | .ToList(); 47 | } 48 | 49 | ClearDomainEvents(); 50 | } 51 | 52 | private List GetDomainEvents() 53 | { 54 | var aggregateRoots = GetTrackedAggregateRoots(); 55 | return aggregateRoots 56 | .SelectMany(x => x.DomainEvents) 57 | .ToList(); 58 | } 59 | 60 | private List GetTrackedAggregateRoots() 61 | { 62 | return _context.ChangeTracker 63 | .Entries() 64 | .Where(x => x.Entity.DomainEvents != null && x.Entity.DomainEvents.Any()) 65 | .Select(e => e.Entity) 66 | .ToList(); 67 | } 68 | 69 | private async Task DispatchDomainEventsAsync(List domainEvents) 70 | { 71 | foreach (var domainEvent in domainEvents) 72 | { 73 | await _mediator.Publish(domainEvent); 74 | } 75 | } 76 | 77 | private void ClearDomainEvents() 78 | { 79 | var aggregateRoots = GetTrackedAggregateRoots(); 80 | aggregateRoots.ForEach(aggregate => aggregate.ClearDomainEvents()); 81 | } 82 | 83 | public void Dispose() 84 | { 85 | _context.Dispose(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Services/NotificationsService.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Services; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace CleanArchitecture.Infrastructure.Services 5 | { 6 | internal sealed class NotificationsService : INotificationsService 7 | { 8 | private readonly ILogger _logger; 9 | 10 | public NotificationsService(ILogger logger) 11 | { 12 | _logger = logger; 13 | } 14 | 15 | public Task WeatherAlertAsync(string summary, int temperatureC, DateTime date) 16 | { 17 | // This class is included for demonstration only 18 | // In a real app it would integrate with an SMTP server or messaging service 19 | _logger.LogInformation("Send Weather Alert Notification"); 20 | return Task.CompletedTask; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/Settings/DatabaseSettings.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | namespace CleanArchitecture.Infrastructure.Settings 4 | { 5 | public sealed class DatabaseSettings 6 | { 7 | public static DatabaseSettings Create(IConfiguration configuration) 8 | { 9 | var databaseSettings = new DatabaseSettings(); 10 | configuration.GetSection("Database").Bind(databaseSettings); 11 | return databaseSettings; 12 | } 13 | 14 | #if (UseSqlServer) 15 | public string? SqlConnectionString { get; set; } 16 | #else 17 | public string? PostgresConnectionString { get; set; } 18 | #endif 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Infrastructure/WeatherContext.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Weather.Entities; 2 | using CleanArchitecture.Core.Locations.Entities; 3 | using CleanArchitecture.Core.Abstractions.Entities; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Logging; 6 | using CleanArchitecture.Infrastructure.Configurations; 7 | using Microsoft.Extensions.Hosting; 8 | 9 | namespace CleanArchitecture.Infrastructure 10 | { 11 | public sealed class WeatherContext : DbContext 12 | { 13 | private static readonly ILoggerFactory DebugLoggerFactory = new LoggerFactory(new[] { new Microsoft.Extensions.Logging.Debug.DebugLoggerProvider() }); 14 | private readonly IHostEnvironment? _env; 15 | 16 | public WeatherContext(DbContextOptions options, 17 | IHostEnvironment? env) : base(options) 18 | { 19 | _env = env; 20 | } 21 | 22 | public DbSet WeatherForecasts { get; set; } 23 | 24 | public DbSet Locations { get; set; } 25 | 26 | 27 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 28 | { 29 | if (_env != null && _env.IsDevelopment()) 30 | { 31 | // used to print activity when debugging 32 | optionsBuilder.UseLoggerFactory(DebugLoggerFactory); 33 | } 34 | } 35 | 36 | protected override void OnModelCreating(ModelBuilder modelBuilder) 37 | { 38 | base.OnModelCreating(modelBuilder); 39 | modelBuilder.ApplyConfigurationsFromAssembly(typeof(WeatherForecastConfiguration).Assembly); 40 | var aggregateTypes = modelBuilder.Model 41 | .GetEntityTypes() 42 | .Select(e => e.ClrType) 43 | .Where(e => !e.IsAbstract && e.IsAssignableTo(typeof(AggregateRoot))); 44 | 45 | foreach (var type in aggregateTypes) 46 | { 47 | var aggregateBuild = modelBuilder.Entity(type); 48 | aggregateBuild.Ignore(nameof(AggregateRoot.DomainEvents)); 49 | aggregateBuild.Property(nameof(AggregateRoot.Id)).ValueGeneratedNever(); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Integration/CleanArchitecture.Integration.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | PreserveNewest 13 | true 14 | PreserveNewest 15 | 16 | 17 | PreserveNewest 18 | true 19 | PreserveNewest 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Integration/Program.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using CleanArchitecture.Application.AutofacModules; 3 | using CleanArchitecture.Application.Weather.IntegrationEvents; 4 | using CleanArchitecture.Hosting; 5 | using CleanArchitecture.Infrastructure.AutofacModules; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | 10 | var hostBuilder = Worker.CreateBuilder(args) 11 | .ConfigureServices((hostContext, services) => 12 | { 13 | services.AddMiniTransit((_, configure) => 14 | { 15 | configure.UseRetry(retry => 16 | { 17 | retry.Exponential(3, TimeSpan.FromSeconds(1)); 18 | }); 19 | configure.UseRabbitMQ(options => 20 | { 21 | hostContext.Configuration.GetSection("EventBus").Bind(options); 22 | }); 23 | configure.AddConsumer(); 24 | }); 25 | }) 26 | .ConfigureContainer((hostContext, container) => 27 | { 28 | container.RegisterModule(new InfrastructureModule(hostContext.Configuration)); 29 | container.RegisterModule(new ApplicationModule()); 30 | }); 31 | 32 | await hostBuilder.BuildAndRunAsync(); -------------------------------------------------------------------------------- /src/CleanArchitecture.Integration/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information", 5 | "Override": { 6 | "Microsoft": "Warning", 7 | "System": "Warning" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Integration/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information", 5 | "Override": { 6 | "Microsoft": "Warning", 7 | "System": "Warning", 8 | "Microsoft.AspNetCore": "Warning" 9 | } 10 | } 11 | }, 12 | "Database": { 13 | //#if( UseSqlServer ) 14 | "SqlConnectionString": "Server=127.0.0.1, 1433; Database=Weather; Integrated Security=False; User Id = SA; Password=Admin1234!; MultipleActiveResultSets=False;TrustServerCertificate=True", 15 | //#else 16 | "PostgresConnectionString": "Host=127.0.0.1;Database=Weather;Username=postgres;Password=Admin1234!" 17 | //#endif 18 | }, 19 | "EventBus": { 20 | "UserName": "guest", 21 | "Password": "guest", 22 | "HostName": "localhost", 23 | "Port": 5672 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/CleanArchitecture.Migrations.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | $(DefineConstants);UseSqlServer 7 | 8 | true 9 | 10 | 11 | 12 | 13 | Exe 14 | net8.0 15 | enable 16 | enable 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | PreserveNewest 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extra AS base 2 | 3 | WORKDIR /app 4 | 5 | ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false 6 | 7 | FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build 8 | 9 | COPY ["src/", "/src/"] 10 | 11 | WORKDIR /src/CleanArchitecture.Migrations 12 | RUN dotnet restore "CleanArchitecture.Migrations.csproj" && \ 13 | dotnet publish "CleanArchitecture.Migrations.csproj" --no-restore -c Release -o /app/publish 14 | 15 | FROM base AS final 16 | COPY --from=build /app/publish . 17 | USER 1000 18 | ENTRYPOINT ["dotnet", "CleanArchitecture.Migrations.dll"] -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Factories/DbContextOptionsFactory.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Infrastructure; 2 | using CleanArchitecture.Infrastructure.Settings; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Configuration; 5 | 6 | namespace CleanArchitecture.Migrations.Factories 7 | { 8 | public static class DbContextOptionsFactory 9 | { 10 | public static DbContextOptions Create(IConfiguration configuration) 11 | { 12 | var appSettings = DatabaseSettings.Create(configuration); 13 | 14 | return new DbContextOptionsBuilder() 15 | #if (UseSqlServer) 16 | .UseSqlServer(appSettings.SqlConnectionString, b => b.MigrationsAssembly("CleanArchitecture.Migrations")) 17 | #else 18 | .UseNpgsql(appSettings.PostgresConnectionString, b => b.MigrationsAssembly("CleanArchitecture.Migrations")) 19 | #endif 20 | .Options; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Factories/WeatherContextFactory.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Infrastructure; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace CleanArchitecture.Migrations.Factories 6 | { 7 | public class WeatherContextFactory : IDesignTimeDbContextFactory 8 | { 9 | private readonly IConfiguration _configuration; 10 | 11 | public WeatherContextFactory() 12 | { 13 | var builder = new ConfigurationBuilder(); 14 | 15 | builder.AddJsonFile("appsettings.json") 16 | .AddEnvironmentVariables(); 17 | _configuration = builder.Build(); 18 | } 19 | 20 | public WeatherContextFactory(IConfiguration configuration) 21 | { 22 | _configuration = configuration; 23 | } 24 | 25 | public WeatherContext CreateDbContext(string[] args) 26 | { 27 | return new WeatherContext(DbContextOptionsFactory.Create(_configuration), null); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/MigrationJob.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Locations.ValueObjects; 2 | using CleanArchitecture.Hosting; 3 | using CleanArchitecture.Infrastructure; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Hosting; 6 | using Microsoft.Extensions.Logging; 7 | using CleanArchitecture.Core.Locations.Entities; 8 | 9 | namespace CleanArchitecture.Migrations 10 | { 11 | public sealed class MigrationJob : Job 12 | { 13 | private readonly WeatherContext _context; 14 | 15 | public MigrationJob(ILogger logger, 16 | WeatherContext context, 17 | IHostApplicationLifetime hostApplicationLifetime) : base(logger, hostApplicationLifetime) 18 | { 19 | _context = context; 20 | } 21 | 22 | protected override async Task RunAsync(CancellationToken cancellationToken) 23 | { 24 | await MigrateDatabaseAsync(); 25 | } 26 | 27 | private async Task MigrateDatabaseAsync() 28 | { 29 | Logger.LogInformation("Starting database migration"); 30 | await _context.Database.MigrateAsync(); 31 | Logger.LogInformation("Finished database migration"); 32 | await MigrateLocationsAsync(); 33 | } 34 | 35 | private async Task MigrateLocationsAsync() 36 | { 37 | var locations = new List() 38 | { 39 | CreateLocation("United Kingdom", "London", 51.51m, -0.13m), 40 | CreateLocation("India", "Mumbai", 17.38m, -78.46m), 41 | CreateLocation("USA", "New York", 40.71m, -74.01m), 42 | CreateLocation("Japan", "Tokyo", 35.69m, 139.69m), 43 | CreateLocation("Australia", "Sydney", -33.87m, 151.21m) 44 | }; 45 | var existingLocations = _context.Locations.ToList(); 46 | foreach (var location in locations) 47 | { 48 | if (!existingLocations.Any(e => e.City == location.City)) 49 | { 50 | Logger.LogInformation("Adding location: {city}", location.City); 51 | _context.Locations.Add(location); 52 | await _context.SaveChangesAsync(); 53 | } 54 | } 55 | } 56 | 57 | private Location CreateLocation(string country, string city, decimal latitude, decimal longitude) 58 | { 59 | return Location.Create(country, city, Coordinates.Create(latitude, longitude)); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Migrations/20230903093623_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace CleanArchitecture.Migrations.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Locations", 16 | columns: table => new 17 | { 18 | #if (UseSqlServer) 19 | Id = table.Column(type: "uniqueidentifier", nullable: false), 20 | #else 21 | Id = table.Column(type: "uuid", nullable: false), 22 | #endif 23 | Country = table.Column(type: "varchar(64)", nullable: false), 24 | City = table.Column(type: "varchar(64)", nullable: false), 25 | #if (UseSqlServer) 26 | Latitude = table.Column(type: "decimal(18,2)", nullable: false), 27 | Longitude = table.Column(type: "decimal(18,2)", nullable: false) 28 | #else 29 | Latitude = table.Column(type: "numeric", nullable: false), 30 | Longitude = table.Column(type: "numeric", nullable: false) 31 | #endif 32 | }, 33 | constraints: table => 34 | { 35 | table.PrimaryKey("PK_Locations", x => x.Id); 36 | }); 37 | 38 | migrationBuilder.CreateTable( 39 | name: "WeatherForecasts", 40 | columns: table => new 41 | { 42 | #if (UseSqlServer) 43 | Id = table.Column(type: "uniqueidentifier", nullable: false), 44 | Date = table.Column(type: "datetime2", nullable: false), 45 | Temperature = table.Column(type: "int", nullable: false), 46 | #else 47 | Id = table.Column(type: "uuid", nullable: false), 48 | Date = table.Column(type: "timestamp with time zone", nullable: false), 49 | Temperature = table.Column(type: "integer", nullable: false), 50 | #endif 51 | Summary = table.Column(type: "varchar(64)", nullable: false), 52 | #if (UseSqlServer) 53 | LocationId = table.Column(type: "uniqueidentifier", nullable: false) 54 | #else 55 | LocationId = table.Column(type: "uuid", nullable: false) 56 | #endif 57 | }, 58 | constraints: table => 59 | { 60 | table.PrimaryKey("PK_WeatherForecasts", x => x.Id); 61 | table.ForeignKey( 62 | name: "FK_WeatherForecasts_Locations_LocationId", 63 | column: x => x.LocationId, 64 | principalTable: "Locations", 65 | principalColumn: "Id", 66 | onDelete: ReferentialAction.Cascade); 67 | }); 68 | 69 | migrationBuilder.CreateIndex( 70 | name: "IX_WeatherForecasts_LocationId", 71 | table: "WeatherForecasts", 72 | column: "LocationId"); 73 | } 74 | 75 | /// 76 | protected override void Down(MigrationBuilder migrationBuilder) 77 | { 78 | migrationBuilder.DropTable( 79 | name: "WeatherForecasts"); 80 | 81 | migrationBuilder.DropTable( 82 | name: "Locations"); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Program.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using CleanArchitecture.Application.AutofacModules; 3 | using CleanArchitecture.Hosting; 4 | using CleanArchitecture.Infrastructure.AutofacModules; 5 | using CleanArchitecture.Migrations; 6 | using CleanArchitecture.Migrations.Factories; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | 10 | public class Program 11 | { 12 | public static async Task Main(string[] args) 13 | { 14 | var hostBuilder = Worker.CreateBuilder(args) 15 | .ConfigureServices((hostContext, services) => 16 | { 17 | services.AddHostedService(); 18 | }) 19 | .ConfigureContainer((hostContext, container) => 20 | { 21 | container.RegisterModule(new InfrastructureModule(DbContextOptionsFactory.Create(hostContext.Configuration), hostContext.Configuration)); 22 | container.RegisterModule(new ApplicationModule()); 23 | }); 24 | 25 | await hostBuilder.BuildAndRunAsync(); 26 | } 27 | 28 | // EF Core uses this method at design time to access the DbContext 29 | public static IHostBuilder CreateHostBuilder(string[] args) 30 | => Host.CreateDefaultBuilder(args); 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "CleanArchitecture.Migrations": { 5 | "commandName": "Project", 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Migrations/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Default": "Information" 5 | } 6 | }, 7 | "Database": { 8 | //#if( UseSqlServer ) 9 | "SqlConnectionString": "Server=127.0.0.1, 1433; Database=Weather; Integrated Security=False; User Id = SA; Password=Admin1234!; MultipleActiveResultSets=False;TrustServerCertificate=True", 10 | //#else 11 | "PostgresConnectionString": "Host=127.0.0.1;Database=Weather;Username=postgres;Password=Admin1234!" 12 | //#endif 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/CleanArchitecture.Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | false 7 | ClientApp\ 8 | https://localhost:44411 9 | npm start 10 | enable 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | wwwroot\%(RecursiveDir)%(FileName)%(Extension) 44 | PreserveNewest 45 | true 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | 18 | [*.{razor,cshtml}] 19 | charset = utf-8-bom 20 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /dist-server 6 | /tmp 7 | /out-tsc 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/README.md: -------------------------------------------------------------------------------- 1 | # CleanArchitecture.Web 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.0.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "CleanArchitecture.Web": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:application": { 10 | "strict": true 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "progress": false, 21 | "outputPath": { 22 | "base": "dist" 23 | }, 24 | "index": "src/index.html", 25 | "polyfills": [ 26 | "src/polyfills.ts" 27 | ], 28 | "tsConfig": "tsconfig.app.json", 29 | "allowedCommonJsDependencies": [ 30 | "oidc-client" 31 | ], 32 | "assets": [ 33 | "src/assets" 34 | ], 35 | "styles": [ 36 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 37 | "src/styles.css" 38 | ], 39 | "scripts": [], 40 | "browser": "src/main.ts" 41 | }, 42 | "configurations": { 43 | "production": { 44 | "budgets": [ 45 | { 46 | "type": "initial", 47 | "maximumWarning": "500kb", 48 | "maximumError": "1mb" 49 | }, 50 | { 51 | "type": "anyComponentStyle", 52 | "maximumWarning": "2kb", 53 | "maximumError": "4kb" 54 | } 55 | ], 56 | "fileReplacements": [ 57 | { 58 | "replace": "src/environments/environment.ts", 59 | "with": "src/environments/environment.prod.ts" 60 | } 61 | ], 62 | "outputHashing": "all" 63 | }, 64 | "development": { 65 | "optimization": false, 66 | "extractLicenses": false, 67 | "sourceMap": true, 68 | "namedChunks": true 69 | } 70 | }, 71 | "defaultConfiguration": "production" 72 | }, 73 | "serve": { 74 | "builder": "@angular-devkit/build-angular:dev-server", 75 | "configurations": { 76 | "production": { 77 | "buildTarget": "CleanArchitecture.Web:build:production" 78 | }, 79 | "development": { 80 | "proxyConfig": "proxy.conf.js", 81 | "buildTarget": "CleanArchitecture.Web:build:development" 82 | } 83 | }, 84 | "defaultConfiguration": "development" 85 | }, 86 | "extract-i18n": { 87 | "builder": "@angular-devkit/build-angular:extract-i18n", 88 | "options": { 89 | "buildTarget": "CleanArchitecture.Web:build" 90 | } 91 | }, 92 | "test": { 93 | "builder": "@angular-devkit/build-angular:karma", 94 | "options": { 95 | "main": "src/test.ts", 96 | "polyfills": "src/polyfills.ts", 97 | "tsConfig": "tsconfig.spec.json", 98 | "karmaConfig": "karma.conf.js", 99 | "assets": [ 100 | "src/assets" 101 | ], 102 | "styles": [ 103 | "src/styles.css" 104 | ], 105 | "scripts": [] 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/aspnetcore-https.js: -------------------------------------------------------------------------------- 1 | // This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate 2 | const fs = require('fs'); 3 | const spawn = require('child_process').spawn; 4 | const path = require('path'); 5 | 6 | const baseFolder = 7 | process.env.APPDATA !== undefined && process.env.APPDATA !== '' 8 | ? `${process.env.APPDATA}/ASP.NET/https` 9 | : `${process.env.HOME}/.aspnet/https`; 10 | 11 | const certificateArg = process.argv.map(arg => arg.match(/--name=(?.+)/i)).filter(Boolean)[0]; 12 | const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name; 13 | 14 | if (!certificateName) { 15 | console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<> explicitly.') 16 | process.exit(-1); 17 | } 18 | 19 | const certFilePath = path.join(baseFolder, `${certificateName}.pem`); 20 | const keyFilePath = path.join(baseFolder, `${certificateName}.key`); 21 | 22 | if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) { 23 | spawn('dotnet', [ 24 | 'dev-certs', 25 | 'https', 26 | '--export-path', 27 | certFilePath, 28 | '--format', 29 | 'Pem', 30 | '--no-password', 31 | ], { stdio: 'inherit', }) 32 | .on('exit', (code) => process.exit(code)); 33 | } 34 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/angularapp'), 29 | subdir: '.', 30 | reporters: [ 31 | { type: 'html' }, 32 | { type: 'text-summary' } 33 | ] 34 | }, 35 | reporters: ['progress', 'kjhtml'], 36 | port: 9876, 37 | colors: true, 38 | logLevel: config.LOG_INFO, 39 | autoWatch: true, 40 | browsers: ['Chrome'], 41 | singleRun: false, 42 | restartOnFileChange: true 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cleanarchitecture.web", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "prestart": "node aspnetcore-https", 7 | "start": "run-script-os", 8 | "start:windows": "ng serve --port 44411 --ssl --ssl-cert \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.pem\" --ssl-key \"%APPDATA%\\ASP.NET\\https\\%npm_package_name%.key\"", 9 | "start:default": "ng serve --port 44411 --ssl --ssl-cert \"$HOME/.aspnet/https/${npm_package_name}.pem\" --ssl-key \"$HOME/.aspnet/https/${npm_package_name}.key\"", 10 | "build": "ng build", 11 | "watch": "ng build --watch --configuration development", 12 | "test": "ng test" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "^19.2.9", 17 | "@angular/common": "^19.2.9", 18 | "@angular/compiler": "^19.2.9", 19 | "@angular/core": "^19.2.9", 20 | "@angular/forms": "^19.2.9", 21 | "@angular/platform-browser": "^19.2.9", 22 | "@angular/platform-browser-dynamic": "^19.2.9", 23 | "@angular/platform-server": "^19.2.9", 24 | "@angular/router": "^19.2.9", 25 | "bootstrap": "^5.2.3", 26 | "jquery": "^3.6.3", 27 | "oidc-client": "^1.11.5", 28 | "popper.js": "^1.16.0", 29 | "run-script-os": "^1.1.6", 30 | "rxjs": "~7.8.0", 31 | "tslib": "^2.5.0", 32 | "zone.js": "~0.15.0" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "^19.2.10", 36 | "@angular/cli": "^19.2.10", 37 | "@angular/compiler-cli": "^19.2.9", 38 | "@types/jasmine": "~4.3.1", 39 | "@types/jasminewd2": "~2.0.10", 40 | "@types/node": "^18.14.0", 41 | "jasmine-core": "~4.5.0", 42 | "karma": "~6.4.1", 43 | "karma-chrome-launcher": "~3.1.1", 44 | "karma-coverage": "~2.2.0", 45 | "karma-jasmine": "~5.1.0", 46 | "karma-jasmine-html-reporter": "^2.0.0", 47 | "typescript": "~5.8.3" 48 | }, 49 | "overrides": { 50 | "autoprefixer": "10.4.5" 51 | }, 52 | "optionalDependencies": {} 53 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/proxy.conf.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | 3 | const PROXY_CONFIG = [ 4 | { 5 | context: [ 6 | "/api", 7 | "/swagger", 8 | ], 9 | target: 'https://localhost:7283', 10 | secure: false, 11 | headers: { 12 | Connection: 'Keep-Alive' 13 | } 14 | }, 15 | { 16 | context: [ 17 | "/healthz", 18 | "/liveness" 19 | ], 20 | target: 'https://localhost:7251', 21 | secure: false, 22 | headers: { 23 | Connection: 'Keep-Alive' 24 | } 25 | } 26 | ] 27 | 28 | module.exports = PROXY_CONFIG; 29 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/_shared/models/location.model.ts: -------------------------------------------------------------------------------- 1 | export interface WeatherLocation { 2 | id: string; 3 | country: string; 4 | city: string; 5 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/_shared/models/results.model.ts: -------------------------------------------------------------------------------- 1 | export interface CreatedResult { 2 | id: string; 3 | } 4 | 5 | export interface Envelope{ 6 | errorMessage: string; 7 | status: number; 8 | timestamp: Date; 9 | traceId: string; 10 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/_shared/models/weather.model.ts: -------------------------------------------------------------------------------- 1 | export interface WeatherForecast { 2 | id: string; 3 | date: Date; 4 | temperatureC: number; 5 | temperatureF: number; 6 | summary: string; 7 | locationId: string; 8 | } 9 | 10 | export interface CreateWeatherForecast { 11 | date: Date; 12 | temperatureC: number; 13 | summary: string; 14 | locationId: string; 15 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/_shared/services/locations.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { WeatherLocation } from '../models/location.model'; 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class LocationsService { 10 | 11 | public constructor(private readonly _http: HttpClient) { 12 | 13 | } 14 | 15 | public get(): Observable { 16 | return this._http.get('api/locations'); 17 | } 18 | } -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/_shared/services/weather.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | import { CreateWeatherForecast, WeatherForecast } from '../models/weather.model'; 5 | import { CreatedResult } from '../models/results.model'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class WeatherService { 11 | 12 | public constructor(private readonly _http: HttpClient) { 13 | 14 | } 15 | 16 | public get(locationId: string): Observable { 17 | return this._http.get(`api/weather-forecasts?locationId=${locationId}`); 18 | } 19 | 20 | public create(forecast: CreateWeatherForecast) : Observable{ 21 | return this._http.post('api/weather-forecasts', forecast); 22 | } 23 | 24 | public delete(id: string): Observable { 25 | return this._http.delete(`api/weather-forecasts/${id}`); 26 | } 27 | 28 | public getTemperatureSummary(temperature: number): string { 29 | if (temperature > 40) { 30 | return "Scorching"; 31 | } 32 | else if (temperature > 20) { 33 | return "Hot"; 34 | } 35 | else if (temperature > 10) { 36 | return "Mild"; 37 | } 38 | else if (temperature > 0) { 39 | return "Cold"; 40 | } 41 | else if (temperature === null) { 42 | return ""; 43 | } 44 | else { 45 | return "Freezing"; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 | 7 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | standalone: false 7 | }) 8 | export class AppComponent { 9 | title = 'app'; 10 | } 11 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 5 | import { RouterModule } from '@angular/router'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { NavMenuComponent } from './nav-menu/nav-menu.component'; 9 | import { HomeComponent } from './home/home.component'; 10 | import { WeatherForecastsComponent } from './weather-forecasts/weather-forecasts.component'; 11 | 12 | @NgModule({ 13 | declarations: [ 14 | AppComponent, 15 | NavMenuComponent, 16 | HomeComponent, 17 | WeatherForecastsComponent 18 | ], 19 | bootstrap: [ 20 | AppComponent 21 | ], 22 | imports: [ 23 | BrowserModule, 24 | FormsModule, 25 | RouterModule.forRoot([ 26 | { path: '', component: HomeComponent, pathMatch: 'full' }, 27 | { path: 'weather-forecast', component: WeatherForecastsComponent }, 28 | ]) 29 | ], 30 | providers: [ 31 | provideHttpClient(withInterceptorsFromDi()) 32 | ] 33 | }) 34 | export class AppModule { } 35 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/app.server.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { ServerModule } from '@angular/platform-server'; 3 | import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; 4 | import { AppComponent } from './app.component'; 5 | import { AppModule } from './app.module'; 6 | 7 | @NgModule({ 8 | imports: [AppModule, ServerModule, ModuleMapLoaderModule], 9 | bootstrap: [AppComponent] 10 | }) 11 | export class AppServerModule { } 12 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |

Hello, world!

2 |

Welcome to your new single-page application, built with:

3 | 8 |

To help you get started, we've also set up:

9 |
    10 |
  • Client-side navigation. For example, click Counter then Back to return here.
  • 11 |
  • Angular CLI integration. In development mode, there's no need to run ng serve. It runs in the background automatically, so your client-side resources are dynamically built on demand and the page refreshes when you modify any file.
  • 12 |
  • Efficient production builds. In production mode, development-time features are disabled, and your dotnet publish configuration automatically invokes ng build to produce minified, ahead-of-time compiled JavaScript files.
  • 13 |
14 |

The ClientApp subdirectory is a standard Angular CLI application. If you open a command prompt in that directory, you can run any ng command (e.g., ng test), or use npm to install extra packages into it.

15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-home', 5 | templateUrl: './home.component.html', 6 | standalone: false 7 | }) 8 | export class HomeComponent { 9 | } 10 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/nav-menu/nav-menu.component.css: -------------------------------------------------------------------------------- 1 | a.navbar-brand { 2 | white-space: normal; 3 | text-align: center; 4 | word-break: break-all; 5 | } 6 | 7 | html { 8 | font-size: 14px; 9 | } 10 | @media (min-width: 768px) { 11 | html { 12 | font-size: 16px; 13 | } 14 | } 15 | 16 | .box-shadow { 17 | box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); 18 | } 19 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/nav-menu/nav-menu.component.html: -------------------------------------------------------------------------------- 1 |
2 | 21 |
-------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/nav-menu/nav-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-nav-menu', 5 | templateUrl: './nav-menu.component.html', 6 | styleUrls: ['./nav-menu.component.css'], 7 | standalone: false 8 | }) 9 | export class NavMenuComponent { 10 | isExpanded = false; 11 | 12 | collapse() { 13 | this.isExpanded = false; 14 | } 15 | 16 | toggle() { 17 | this.isExpanded = !this.isExpanded; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/weather-forecasts/weather-forecasts.component.html: -------------------------------------------------------------------------------- 1 |

Weather forecast

2 | 3 |

This component demonstrates fetching data from the server.

4 | 5 |

Please select a location to see the forecast.

6 | 7 |
8 |
9 |
10 |
11 | 15 | 16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 | 25 |
26 | 27 |
28 | 29 |
30 |

Use the Generate button to generate some weather forecasts.

31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
DateTemp. (C)Temp. (F)Summary
{{ forecast.date }}{{ forecast.temperatureC }}{{ forecast.temperatureF }}{{ forecast.summary }}
52 |
-------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/app/weather-forecasts/weather-forecasts.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { WeatherService } from '../_shared/services/weather.service'; 3 | import { CreateWeatherForecast, WeatherForecast } from '../_shared/models/weather.model'; 4 | import { LocationsService } from '../_shared/services/locations.service'; 5 | import { WeatherLocation } from '../_shared/models/location.model'; 6 | 7 | @Component({ 8 | selector: 'app-weather-forecasts', 9 | templateUrl: './weather-forecasts.component.html', 10 | standalone: false 11 | }) 12 | export class WeatherForecastsComponent implements OnInit { 13 | 14 | public locations: WeatherLocation[] = []; 15 | public forecasts: WeatherForecast[] = []; 16 | public selectedLocationId?: string; 17 | 18 | public constructor(private readonly _weatherService: WeatherService, 19 | private readonly _locationsService: LocationsService) { 20 | 21 | } 22 | 23 | public generate(): void { 24 | function getRandom(min: number, max: number) { 25 | const floatRandom = Math.random() 26 | 27 | const difference = max - min 28 | 29 | // random between 0 and the difference 30 | const random = Math.round(difference * floatRandom) 31 | 32 | const randomWithinRange = random + min 33 | 34 | return randomWithinRange 35 | } 36 | const temperature = getRandom(-50, 50); 37 | const forecast: CreateWeatherForecast = { 38 | date: new Date(), 39 | temperatureC: temperature, 40 | summary: this._weatherService.getTemperatureSummary(temperature), 41 | locationId: this.selectedLocationId! 42 | }; 43 | this._weatherService.create(forecast) 44 | .subscribe(() => { 45 | this.loadForecasts(); 46 | }); 47 | } 48 | 49 | public delete(id: string): void { 50 | this._weatherService.delete(id) 51 | .subscribe(() => { 52 | this.loadForecasts(); 53 | }); 54 | } 55 | 56 | public loadForecasts(): void { 57 | if (this.selectedLocationId) { 58 | this._weatherService.get(this.selectedLocationId) 59 | .subscribe(forecasts => { 60 | this.forecasts = forecasts; 61 | }); 62 | } 63 | } 64 | 65 | public ngOnInit(): void { 66 | this.loadLocations(); 67 | } 68 | 69 | private loadLocations(): void { 70 | this._locationsService.get() 71 | .subscribe(locations => { 72 | this.locations = locations; 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-bentley/CleanArchitecture/e92a0bb774b33c9e581ac3168507c8d4090466e5/src/CleanArchitecture.Web/ClientApp/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CleanArchitecture.Web 6 | 7 | 8 | 9 | 10 | 11 | 12 | Loading... 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | export function getBaseUrl() { 8 | return document.getElementsByTagName('base')[0].href; 9 | } 10 | 11 | const providers = [ 12 | { provide: 'BASE_URL', useFactory: getBaseUrl, deps: [] } 13 | ]; 14 | 15 | if (environment.production) { 16 | enableProdMode(); 17 | } 18 | 19 | platformBrowserDynamic(providers).bootstrapModule(AppModule) 20 | .catch(err => console.log(err)); 21 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * IE11 requires the following for NgClass support on SVG elements 23 | */ 24 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 25 | 26 | /** 27 | * Web Animations `@angular/platform-browser/animations` 28 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 29 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 30 | */ 31 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 32 | 33 | /** 34 | * By default, zone.js will patch all possible macroTask and DomEvents 35 | * user can disable parts of macroTask/DomEvents patch by setting following flags 36 | * because those flags need to be set before `zone.js` being loaded, and webpack 37 | * will put import in the top of bundle, so user need to create a separate file 38 | * in this directory (for example: zone-flags.ts), and put the following flags 39 | * into that file, and then add the following code before importing zone.js. 40 | * import './zone-flags'; 41 | * 42 | * The flags allowed in zone-flags.ts are listed here. 43 | * 44 | * The following flags will work for all browsers. 45 | * 46 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 47 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 48 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 49 | * 50 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 51 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 52 | * 53 | * (window as any).__Zone_enable_cross_context_check = true; 54 | * 55 | */ 56 | 57 | /*************************************************************************************************** 58 | * Zone JS is required by default for Angular itself. 59 | */ 60 | import 'zone.js'; // Included with Angular CLI. 61 | 62 | 63 | /*************************************************************************************************** 64 | * APPLICATION IMPORTS 65 | */ 66 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | /* Provide sufficient contrast against white background */ 4 | a { 5 | color: #0366d6; 6 | } 7 | 8 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 9 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 10 | } 11 | 12 | code { 13 | color: #e01a76; 14 | } 15 | 16 | .btn-primary { 17 | color: #fff; 18 | background-color: #1b6ec2; 19 | border-color: #1861ac; 20 | } 21 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | getTestBed().initTestEnvironment( 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting() 13 | ); 14 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "target": "es2022", 17 | "module": "es2020", 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ], 22 | "useDefineForClassFields": false 23 | }, 24 | "angularCompilerOptions": { 25 | "enableI18nLegacyMessageIdFormat": false, 26 | "strictInjectionParameters": true, 27 | "strictInputAccessModifiers": true, 28 | "strictTemplates": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/ClientApp/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "src/test.ts", 13 | "src/polyfills.ts" 14 | ], 15 | "include": [ 16 | "src/**/*.spec.ts", 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled-extra AS base 2 | 3 | WORKDIR /app 4 | EXPOSE 8080 5 | 6 | ENV ASPNETCORE_URLS=http://+:8080; 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS source 9 | 10 | # Setup Node and NPM 11 | RUN apt-get update && \ 12 | apt-get install -y curl gnupg2 && \ 13 | mkdir -p /etc/apt/keyrings && \ 14 | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ 15 | echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ 16 | apt-get update && \ 17 | apt-get install -y build-essential nodejs && \ 18 | npm install -g npm@latest 19 | 20 | COPY ["src/", "/src/"] 21 | 22 | FROM source AS publish 23 | WORKDIR "/src/CleanArchitecture.Web" 24 | RUN dotnet restore "CleanArchitecture.Web.csproj" && \ 25 | dotnet publish "CleanArchitecture.Web.csproj" --no-restore -c Release -o /app 26 | 27 | RUN chown -R 1000:1000 /app/wwwroot 28 | 29 | FROM base AS final 30 | COPY --from=publish /app . 31 | USER 1000 32 | ENTRYPOINT ["dotnet", "CleanArchitecture.Web.dll"] -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | // Add services to the container. 4 | builder.Services.AddControllers(); 5 | 6 | var app = builder.Build(); 7 | 8 | // Configure the HTTP request pipeline. 9 | if (!app.Environment.IsDevelopment()) 10 | { 11 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 12 | app.UseHsts(); 13 | } 14 | 15 | app.UseHttpsRedirection(); 16 | app.UseStaticFiles(); 17 | app.UseRouting(); 18 | 19 | 20 | app.MapControllerRoute( 21 | name: "default", 22 | pattern: "{controller}/{action=Index}/{id?}"); 23 | 24 | app.MapFallbackToFile("index.html"); 25 | 26 | app.Run(); 27 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "CleanArchitecture.Web": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:7251;http://localhost:5141", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development", 9 | "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.AspNetCore.SpaProxy": "Information", 7 | "Microsoft.Hosting.Lifetime": "Information" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /src/CleanArchitecture.Web/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-bentley/CleanArchitecture/e92a0bb774b33c9e581ac3168507c8d4090466e5/src/CleanArchitecture.Web/wwwroot/favicon.ico -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/CleanArchitecture.AcceptanceTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | disable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PreserveNewest 19 | true 20 | PreserveNewest 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | all 33 | runtime; build; native; contentfiles; analyzers; buildtransitive 34 | 35 | 36 | all 37 | runtime; build; native; contentfiles; analyzers; buildtransitive 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:v1.52.0-jammy 2 | 3 | RUN wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb && \ 4 | dpkg -i packages-microsoft-prod.deb && \ 5 | rm packages-microsoft-prod.deb && \ 6 | apt-get update && \ 7 | apt-get install -y dotnet-sdk-8.0 8 | 9 | COPY ["src/", "/src/"] 10 | COPY ["tests/", "/tests/"] 11 | 12 | WORKDIR /tests/CleanArchitecture.AcceptanceTests 13 | 14 | RUN dotnet restore "CleanArchitecture.AcceptanceTests.csproj" 15 | RUN dotnet build "CleanArchitecture.AcceptanceTests.csproj" --no-restore -c Release 16 | ENTRYPOINT ["dotnet", "test", "-c", "Release"] -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Features/weather-forecast.feature: -------------------------------------------------------------------------------- 1 | @weather_cleanup 2 | Feature: Weather Forecast 3 | 4 | Weather Forecast page shows a table of weather forecasts 5 | and allows new forecasts to be generated. 6 | 7 | Scenario: 1 Navigate to Weather Forecast page 8 | Given a user is on the Home page 9 | When Weather Forecast page is opened 10 | Then Weather Forecast page is open 11 | 12 | Scenario: 2 Generate a Weather Forecasts 13 | Given a user is on the Weather Forecast page 14 | When 'London' location is selected 15 | And a weather forecast is generated 16 | And a weather forecast is generated 17 | Then '2' weather forecasts present 18 | 19 | Scenario: 3 Generate Weather Forecasts prompt shown 20 | Given a user is on the Weather Forecast page 21 | When 'Mumbai' location is selected 22 | Then Generate prompt is visible 23 | And '0' weather forecasts present -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Hooks/GlobalHooks.cs: -------------------------------------------------------------------------------- 1 | using SpecFlow.Autofac.SpecFlowPlugin; 2 | using SpecFlow.Autofac; 3 | using Microsoft.Extensions.Configuration; 4 | using CleanArchitecture.AcceptanceTests.Settings; 5 | using Autofac; 6 | using CleanArchitecture.Infrastructure.AutofacModules; 7 | using CleanArchitecture.AcceptanceTests.Pages; 8 | 9 | namespace CleanArchitecture.AcceptanceTests.Hooks 10 | { 11 | [Binding] 12 | public sealed class GlobalHooks 13 | { 14 | private static IConfiguration Configuration; 15 | 16 | [GlobalDependencies] 17 | public static void CreateGlobalContainer(ContainerBuilder container) 18 | { 19 | Configuration = new ConfigurationBuilder() 20 | .AddJsonFile("appsettings.json", true) 21 | .Build(); 22 | 23 | var browserSettings = new BrowserSettings(); 24 | Configuration.GetSection("Browser").Bind(browserSettings); 25 | 26 | container.RegisterInstance(new TestHostEnvironment()) 27 | .AsImplementedInterfaces(); 28 | 29 | var testHarness = new TestHarness(browserSettings); 30 | 31 | container.RegisterInstance(testHarness).AsSelf(); 32 | container.RegisterInstance(browserSettings); 33 | 34 | RegisterApplicationServices(container); 35 | } 36 | 37 | [ScenarioDependencies] 38 | public static void CreateContainerBuilder(ContainerBuilder container) 39 | { 40 | container.AddSpecFlowBindings(); 41 | RegisterApplicationServices(container); 42 | } 43 | 44 | private static void RegisterApplicationServices(ContainerBuilder container) 45 | { 46 | container.RegisterModule(new InfrastructureModule(Configuration)); 47 | } 48 | 49 | [BeforeFeature] 50 | public static async Task BeforeFeatureAsync(TestHarness testHarness) 51 | { 52 | await testHarness.StartAsync(); 53 | testHarness.CurrentPage = new HomePage(testHarness.Page); 54 | } 55 | 56 | [BeforeScenario] 57 | public static async Task BeforeScenarioAsync(FeatureContext featureContext, ScenarioContext scenarioContext, TestHarness testHarness) 58 | { 59 | await testHarness.StartScenarioAsync(featureContext.FeatureInfo.Title, scenarioContext.ScenarioInfo.Title); 60 | } 61 | 62 | [AfterScenario] 63 | public static async Task AfterScenarioAsync(ScenarioContext scenarioContext, TestHarness testHarness) 64 | { 65 | await testHarness.StopScenarioAsync(scenarioContext.ScenarioExecutionStatus.ToString()); 66 | } 67 | 68 | [AfterFeature] 69 | public static async Task AfterFeature(TestHarness testHarness) 70 | { 71 | await testHarness.StopAsync(); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Hooks/WeatherForecastHooks.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Tests.Builders; 2 | using CleanArchitecture.Infrastructure; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace CleanArchitecture.AcceptanceTests.Hooks 6 | { 7 | [Binding] 8 | public class WeatherForecastHooks 9 | { 10 | [BeforeFeature("weather_cleanup")] 11 | public static async Task CleanupWeatherForecasts(WeatherContext context) 12 | { 13 | var forecasts = await context.WeatherForecasts.ToListAsync(); 14 | context.RemoveRange(forecasts); 15 | await context.SaveChangesAsync(); 16 | var location = await context.Locations.FirstOrDefaultAsync(e => e.City == "New York"); 17 | var forecast = new WeatherForecastBuilder().WithLocation(location.Id).Build(); 18 | context.Add(forecast); 19 | await context.SaveChangesAsync(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Pages/Abstract/PageObject.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.AcceptanceTests.Pages.Abstract 3 | { 4 | public abstract class PageObject 5 | { 6 | protected PageObject(IPage page) 7 | { 8 | Page = page; 9 | } 10 | 11 | public readonly IPage Page; 12 | public TPage As() where TPage : PageObject 13 | { 14 | return (TPage)this; 15 | } 16 | 17 | public async Task RefreshAsync() 18 | { 19 | await Page.ReloadAsync(); 20 | } 21 | 22 | public async Task WaitForConditionAsync(Func> condition, bool waitForValue = true, int checkDelayMs = 100, int numberOfChecks = 300) 23 | { 24 | var value = !waitForValue; 25 | for (int i = 0; i < numberOfChecks; i++) 26 | { 27 | value = await condition(); 28 | if (value == waitForValue) 29 | { 30 | break; 31 | } 32 | 33 | await Task.Delay(checkDelayMs); 34 | } 35 | return value; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Pages/HomePage.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.AcceptanceTests.Pages.Abstract; 2 | 3 | namespace CleanArchitecture.AcceptanceTests.Pages 4 | { 5 | public class HomePage : PageObject 6 | { 7 | public readonly NavBar NavBar; 8 | 9 | public HomePage(IPage page) : base(page) 10 | { 11 | NavBar = new NavBar(page); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Pages/NavBar.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.AcceptanceTests.Pages.Abstract; 2 | 3 | namespace CleanArchitecture.AcceptanceTests.Pages 4 | { 5 | public class NavBar : PageObject 6 | { 7 | public NavBar(IPage page) : base(page) 8 | { 9 | } 10 | 11 | public ILocator Header => Page.Locator("app-nav-menu"); 12 | public ILocator Home => Header.GetByText("Home"); 13 | public ILocator WeatherForecast => Header.GetByText("Weather Forecast"); 14 | 15 | public async Task OpenWeatherForecast() 16 | { 17 | await WeatherForecast.ClickAsync(); 18 | return new WeatherForecastPage(Page); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Pages/WeatherForecastPage.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.AcceptanceTests.Pages.Abstract; 2 | 3 | namespace CleanArchitecture.AcceptanceTests.Pages 4 | { 5 | public class WeatherForecastPage : PageObject 6 | { 7 | public readonly NavBar NavBar; 8 | 9 | public WeatherForecastPage(IPage page) : base(page) 10 | { 11 | NavBar = new NavBar(page); 12 | } 13 | 14 | public ILocator Title => Page.Locator("h1").GetByText("Weather forecast"); 15 | public ILocator LocationSelector => Page.GetByLabel("Location"); 16 | public ILocator GenerateButton => Page.GetByRole(AriaRole.Button, new() { Name = "Generate" }); 17 | public ILocator Forecasts => Page.Locator("#forecasts"); 18 | public ILocator ForecastRows => Forecasts.Locator("tbody").Locator("tr"); 19 | public ILocator GeneratePrompt => Page.Locator("#generate-prompt"); 20 | 21 | public async Task SelectLocation(string location) 22 | { 23 | await LocationSelector.SelectOptionAsync(new[] { location }); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Settings/BrowserSettings.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.AcceptanceTests.Settings 3 | { 4 | public class BrowserSettings 5 | { 6 | public bool Headless { get; set; } 7 | public int SlowMoMilliseconds { get; set; } 8 | public string BaseUrl { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Steps/Abstract/BaseSteps.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.AcceptanceTests.Steps.Abstract 3 | { 4 | [Binding] 5 | public abstract class BaseSteps 6 | { 7 | protected readonly TestHarness TestHarness; 8 | 9 | protected BaseSteps(TestHarness testHarness) 10 | { 11 | TestHarness = testHarness; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Steps/HomeSteps.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.AcceptanceTests.Pages; 2 | using CleanArchitecture.AcceptanceTests.Steps.Abstract; 3 | 4 | namespace CleanArchitecture.AcceptanceTests.Steps 5 | { 6 | public class HomeSteps : BaseSteps 7 | { 8 | private HomePage _page; 9 | 10 | public HomeSteps(TestHarness testHarness) : base(testHarness) 11 | { 12 | 13 | } 14 | 15 | [Given(@"a user is on the Home page")] 16 | public async Task GivenUserOnHomePage() 17 | { 18 | _page = new HomePage(await TestHarness.GotoAsync("/")); 19 | TestHarness.CurrentPage = _page; 20 | } 21 | 22 | [When(@"Weather Forecast page is opened")] 23 | public async Task WhenWeatherForecastOpened() 24 | { 25 | TestHarness.CurrentPage = await _page.NavBar.OpenWeatherForecast(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Steps/WeatherForecastSteps.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.AcceptanceTests.Pages; 2 | using CleanArchitecture.AcceptanceTests.Steps.Abstract; 3 | 4 | namespace CleanArchitecture.AcceptanceTests.Steps 5 | { 6 | public class WeatherForecastSteps : BaseSteps 7 | { 8 | private WeatherForecastPage _page; 9 | 10 | public WeatherForecastSteps(TestHarness testHarness) : base(testHarness) 11 | { 12 | 13 | } 14 | 15 | [Given(@"a user is on the Weather Forecast page")] 16 | public async Task GivenUserOnHomePage() 17 | { 18 | _page = new WeatherForecastPage(await TestHarness.GotoAsync("/weather-forecast")); 19 | TestHarness.CurrentPage = _page; 20 | } 21 | 22 | [When(@"'(.*)' location is selected")] 23 | public async Task WhenSelectLocation(string location) 24 | { 25 | await _page.SelectLocation(location); 26 | } 27 | 28 | [When(@"a weather forecast is generated")] 29 | public async Task WhenWeatherForecastGenerated() 30 | { 31 | await _page.GenerateButton.ClickAsync(); 32 | } 33 | 34 | [Then(@"Weather Forecast page is open")] 35 | public async Task ThenWeatherForecastOpen() 36 | { 37 | _page = TestHarness.CurrentPage as WeatherForecastPage; 38 | var isVisiable = await _page.Title.IsVisibleAsync(); 39 | isVisiable.Should().BeTrue(); 40 | } 41 | 42 | [Then(@"'(.*)' weather forecasts present")] 43 | public async Task ThenWeatherForecastsPresent(int count) 44 | { 45 | if (count == 0) 46 | { 47 | var isVisible = await _page.Forecasts.IsVisibleAsync(); 48 | isVisible.Should().BeFalse(); 49 | } 50 | else 51 | { 52 | var hasCount = await _page.WaitForConditionAsync(async () => 53 | { 54 | var actualCount = await _page.ForecastRows.CountAsync(); 55 | return actualCount == count; 56 | }); 57 | hasCount.Should().BeTrue(); 58 | } 59 | } 60 | 61 | [Then(@"Generate prompt is visible")] 62 | public async Task ThenGeneratePromptVisible() 63 | { 64 | var isVisiable = await _page.GeneratePrompt.IsVisibleAsync(); 65 | isVisiable.Should().BeTrue(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/TestHostEnvironment.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace CleanArchitecture.AcceptanceTests 5 | { 6 | public class TestHostEnvironment : IHostEnvironment 7 | { 8 | public string EnvironmentName { get; set; } = Environments.Development; 9 | public string ApplicationName { get; set; } = typeof(TestHostEnvironment).Namespace; 10 | public string ContentRootPath { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } 11 | public IFileProvider ContentRootFileProvider { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Microsoft.Playwright; 2 | global using TechTalk.SpecFlow; 3 | global using FluentAssertions; -------------------------------------------------------------------------------- /tests/CleanArchitecture.AcceptanceTests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Browser": { 3 | "Headless": false, 4 | "SlowMoMilliseconds": 200, 5 | "BaseUrl": "https://localhost:44411" 6 | }, 7 | "Database": { 8 | //#if( UseSqlServer ) 9 | "SqlConnectionString": "Server=127.0.0.1, 1433; Database=Weather; Integrated Security=False; User Id = SA; Password=Admin1234!; MultipleActiveResultSets=False;TrustServerCertificate=True", 10 | //#else 11 | "PostgresConnectionString": "Host=127.0.0.1;Database=Weather;Username=postgres;Password=Admin1234!" 12 | //#endif 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/CleanArchitecture.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | <_Parameter1>$(MSBuildProjectName).Tests 19 | 20 | 21 | 22 | 23 | 24 | PreserveNewest 25 | true 26 | PreserveNewest 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | runtime; build; native; contentfiles; analyzers; buildtransitive 36 | all 37 | 38 | 39 | runtime; build; native; contentfiles; analyzers; buildtransitive 40 | all 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/Controllers/ErrorsControllerTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Tests.Builders; 2 | 3 | namespace CleanArchitecture.Api.Tests.Controllers 4 | { 5 | public class ErrorsControllerTests 6 | { 7 | private const string BASE_URL = "api/weather-forecasts"; 8 | private readonly TestWebApplication _application = new TestWebApplication(); 9 | 10 | public ErrorsControllerTests() 11 | { 12 | _application.TestWeatherForecasts.Add(new WeatherForecastBuilder().Build()); 13 | } 14 | 15 | [Fact] 16 | public async Task GivenController_WhenUnhandledError_ThenInternalServerError() 17 | { 18 | using var client = _application.CreateClient(); 19 | _application.WeatherForecastsRepository.Setup(e => e.GetByIdAsync(It.IsAny())).Throws(new Exception("There was an error")); 20 | 21 | var response = await client.GetAsync($"{BASE_URL}/{_application.TestWeatherForecasts.First().Id}"); 22 | 23 | await response.ReadAndAssertError(HttpStatusCode.InternalServerError); 24 | } 25 | 26 | [Fact] 27 | public async Task GivenController_WhenUnauthorizedAccessException_ThenForbidden() 28 | { 29 | using var client = _application.CreateClient(); 30 | _application.WeatherForecastsRepository.Setup(e => e.GetByIdAsync(It.IsAny())).Throws(new UnauthorizedAccessException("Unauthorized")); 31 | 32 | var response = await client.GetAsync($"{BASE_URL}/{_application.TestWeatherForecasts.First().Id}"); 33 | 34 | await response.ReadAndAssertError(HttpStatusCode.Forbidden); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/Controllers/HealthControllerTests.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CleanArchitecture.Api.Tests.Controllers 3 | { 4 | public class HealthControllerTests 5 | { 6 | private readonly TestWebApplication _application = new TestWebApplication(); 7 | 8 | [Fact] 9 | public async Task GivenHealthEndpoint_WhenHealthy_ThenOk() 10 | { 11 | using var client = _application.CreateClient(); 12 | var response = await client.GetAsync("healthz"); 13 | Assert.True(response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.ServiceUnavailable); 14 | } 15 | 16 | [Fact] 17 | public async Task GivenLivenessEndpoint_WhenHealthy_ThenOk() 18 | { 19 | using var client = _application.CreateClient(); 20 | var response = await client.GetAsync("liveness"); 21 | 22 | response.IsSuccessStatusCode.Should().BeTrue(); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/Controllers/LocationsControllerTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Locations.Models; 2 | 3 | namespace CleanArchitecture.Api.Tests.Controllers 4 | { 5 | public class LocationsControllerTests 6 | { 7 | private const string BASE_URL = "api/locations"; 8 | private readonly TestWebApplication _application = new TestWebApplication(); 9 | 10 | [Fact] 11 | public async Task GivenLocationsController_WhenGet_ThenOk() 12 | { 13 | using var client = _application.CreateClient(); 14 | var response = await client.GetAsync(BASE_URL); 15 | 16 | var locations = await response.ReadAndAssertSuccessAsync>(); 17 | 18 | locations.Should().HaveCount(_application.TestLocations.Count); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/Extensions/HttpResponseMessageExtensions.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Api.Infrastructure.ActionResults; 2 | using Newtonsoft.Json; 3 | 4 | namespace CleanArchitecture.Api.Tests 5 | { 6 | internal static class HttpResponseMessageExtensions 7 | { 8 | public static async Task ReadAndAssertSuccessAsync(this HttpResponseMessage response) where T : class 9 | { 10 | response.IsSuccessStatusCode.Should().BeTrue(); 11 | var json = await response.Content.ReadAsStringAsync(); 12 | if (typeof(T) == typeof(string)) 13 | { 14 | return json as T; 15 | } 16 | else 17 | { 18 | return JsonConvert.DeserializeObject(json); 19 | } 20 | } 21 | 22 | public static async Task ReadAndAssertError(this HttpResponseMessage response, HttpStatusCode statusCode) 23 | { 24 | response.StatusCode.Should().Be(statusCode); 25 | var json = await response.Content.ReadAsStringAsync(); 26 | return JsonConvert.DeserializeObject(json)!; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; 3 | global using Moq; 4 | global using System.Net; -------------------------------------------------------------------------------- /tests/CleanArchitecture.Api.Tests/appsettings.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | } 4 | } 5 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Application.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; 3 | global using Moq; -------------------------------------------------------------------------------- /tests/CleanArchitecture.Application.Tests/Weather/DomainEventHandlers/WeatherForecastCreatedDomainEventHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Weather.DomainEventHandlers; 2 | using CleanArchitecture.Application.Weather.IntegrationEvents; 3 | using CleanArchitecture.Core.Weather.DomainEvents; 4 | using Microsoft.Extensions.Logging; 5 | using MiniTransit; 6 | 7 | namespace CleanArchitecture.Application.Tests.Weather.DomainEventHandlers 8 | { 9 | public class WeatherForecastCreatedDomainEventHandlerTests 10 | { 11 | private readonly WeatherForecastCreatedDomainEventHandler _handler; 12 | private readonly Mock _eventBus = new Mock(); 13 | 14 | public WeatherForecastCreatedDomainEventHandlerTests() 15 | { 16 | _handler = new WeatherForecastCreatedDomainEventHandler(Mock.Of>(), _eventBus.Object); 17 | } 18 | 19 | [Fact] 20 | public async Task GivenWeatherForecastCreatedDomainEvent_WhenHandle_ThenPublishIntegrationEvent() 21 | { 22 | var @event = new WeatherForecastCreatedDomainEvent(Guid.NewGuid(), 25, "Sunny", DateTime.UtcNow); 23 | Func action = () => _handler.Handle(@event, default); 24 | await action.Should().NotThrowAsync(); 25 | _eventBus.Verify(e => e.PublishAsync(It.IsAny()), Times.Once); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Application.Tests/Weather/IntegrationEvents/WeatherForecastCreatedEventTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Weather.IntegrationEvents; 2 | using CleanArchitecture.Core.Abstractions.Services; 3 | using Microsoft.Extensions.Logging; 4 | using MiniTransit; 5 | using MiniTransit.Subscriptions; 6 | 7 | namespace CleanArchitecture.Application.Tests.Weather.IntegrationEvents 8 | { 9 | public class WeatherForecastCreatedEventTests 10 | { 11 | private readonly WeatherForecastCreatedEventHandler _handler; 12 | private readonly Mock _notificationsService = new Mock(); 13 | private readonly string _correlationId = Guid.NewGuid().ToString(); 14 | 15 | public WeatherForecastCreatedEventTests() 16 | { 17 | _handler = new WeatherForecastCreatedEventHandler(_notificationsService.Object, Mock.Of>()); 18 | } 19 | 20 | [Fact] 21 | public async Task GivenWeatherForecastCreatedDomainEvent_WhenHandleHotTemperature_ThenSendAlert() 22 | { 23 | var context = GenerateContext(new WeatherForecastCreatedEvent(Guid.NewGuid(), 50, "Hot", DateTime.UtcNow, _correlationId)); 24 | Func action = () => _handler.ConsumeAsync(context); 25 | await action.Should().NotThrowAsync(); 26 | _notificationsService.Verify(e => e.WeatherAlertAsync("Hot", 50, It.IsAny()), Times.Once); 27 | } 28 | 29 | [Fact] 30 | public async Task GivenWeatherForecastCreatedDomainEvent_WhenHandleColdTemperature_ThenSendAlert() 31 | { 32 | var context = GenerateContext(new WeatherForecastCreatedEvent(Guid.NewGuid(), -1, "Cold", DateTime.UtcNow, _correlationId)); 33 | Func action = () => _handler.ConsumeAsync(context); 34 | await action.Should().NotThrowAsync(); 35 | _notificationsService.Verify(e => e.WeatherAlertAsync("Cold", -1, It.IsAny()), Times.Once); 36 | } 37 | 38 | [Fact] 39 | public async Task GivenWeatherForecastCreatedDomainEvent_WhenHandleNormalTemperature_ThenDontSendAlert() 40 | { 41 | var context = GenerateContext(new WeatherForecastCreatedEvent(Guid.NewGuid(), 20, "Mild", DateTime.UtcNow, _correlationId)); 42 | Func action = () => _handler.ConsumeAsync(context); 43 | await action.Should().NotThrowAsync(); 44 | _notificationsService.Verify(e => e.WeatherAlertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); 45 | } 46 | 47 | private ConsumeContext GenerateContext(WeatherForecastCreatedEvent @event) 48 | { 49 | var subscriptionContext = new SubscriptionContext("events", "test", @event.GetType().Name, _handler.GetType().Name, 0); 50 | return new ConsumeContext(@event, subscriptionContext, Mock.Of(), default); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/ApiLayerTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace CleanArchitecture.Arch.Tests 4 | { 5 | [Collection("Sequential")] 6 | public class ApiLayerTests : BaseTests 7 | { 8 | [Fact] 9 | public void Api_Controllers_ShouldOnlyResideInApi() 10 | { 11 | AllTypes.That().Inherit(typeof(ControllerBase)) 12 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Api") 13 | .AssertIsSuccessful(); 14 | } 15 | 16 | [Fact] 17 | public void Api_Controllers_ShouldInheritFromControllerBase() 18 | { 19 | Types.InAssembly(ApiAssembly) 20 | .That().HaveNameEndingWith("Controller") 21 | .Should().Inherit(typeof(ControllerBase)) 22 | .AssertIsSuccessful(); 23 | } 24 | 25 | [Fact] 26 | public void Api_Controllers_ShouldEndWithController() 27 | { 28 | AllTypes.That().Inherit(typeof(ControllerBase)) 29 | .Should().HaveNameEndingWith("Controller") 30 | .AssertIsSuccessful(); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/ApplicationLayerTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Commands; 2 | using CleanArchitecture.Application.Abstractions.Queries; 3 | using AutoMapper; 4 | 5 | namespace CleanArchitecture.Arch.Tests 6 | { 7 | [Collection("Sequential")] 8 | public class ApplicationLayerTests : BaseTests 9 | { 10 | [Fact] 11 | public void ApplicationLayer_Cqrs_QueriesEndWithQuery() 12 | { 13 | AllTypes.That().Inherit(typeof(Query<>)) 14 | .Should().HaveNameEndingWith("Query") 15 | .AssertIsSuccessful(); 16 | } 17 | 18 | [Fact] 19 | public void ApplicationLayer_Cqrs_ContainsAllQueries() 20 | { 21 | AllTypes.That().HaveNameEndingWith("Query") 22 | .Should().ResideInNamespace("CleanArchitecture.Application") 23 | .AssertIsSuccessful(); 24 | } 25 | 26 | [Fact] 27 | public void ApplicationLayer_Cqrs_CommandsEndWithCommand() 28 | { 29 | AllTypes.That().Inherit(typeof(CommandBase<>)) 30 | .Should().HaveNameEndingWith("Command") 31 | .AssertIsSuccessful(); 32 | 33 | AllTypes.That().Inherit(typeof(CreateCommand)) 34 | .Should().HaveNameEndingWith("Command") 35 | .AssertIsSuccessful(); 36 | } 37 | 38 | [Fact] 39 | public void ApplicationLayer_Cqrs_ContainsAllCommands() 40 | { 41 | AllTypes.That().HaveNameEndingWith("Command") 42 | .Should().ResideInNamespace("CleanArchitecture.Application") 43 | .AssertIsSuccessful(); 44 | } 45 | 46 | [Fact] 47 | public void ApplicationLayer_Cqrs_QueryHandlersEndWithQueryHandler() 48 | { 49 | AllTypes.That().Inherit(typeof(QueryHandler<,>)) 50 | .Should().HaveNameEndingWith("QueryHandler") 51 | .AssertIsSuccessful(); 52 | } 53 | 54 | [Fact] 55 | public void ApplicationLayer_Cqrs_ContainsAllQueryHandlers() 56 | { 57 | AllTypes.That().HaveNameEndingWith("QueryHandler") 58 | .Should().ResideInNamespace("CleanArchitecture.Application") 59 | .AssertIsSuccessful(); 60 | } 61 | 62 | [Fact] 63 | public void ApplicationLayer_Cqrs_CommandHandlersEndWithCommandHandler() 64 | { 65 | AllTypes.That().Inherit(typeof(CommandHandler<>)) 66 | .Should().HaveNameEndingWith("CommandHandler") 67 | .AssertIsSuccessful(); 68 | 69 | AllTypes.That().Inherit(typeof(CreateCommandHandler<>)) 70 | .Should().HaveNameEndingWith("CommandHandler") 71 | .AssertIsSuccessful(); 72 | } 73 | 74 | [Fact] 75 | public void ApplicationLayer_Cqrs_ContainsAllCommandHandlers() 76 | { 77 | AllTypes.That().HaveNameEndingWith("CommandHandler") 78 | .Should().ResideInNamespace("CleanArchitecture.Application") 79 | .AssertIsSuccessful(); 80 | } 81 | 82 | [Fact] 83 | public void ApplicationLayer_Dtos_ShouldBeMutable() 84 | { 85 | AllTypes.That().HaveNameEndingWith("Dto") 86 | .And().DoNotHaveName("IntegrationSupportGroupUserDto") 87 | .Should().BeMutable() 88 | .AssertIsSuccessful(); 89 | } 90 | 91 | [Fact] 92 | public void ApplicationLayer_MappingProfiles_ShouldOnlyResideInApplication() 93 | { 94 | AllTypes.That().Inherit(typeof(Profile)) 95 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Application") 96 | .AssertIsSuccessful(); 97 | } 98 | 99 | [Fact] 100 | public void ApplicationLayer_MappingProfiles_ShouldEndWithProfile() 101 | { 102 | AllTypes.That().Inherit(typeof(Profile)) 103 | .Should().HaveNameEndingWith("Profile") 104 | .AssertIsSuccessful(); 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/BaseTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.AutofacModules; 2 | using CleanArchitecture.Core.Abstractions.Entities; 3 | using CleanArchitecture.Infrastructure.AutofacModules; 4 | using System.Reflection; 5 | 6 | namespace CleanArchitecture.Arch.Tests 7 | { 8 | public abstract class BaseTests 9 | { 10 | protected static Assembly ApiAssembly = typeof(Api.Controllers.WeatherForecastsController).Assembly; 11 | protected static Assembly ApplicationAssembly = typeof(ApplicationModule).Assembly; 12 | protected static Assembly InfrastuctureAssembly = typeof(InfrastructureModule).Assembly; 13 | protected static Assembly CoreAssembly = typeof(EntityBase).Assembly; 14 | protected static Types AllTypes = Types.InAssemblies(new List { ApiAssembly, ApplicationAssembly, InfrastuctureAssembly, CoreAssembly }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/CleanArchitecture.Arch.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/CleanArchitectureTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Application.Abstractions.Repositories; 2 | 3 | namespace CleanArchitecture.Arch.Tests 4 | { 5 | [Collection("Sequential")] 6 | public class CleanArchitectureTests : BaseTests 7 | { 8 | [Fact] 9 | public void CleanArchitecture_Layers_ApplicationDoesNotReferenceInfrastructure() 10 | { 11 | AllTypes.That().ResideInNamespace("CleanArchitecture.Application") 12 | .ShouldNot().HaveDependencyOn("CleanArchitecture.Infrastructure") 13 | .AssertIsSuccessful(); 14 | } 15 | 16 | [Fact] 17 | public void CleanArchitecture_Layers_CoreDoesNotReferenceOuter() 18 | { 19 | var coreTypes = AllTypes.That().ResideInNamespace("CleanArchitecture.Core"); 20 | 21 | coreTypes.ShouldNot().HaveDependencyOn("CleanArchitecture.Infrastructure") 22 | .AssertIsSuccessful(); 23 | 24 | coreTypes.ShouldNot().HaveDependencyOn("CleanArchitecture.Application") 25 | .AssertIsSuccessful(); 26 | } 27 | 28 | [Fact] 29 | public void CleanArchitecture_Repositories_OnlyInInfrastructure() 30 | { 31 | AllTypes.That().HaveNameEndingWith("Repository") 32 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Infrastructure") 33 | .AssertIsSuccessful(); 34 | 35 | AllTypes.That().HaveNameEndingWith("Repository") 36 | .And().AreClasses() 37 | .Should().ImplementInterface(typeof(IRepository<>)) 38 | .AssertIsSuccessful(); 39 | } 40 | 41 | [Fact] 42 | public void CleanArchitecture_Repositories_ShouldEndWithRepository() 43 | { 44 | AllTypes.That().Inherit(typeof(IRepository<>)) 45 | .Should().HaveNameEndingWith("Repository") 46 | .AssertIsSuccessful(); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/DomainDrivenDesignTests.cs: -------------------------------------------------------------------------------- 1 | using CSharpFunctionalExtensions; 2 | using CleanArchitecture.Application.Abstractions.DomainEventHandlers; 3 | using CleanArchitecture.Core.Abstractions.DomainEvents; 4 | using CleanArchitecture.Core.Abstractions.Entities; 5 | 6 | namespace CleanArchitecture.Arch.Tests 7 | { 8 | [Collection("Sequential")] 9 | public class DomainDrivenDesignTests : BaseTests 10 | { 11 | [Fact] 12 | public void DomainDrivenDesign_ValueObjects_ShouldBeImmutable() 13 | { 14 | Types.InAssembly(CoreAssembly) 15 | .That().Inherit(typeof(ValueObject)) 16 | .Should().BeImmutable() 17 | .AssertIsSuccessful(); 18 | } 19 | 20 | [Fact] 21 | public void DomainDrivenDesign_Aggregates_ShouldBeHavePrivateSettings() 22 | { 23 | Types.InAssembly(CoreAssembly) 24 | .That().Inherit(typeof(AggregateRoot)) 25 | .Should().BeImmutable() 26 | .AssertIsSuccessful(); 27 | } 28 | 29 | [Fact] 30 | public void DomainDrivenDesign_Entities_ShouldBeHavePrivateSettings() 31 | { 32 | Types.InAssembly(CoreAssembly).That().Inherit(typeof(EntityBase)) 33 | .Should().BeImmutable() 34 | .AssertIsSuccessful(); 35 | } 36 | 37 | [Fact] 38 | public void DomainDrivenDesign_Aggregates_ShouldOnlyResideInCore() 39 | { 40 | AllTypes.That().Inherit(typeof(AggregateRoot)) 41 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Core") 42 | .AssertIsSuccessful(); 43 | } 44 | 45 | [Fact] 46 | public void DomainDrivenDesign_DomainEvents_ShouldOnlyResideInCore() 47 | { 48 | AllTypes.That().Inherit(typeof(DomainEvent)) 49 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Core") 50 | .AssertIsSuccessful(); 51 | } 52 | 53 | [Fact] 54 | public void DomainDrivenDesign_DomainEvents_ShouldEndWithDomainEvent() 55 | { 56 | AllTypes.That().Inherit(typeof(DomainEvent)) 57 | .Should().HaveNameEndingWith("DomainEvent") 58 | .AssertIsSuccessful(); 59 | } 60 | 61 | [Fact] 62 | public void DomainDrivenDesign_DomainEventHandlers_ShouldOnlyResideInApplication() 63 | { 64 | AllTypes.That().Inherit(typeof(DomainEventHandler<>)) 65 | .Should().ResideInNamespaceStartingWith("CleanArchitecture.Application") 66 | .AssertIsSuccessful(); 67 | } 68 | 69 | [Fact] 70 | public void DomainDrivenDesign_DomainEventHandlers_ShouldEndWithDomainEventHandler() 71 | { 72 | AllTypes.That().Inherit(typeof(DomainEventHandler<>)) 73 | .Should().HaveNameEndingWith("DomainEventHandler") 74 | .AssertIsSuccessful(); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/Extensions/ConditionListExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace NetArchTest.Rules 3 | { 4 | internal static class ConditionListExtensions 5 | { 6 | internal static void AssertIsSuccessful(this ConditionList conditionList) 7 | { 8 | var result = conditionList.GetResult(); 9 | (result.FailingTypeNames ?? Array.Empty()).Should().HaveCount(0); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Arch.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using NetArchTest.Rules; 3 | global using FluentAssertions; -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Builders/LocationBuilder.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Locations.Entities; 2 | using CleanArchitecture.Core.Locations.ValueObjects; 3 | 4 | namespace CleanArchitecture.Core.Tests.Builders 5 | { 6 | public class LocationBuilder 7 | { 8 | private string _country = "United Kingdom"; 9 | private string _city = "London"; 10 | private decimal _latitude = 51.51m; 11 | private decimal _longitude = -0.13m; 12 | 13 | public Location Build() 14 | { 15 | return Location.Create(_country, _city, Coordinates.Create(_latitude, _longitude)); 16 | } 17 | 18 | public LocationBuilder WithCity(string city) 19 | { 20 | _city = city; 21 | return this; 22 | } 23 | 24 | public LocationBuilder WithLatitude(decimal latitude) 25 | { 26 | _latitude = latitude; 27 | return this; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Builders/WeatherForecastBuilder.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Weather.Entities; 2 | using CleanArchitecture.Core.Weather.ValueObjects; 3 | 4 | namespace CleanArchitecture.Core.Tests.Builders 5 | { 6 | public class WeatherForecastBuilder 7 | { 8 | private DateTime _date = DateTime.UtcNow; 9 | private int _temperature = 8; 10 | private string? _summary = "Mild"; 11 | private Guid _location = new Guid("B0C91847-8931-4C45-9FD5-018A3A3398CF"); 12 | 13 | public WeatherForecast Build() 14 | { 15 | return WeatherForecast.Create(_date, Temperature.FromCelcius(_temperature), _summary, _location); 16 | } 17 | 18 | public WeatherForecastBuilder WithTemperature(int temperature) 19 | { 20 | _temperature = temperature; 21 | return this; 22 | } 23 | 24 | public WeatherForecastBuilder WithSummary(string? summary) 25 | { 26 | _summary = summary; 27 | return this; 28 | } 29 | 30 | public WeatherForecastBuilder WithDate(DateTime date) 31 | { 32 | _date = date; 33 | return this; 34 | } 35 | 36 | public WeatherForecastBuilder WithLocation(Guid locationId) 37 | { 38 | _location = locationId; 39 | return this; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/CleanArchitecture.Core.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Factories/MockRepositoryFactory.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Entities; 2 | using CleanArchitecture.Application.Abstractions.Repositories; 3 | using MockQueryable.Moq; 4 | 5 | namespace CleanArchitecture.Core.Tests.Factories 6 | { 7 | public static class MockRepositoryFactory 8 | { 9 | public static Mock Create(IEnumerable? items = null) 10 | where T : AggregateRoot 11 | where TRepository : class, IRepository 12 | { 13 | var repository = new Mock(); 14 | return Setup(repository, items); 15 | } 16 | 17 | public static Mock> Create(IEnumerable? items = null) 18 | where T : AggregateRoot 19 | { 20 | var repository = new Mock>(); 21 | return Setup(repository, items); 22 | } 23 | 24 | public static Mock Setup(Mock repository, IEnumerable? items = null) 25 | where T : AggregateRoot 26 | where TRepository : class, IRepository 27 | { 28 | if (items == null) 29 | { 30 | items = new List(); 31 | } 32 | repository.Setup(e => e.GetByIdAsync(It.IsAny())).Returns((id) => Task.FromResult(items.FirstOrDefault(e => e.Id == id))); 33 | repository.Setup(e => e.GetAll(It.IsAny())).Returns(() => items.AsQueryable().BuildMockDbSet().Object); 34 | return repository; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Locations/Entities/LocationTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Exceptions; 2 | using CleanArchitecture.Core.Tests.Builders; 3 | 4 | namespace CleanArchitecture.Core.Tests.Locations.Entities 5 | { 6 | public class LocationTests 7 | { 8 | [Fact] 9 | public void GivenLocation_WhenCreateValid_ThenCreate() 10 | { 11 | var location = new LocationBuilder().Build(); 12 | location.City.Should().NotBeNullOrWhiteSpace(); 13 | } 14 | 15 | [Fact] 16 | public void GivenLocation_WhenCreateEmptyCity_ThenError() 17 | { 18 | Action action = () => new LocationBuilder().WithCity("").Build(); 19 | action.Should().Throw().WithMessage("Required input 'City' is missing."); 20 | } 21 | 22 | [Fact] 23 | public void GivenLocation_WhenLatitudeOver90_ThenError() 24 | { 25 | Action action = () => new LocationBuilder().WithLatitude(91).Build(); 26 | action.Should().Throw().WithMessage("'Latitude' must be between -90° and 90°."); 27 | } 28 | 29 | [Fact] 30 | public void GivenLocation_WhenLatitudeUnder90_ThenError() 31 | { 32 | Action action = () => new LocationBuilder().WithLatitude(-91).Build(); 33 | action.Should().Throw().WithMessage("'Latitude' must be between -90° and 90°."); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; 3 | global using Moq; -------------------------------------------------------------------------------- /tests/CleanArchitecture.Core.Tests/Weather/Entities/WeatherForecastTests.cs: -------------------------------------------------------------------------------- 1 | using CleanArchitecture.Core.Abstractions.Exceptions; 2 | using CleanArchitecture.Core.Tests.Builders; 3 | using CleanArchitecture.Core.Weather.DomainEvents; 4 | using CleanArchitecture.Core.Weather.ValueObjects; 5 | 6 | namespace CleanArchitecture.Core.Tests.Weather.Entities 7 | { 8 | public class WeatherForecastTests 9 | { 10 | [Fact] 11 | public void GivenWeatherForecast_WhenCreate_ThenCreate() 12 | { 13 | var forecast = new WeatherForecastBuilder().Build(); 14 | forecast.Summary.Should().NotBeNullOrWhiteSpace(); 15 | forecast.DomainEvents.Where(e => e is WeatherForecastCreatedDomainEvent).Should().HaveCount(1); 16 | } 17 | 18 | [Fact] 19 | public void GivenWeatherForecast_WhenTemperature10C_ThenCalculateTemperature50F() 20 | { 21 | var forecast = new WeatherForecastBuilder().WithTemperature(10).Build(); 22 | var farenheit = forecast.Temperature.Farenheit; 23 | farenheit.Should().Be(50); 24 | } 25 | 26 | [Fact] 27 | public void GivenWeatherForecast_WhenTemperatureBelowAbsoluteZero_ThenError() 28 | { 29 | var forecastBuilder = new WeatherForecastBuilder().WithTemperature(-300); 30 | Action action = () => forecastBuilder.Build(); 31 | action.Should().Throw().WithMessage("Temperature cannot be below Absolute Zero"); 32 | } 33 | 34 | [Fact] 35 | public void GivenWeatherForecast_WhenSummaryEmpty_ThenError() 36 | { 37 | var forecastBuilder = new WeatherForecastBuilder().WithSummary(null); 38 | Action action = () => forecastBuilder.Build(); 39 | action.Should().Throw().WithMessage("Required input 'Summary' is missing."); 40 | } 41 | 42 | [Fact] 43 | public void GivenWeatherForecast_WhenUpdate_ThenUpdate() 44 | { 45 | var forecast = new WeatherForecastBuilder().Build(); 46 | forecast.Update(Temperature.FromCelcius(21), "Hot"); 47 | forecast.Summary.Should().Be("Hot"); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Infrastructure.Tests/Repositories/Abstract/BaseRepositoryTests.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | using Autofac; 3 | using CleanArchitecture.Core.Abstractions.Entities; 4 | using CleanArchitecture.Application.Abstractions.Repositories; 5 | using CleanArchitecture.Infrastructure.AutofacModules; 6 | using Microsoft.Data.Sqlite; 7 | using Microsoft.EntityFrameworkCore; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Hosting; 10 | using CleanArchitecture.Core.Tests.Builders; 11 | using CleanArchitecture.Core.Locations.Entities; 12 | 13 | namespace CleanArchitecture.Infrastructure.Tests.Repositories.Abstract 14 | { 15 | public abstract class BaseRepositoryTests : IAsyncLifetime 16 | { 17 | private const string InMemoryConnectionString = "DataSource=:memory:"; 18 | private readonly SqliteConnection _connection; 19 | protected readonly WeatherContext Database; 20 | private readonly IContainer _container; 21 | protected readonly Location Location = new LocationBuilder().Build(); 22 | 23 | public BaseRepositoryTests() 24 | { 25 | _connection = new SqliteConnection(InMemoryConnectionString); 26 | _connection.Open(); 27 | var options = new DbContextOptionsBuilder() 28 | .UseSqlite(_connection) 29 | .Options; 30 | 31 | var configuration = new ConfigurationBuilder().Build(); 32 | var containerBuilder = new ContainerBuilder(); 33 | 34 | var env = Mock.Of(); 35 | containerBuilder.RegisterInstance(env); 36 | containerBuilder.RegisterInstance(Mock.Of()); 37 | Database = new WeatherContext(options, env); 38 | Database.Database.EnsureCreated(); 39 | 40 | containerBuilder.RegisterModule(new InfrastructureModule(options, configuration)); 41 | _container = containerBuilder.Build(); 42 | } 43 | 44 | public async Task InitializeAsync() 45 | { 46 | var locationsRepository = GetRepository(); 47 | locationsRepository.Insert(Location); 48 | await GetUnitOfWork().CommitAsync(); 49 | } 50 | 51 | public Task DisposeAsync() 52 | { 53 | Database.Dispose(); 54 | _connection.Close(); 55 | _connection.Dispose(); 56 | return Task.CompletedTask; 57 | } 58 | 59 | protected IRepository GetRepository() 60 | where T : AggregateRoot 61 | { 62 | return _container.Resolve>(); 63 | } 64 | 65 | protected IUnitOfWork GetUnitOfWork() 66 | { 67 | return _container.Resolve(); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/CleanArchitecture.Infrastructure.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using FluentAssertions; 3 | global using Moq; --------------------------------------------------------------------------------