((hostContext, container) =>
27 | {
28 | container.RegisterModule(new InfrastructureModule(hostContext.Configuration));
29 | container.RegisterModule(new ApplicationModule());
30 | });
31 |
32 | await hostBuilder.BuildAndRunAsync();
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.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.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/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.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.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/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 |
--------------------------------------------------------------------------------
/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/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.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/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
13 | {{location.city}}
14 |
15 | Location
16 |
17 |
18 |
19 |
20 | Generate
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Use the Generate button to generate some weather forecasts.
31 |
32 |
33 |
34 |
35 | Date
36 | Temp. (C)
37 | Temp. (F)
38 | Summary
39 |
40 |
41 |
42 |
43 |
44 | {{ forecast.date }}
45 | {{ forecast.temperatureC }}
46 | {{ forecast.temperatureF }}
47 | {{ forecast.summary }}
48 | Delete
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.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.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.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 |
--------------------------------------------------------------------------------
/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.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 |
--------------------------------------------------------------------------------
/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.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.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | }
--------------------------------------------------------------------------------
/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.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 |
--------------------------------------------------------------------------------
/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.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.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.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 |
--------------------------------------------------------------------------------
/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.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/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.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.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.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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------