├── .gitignore ├── Api.IntegrationTests ├── Api.IntegrationTests.csproj ├── BaseIntegrationTest.cs ├── GlobalUsings.cs ├── IntegrationTestApiFactory.cs └── UserController │ └── GetUserIntegrationTests.cs ├── Api ├── .dockerignore ├── Api.csproj ├── Controllers │ ├── UserController.cs │ └── UserTaskController.cs ├── Dockerfile ├── Dtos │ └── UserTaskDtos │ │ └── UpdateUserTaskBodyDto.cs ├── Program.cs ├── Properties │ └── launchSettings.json └── appsettings.json ├── BackgroundWorker ├── .dockerignore ├── BackgroundWorker.csproj ├── Dockerfile ├── Program.cs ├── Properties │ └── launchSettings.json └── appsettings.json ├── Components.UnitTests ├── BaseUnitTest.cs ├── Components.UnitTests.csproj ├── GlobalUsings.cs └── UserComponents │ └── Queries │ └── GetUserUnitTests.cs ├── Components ├── Components.csproj ├── ComponentsExtensions.cs ├── UserComponents │ ├── Commands │ │ ├── CreateUser.cs │ │ └── DeleteUser.cs │ └── Queries │ │ └── GetUser.cs └── UserTaskComponents │ ├── Commands │ ├── CreateUserTask.cs │ ├── DeleteUserTask.cs │ └── UpdateUserTask.cs │ └── Queries │ ├── GetUserTask.cs │ └── GetUserTasksCreatedByUser.cs ├── DataModel ├── DataModel.csproj ├── DataModelExtensions.cs ├── DataModelOptions.cs ├── DatabaseContext.cs ├── DatabaseContextFactory.cs ├── Migrations │ ├── 20230905193848_Init.Designer.cs │ ├── 20230905193848_Init.cs │ └── DatabaseContextModelSnapshot.cs └── Models │ ├── BaseModel.cs │ ├── BaseModelEnum.cs │ ├── BaseModelEnumExtensions.cs │ ├── BaseModelExtensions.cs │ ├── Refs │ ├── TaskStatusRefs │ │ ├── TaskStatusEnum.cs │ │ ├── TaskStatusRef.cs │ │ └── TaskStatusRefContext.cs │ └── UserStatusRefs │ │ ├── UserStatusEnum.cs │ │ ├── UserStatusRef.cs │ │ └── UserStatusRefContext.cs │ ├── UserTasks │ ├── UserTask.cs │ └── UserTaskContext.cs │ └── Users │ ├── User.cs │ └── UserContext.cs ├── EventHandlers.UnitTests ├── BaseUnitTest.cs ├── EventHandlers.UnitTests.csproj ├── GlobalUsings.cs └── UserTaskEventHandlers │ └── NotifyAssignedUserEventHandlerTests.cs ├── EventHandlers ├── EventHandlers.csproj ├── EventHandlersExtensions.cs ├── UserEventHandlers │ ├── LogUserCreatedEventHandler.cs │ └── LogUserDeletedEventHandler.cs └── UserTaskEventHandlers │ └── NotifyAssignedUserEventHandler.cs ├── Events ├── Events.csproj ├── UserEvents │ ├── UserCreatedEvent.cs │ └── UserDeletedEvent.cs └── UserTaskEvents │ ├── UserTaskCreatedEvent.cs │ ├── UserTaskDeletedEvent.cs │ └── UserTaskUpdatedEvent.cs ├── Infrastructure ├── Authentication │ └── AuthenticationExtensions.cs ├── BackgroundJob │ ├── BackgroundJobOptions.cs │ ├── Hangfire │ │ ├── HangfireExtensions.cs │ │ └── HangfireJobBus.cs │ └── IBackgroundJobBus.cs ├── CQRS │ ├── CQRSExtensions.cs │ ├── Commands │ │ ├── CommandBus.cs │ │ ├── ICommand.cs │ │ ├── ICommandBus.cs │ │ └── ICommandHandler.cs │ ├── Events │ │ ├── Event.cs │ │ ├── EventBus.cs │ │ ├── IEvent.cs │ │ ├── IEventBus.cs │ │ └── IEventHandler.cs │ ├── Queries │ │ ├── IQuery.cs │ │ ├── IQueryBus.cs │ │ ├── IQueryHandler.cs │ │ └── QueryBus.cs │ └── ValidationBehavior.cs ├── Cors │ └── CorsExtensions.cs ├── ExceptionHandling │ ├── ExceptionHandlingExtensions.cs │ ├── ExceptionHandlingMiddleware.cs │ └── Exceptions │ │ ├── BadRequestException.cs │ │ ├── ForbiddenException.cs │ │ ├── NotFoundException.cs │ │ └── NotSupportedException.cs ├── HealthChecks │ └── HealthChecksExtensions.cs ├── Infrastructure.csproj ├── StronglyTypedIds │ ├── InvalidStronglyTypedIdException.cs │ ├── StronglyTypedIdBaseEntity.cs │ ├── StronglyTypedIdConversions.cs │ ├── StronglyTypedIdConverter.cs │ ├── StronglyTypedIdHelper.cs │ ├── StronglyTypedIdJsonConverterFactory.cs │ ├── StronglyTypedIdValidator.cs │ └── StronglyTypedIdsExtensions.cs └── Swagger │ ├── AuthOperationsFilter.cs │ ├── StronglyTypedIdSchemaFilter.cs │ └── SwaggerExtensions.cs ├── LICENSE ├── NotificationService ├── INotificationService.cs ├── NotificationService.cs └── NotificationService.csproj ├── README.md ├── SampleApplication.sln └── docker-compose.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | 84 | # Visual Studio profiler 85 | *.psess 86 | *.vsp 87 | *.vspx 88 | *.sap 89 | 90 | # TFS 2012 Local Workspace 91 | $tf/ 92 | 93 | # Guidance Automation Toolkit 94 | *.gpState 95 | 96 | # ReSharper is a .NET coding add-in 97 | _ReSharper*/ 98 | *.[Rr]e[Ss]harper 99 | *.DotSettings.user 100 | 101 | # JustCode is a .NET coding add-in 102 | .JustCode 103 | 104 | # TeamCity is a build add-in 105 | _TeamCity* 106 | 107 | # DotCover is a Code Coverage Tool 108 | *.dotCover 109 | 110 | # NCrunch 111 | _NCrunch_* 112 | .*crunch*.local.xml 113 | nCrunchTemp_* 114 | 115 | # MightyMoose 116 | *.mm.* 117 | AutoTest.Net/ 118 | 119 | # Web workbench (sass) 120 | .sass-cache/ 121 | 122 | # Installshield output folder 123 | [Ee]xpress/ 124 | 125 | # DocProject is a documentation generator add-in 126 | DocProject/buildhelp/ 127 | DocProject/Help/*.HxT 128 | DocProject/Help/*.HxC 129 | DocProject/Help/*.hhc 130 | DocProject/Help/*.hhk 131 | DocProject/Help/*.hhp 132 | DocProject/Help/Html2 133 | DocProject/Help/html 134 | 135 | # Click-Once directory 136 | publish/ 137 | 138 | # Publish Web Output 139 | *.[Pp]ublish.xml 140 | *.azurePubxml 141 | # TODO: Comment the next line if you want to checkin your web deploy settings 142 | # but database connection strings (with potential passwords) will be unencrypted 143 | *.pubxml 144 | *.publishproj 145 | 146 | # NuGet Packages 147 | *.nupkg 148 | # The packages folder can be ignored because of Package Restore 149 | **/packages/* 150 | # except build/, which is used as an MSBuild target. 151 | !**/packages/build/ 152 | # Uncomment if necessary however generally it will be regenerated when needed 153 | #!**/packages/repositories.config 154 | 155 | # Microsoft Azure Build Output 156 | csx/ 157 | *.build.csdef 158 | 159 | # Microsoft Azure Emulator 160 | ecf/ 161 | rcf/ 162 | 163 | # Microsoft Azure ApplicationInsights config file 164 | ApplicationInsights.config 165 | 166 | # Windows Store app package directory 167 | AppPackages/ 168 | BundleArtifacts/ 169 | 170 | # Visual Studio cache files 171 | # files ending in .cache can be ignored 172 | *.[Cc]ache 173 | # but keep track of directories ending in .cache 174 | !*.[Cc]ache/ 175 | 176 | # Others 177 | ClientBin/ 178 | ~$* 179 | *~ 180 | *.dbmdl 181 | *.dbproj.schemaview 182 | *.pfx 183 | *.publishsettings 184 | node_modules/ 185 | orleans.codegen.cs 186 | 187 | # RIA/Silverlight projects 188 | Generated_Code/ 189 | 190 | # Backup & report files from converting an old project file 191 | # to a newer Visual Studio version. Backup files are not needed, 192 | # because we have git ;-) 193 | _UpgradeReport_Files/ 194 | Backup*/ 195 | UpgradeLog*.XML 196 | UpgradeLog*.htm 197 | 198 | # SQL Server files 199 | *.mdf 200 | *.ldf 201 | 202 | # Business Intelligence projects 203 | *.rdl.data 204 | *.bim.layout 205 | *.bim_*.settings 206 | 207 | # Microsoft Fakes 208 | FakesAssemblies/ 209 | 210 | # GhostDoc plugin setting file 211 | *.GhostDoc.xml 212 | 213 | # Node.js Tools for Visual Studio 214 | .ntvs_analysis.dat 215 | 216 | # Visual Studio 6 build log 217 | *.plg 218 | 219 | # Visual Studio 6 workspace options file 220 | *.opt 221 | 222 | # Visual Studio LightSwitch build output 223 | **/*.HTMLClient/GeneratedArtifacts 224 | **/*.DesktopClient/GeneratedArtifacts 225 | **/*.DesktopClient/ModelManifest.xml 226 | **/*.Server/GeneratedArtifacts 227 | **/*.Server/ModelManifest.xml 228 | _Pvt_Extensions 229 | 230 | # Paket dependency manager 231 | .paket/paket.exe 232 | 233 | # FAKE - F# Make 234 | .fake/ 235 | 236 | # Visual Studio 2015 237 | .vs/ 238 | 239 | *.swp 240 | *.*~ 241 | project.lock.json 242 | .DS_Store 243 | *.pyc 244 | nupkg/ 245 | 246 | # Visual Studio Code 247 | .vscode 248 | 249 | # Jetbrains 250 | .idea/ 251 | 252 | [Aa]ppsettings.[Pp]roduction.json 253 | [Aa]ppsettings.[Ss]taging.json 254 | [Aa]ppsettings.[Dd]evelopment.json 255 | 256 | API/API.xml 257 | -------------------------------------------------------------------------------- /Api.IntegrationTests/Api.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | all 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Api.IntegrationTests/BaseIntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Api.IntegrationTests; 6 | 7 | public abstract class BaseIntegrationTest : IClassFixture 8 | { 9 | private readonly IServiceScope _scope; 10 | protected readonly HttpClient Client; 11 | protected readonly DatabaseContext Db; 12 | 13 | protected BaseIntegrationTest(IntegrationTestApiFactory factory) 14 | { 15 | _scope = factory.Services.CreateScope(); 16 | Db = _scope.ServiceProvider.GetRequiredService(); 17 | Db.Database.Migrate(); 18 | Client = factory.CreateClient(); 19 | } 20 | } -------------------------------------------------------------------------------- /Api.IntegrationTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /Api.IntegrationTests/IntegrationTestApiFactory.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.AspNetCore.Mvc.Testing; 4 | using Microsoft.AspNetCore.TestHost; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Testcontainers.SqlEdge; 8 | 9 | namespace Api.IntegrationTests; 10 | 11 | public class IntegrationTestApiFactory : WebApplicationFactory, IAsyncLifetime 12 | { 13 | private readonly SqlEdgeContainer _dbContainer = 14 | new SqlEdgeBuilder() 15 | .WithImage("mcr.microsoft.com/azure-sql-edge:latest") 16 | .WithPassword("pass123!") 17 | .Build(); 18 | 19 | public Task InitializeAsync() 20 | { 21 | return _dbContainer.StartAsync(); 22 | } 23 | 24 | public new Task DisposeAsync() 25 | { 26 | return _dbContainer.StopAsync(); 27 | } 28 | 29 | protected override void ConfigureWebHost(IWebHostBuilder builder) 30 | { 31 | builder.ConfigureTestServices(services => 32 | { 33 | var descriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions)); 34 | 35 | if (descriptor is not null) 36 | { 37 | services.Remove(descriptor); 38 | } 39 | 40 | services.AddDbContext(options => 41 | { 42 | options.UseSqlServer(_dbContainer.GetConnectionString()); 43 | }); 44 | }); 45 | } 46 | } -------------------------------------------------------------------------------- /Api.IntegrationTests/UserController/GetUserIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Bogus; 3 | using Components.UserComponents.Queries; 4 | using DataModel.Models.Refs.UserStatusRefs; 5 | using DataModel.Models.Users; 6 | using FluentAssertions; 7 | using Newtonsoft.Json; 8 | 9 | namespace Api.IntegrationTests.UserController; 10 | 11 | public class GetUserTests : BaseIntegrationTest 12 | { 13 | public GetUserTests(IntegrationTestApiFactory factory) : base(factory) 14 | { 15 | } 16 | 17 | [Theory] 18 | [InlineData("Not valid id")] 19 | [InlineData("123456789")] 20 | [InlineData("us_123456789")] 21 | [InlineData("un_01h9h119dmpapchz8ap7x1f26b")] 22 | public async Task GetUser_Should_ReturnBadRequest_When_UserIdIsNotValid(string userId) 23 | { 24 | var expectedStatusCode = HttpStatusCode.BadRequest; 25 | 26 | var response = await Client.GetAsync($"/api/users/{userId}"); 27 | var actualStatusCode = response.StatusCode; 28 | 29 | actualStatusCode.Should().Be(expectedStatusCode); 30 | } 31 | 32 | [Fact] 33 | public async Task Handle_Should_ReturnNotFound_When_UserIsNotFound() 34 | { 35 | var expectedStatusCode = HttpStatusCode.NotFound; 36 | 37 | var userId = UserId.New().ToString(); 38 | var response = await Client.GetAsync($"/api/users/{userId}"); 39 | var actualStatusCode = response.StatusCode; 40 | 41 | actualStatusCode.Should().Be(expectedStatusCode); 42 | } 43 | 44 | [Fact] 45 | public async Task Handle_Should_ReturnOK_When_UserIsFound() 46 | { 47 | var expectedStatusCode = HttpStatusCode.OK; 48 | 49 | var user = new Faker() 50 | .RuleFor(user => user.FirstName, f => f.Name.FirstName()) 51 | .RuleFor(user => user.LastName, f => f.Name.LastName()) 52 | .RuleFor(user => user.Email, f => f.Internet.Email()) 53 | .RuleFor(user => user.StatusEnum, UserStatusEnum.Active) 54 | .Generate(); 55 | 56 | Db.Users.Add(user); 57 | Db.SaveChanges(); 58 | 59 | var response = await Client.GetAsync($"/api/users/{user.Id}"); 60 | var actualStatusCode = response.StatusCode; 61 | 62 | actualStatusCode.Should().Be(expectedStatusCode); 63 | } 64 | 65 | [Fact] 66 | public async Task Handle_Should_ReturnUserResult_When_UserIsFound() 67 | { 68 | var user = new Faker() 69 | .RuleFor(user => user.FirstName, f => f.Name.FirstName()) 70 | .RuleFor(user => user.LastName, f => f.Name.LastName()) 71 | .RuleFor(user => user.Email, f => f.Internet.Email()) 72 | .RuleFor(user => user.StatusEnum, UserStatusEnum.Active) 73 | .Generate(); 74 | 75 | var expectedResult = new GetUser.Result 76 | { 77 | FirstName = user.FirstName, 78 | LastName = user.LastName 79 | }; 80 | 81 | Db.Users.Add(user); 82 | Db.SaveChanges(); 83 | 84 | var response = await Client.GetAsync($"/api/users/{user.Id}"); 85 | var json = await response.Content.ReadAsStringAsync(); 86 | var actualResult = JsonConvert.DeserializeObject(json); 87 | 88 | actualResult.Should().BeEquivalentTo(expectedResult); 89 | } 90 | } -------------------------------------------------------------------------------- /Api/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /Api/Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | Linux 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Api/Controllers/UserController.cs: -------------------------------------------------------------------------------- 1 | using Components.UserComponents.Commands; 2 | using Components.UserComponents.Queries; 3 | using DataModel.Models.Users; 4 | using Infrastructure.CQRS.Commands; 5 | using Infrastructure.CQRS.Queries; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace Api.Controllers; 9 | 10 | [ApiController] 11 | [Route("/api/users")] 12 | public class UserController : ControllerBase 13 | { 14 | private readonly ICommandBus _commandBus; 15 | private readonly IQueryBus _queryBus; 16 | 17 | public UserController(IQueryBus queryBus, ICommandBus commandBus) 18 | { 19 | _queryBus = queryBus; 20 | _commandBus = commandBus; 21 | } 22 | 23 | [HttpGet] 24 | [Route("{userId}", Name = nameof(GetUser))] 25 | public async Task> GetUser([FromRoute] UserId userId, 26 | CancellationToken cancellationToken) 27 | { 28 | var query = new GetUser.Query 29 | { 30 | UserId = userId 31 | }; 32 | var result = await _queryBus.Send(query, cancellationToken); 33 | 34 | return Ok(result); 35 | } 36 | 37 | [HttpPost] 38 | [Route("", Name = nameof(CreateUser))] 39 | public async Task> CreateUser( 40 | [FromBody] CreateUser.Command command, 41 | CancellationToken cancellationToken 42 | ) 43 | { 44 | var result = await _commandBus.Send(command, cancellationToken); 45 | 46 | return Created($"/api/users/{result.Id}", result); 47 | } 48 | 49 | [HttpDelete] 50 | [Route("{userId}", Name = nameof(DeleteUser))] 51 | public async Task DeleteUser( 52 | [FromRoute] string userId, 53 | CancellationToken cancellationToken 54 | ) 55 | { 56 | var command = new DeleteUser.Command 57 | { 58 | UserId = userId 59 | }; 60 | 61 | await _commandBus.Send(command, cancellationToken); 62 | 63 | return NoContent(); 64 | } 65 | } -------------------------------------------------------------------------------- /Api/Controllers/UserTaskController.cs: -------------------------------------------------------------------------------- 1 | using Api.Dtos.UserTaskDtos; 2 | using Components.UserTaskComponents.Commands; 3 | using Components.UserTaskComponents.Queries; 4 | using DataModel.Models.UserTasks; 5 | using Infrastructure.CQRS.Commands; 6 | using Infrastructure.CQRS.Queries; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace Api.Controllers; 10 | 11 | [ApiController] 12 | [Route("/api/user-tasks")] 13 | public class UserTaskController : ControllerBase 14 | { 15 | private readonly ICommandBus _commandBus; 16 | private readonly IQueryBus _queryBus; 17 | 18 | public UserTaskController(IQueryBus queryBus, ICommandBus commandBus) 19 | { 20 | _queryBus = queryBus; 21 | _commandBus = commandBus; 22 | } 23 | 24 | [HttpPost] 25 | [Route("", Name = nameof(CreateUserTask))] 26 | public async Task> CreateUserTask( 27 | [FromBody] CreateUserTask.Command command, 28 | CancellationToken cancellationToken 29 | ) 30 | { 31 | var result = await _commandBus.Send(command, cancellationToken); 32 | 33 | return Created($"/api/user-tasks/{result.Id}", result); 34 | } 35 | 36 | [HttpGet] 37 | [Route("{userTaskId}", Name = nameof(GetUserTask))] 38 | public async Task> GetUserTask( 39 | [FromRoute] UserTaskId userTaskId, 40 | CancellationToken cancellationToken 41 | ) 42 | { 43 | var query = new GetUserTask.Query 44 | { 45 | UserTaskId = userTaskId 46 | }; 47 | var result = await _queryBus.Send(query, cancellationToken); 48 | 49 | return Ok(result); 50 | } 51 | 52 | [HttpDelete] 53 | [Route("{userTaskId}", Name = nameof(DeleteUserTask))] 54 | public async Task DeleteUserTask( 55 | [FromRoute] string userTaskId, 56 | CancellationToken cancellationToken 57 | ) 58 | { 59 | var command = new DeleteUserTask.Command 60 | { 61 | UserTaskId = userTaskId 62 | }; 63 | await _commandBus.Send(command, cancellationToken); 64 | 65 | return NoContent(); 66 | } 67 | 68 | [HttpPatch] 69 | [Route("{userTaskId}", Name = nameof(UpdateUserTask))] 70 | public async Task UpdateUserTask( 71 | [FromRoute] UserTaskId userTaskId, 72 | [FromBody] UpdateUserTaskBodyDto body, 73 | CancellationToken cancellationToken 74 | ) 75 | { 76 | var command = new UpdateUserTask.Command 77 | { 78 | UserTaskId = userTaskId, 79 | AssignToUserId = body.AssignToUserId 80 | }; 81 | await _commandBus.Send(command, cancellationToken); 82 | 83 | return NoContent(); 84 | } 85 | } -------------------------------------------------------------------------------- /Api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base 2 | WORKDIR /app 3 | EXPOSE 80 4 | EXPOSE 443 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build 7 | WORKDIR /src 8 | COPY ["Api/Api.csproj", "Api/"] 9 | RUN dotnet restore "Api/Api.csproj" 10 | COPY . . 11 | WORKDIR "/src/Api" 12 | RUN dotnet build "Api.csproj" -c Release -o /app/build 13 | 14 | FROM build AS publish 15 | RUN dotnet publish "Api.csproj" -c Release -o /app/publish 16 | 17 | FROM base AS final 18 | WORKDIR /app 19 | COPY --from=publish /app/publish . 20 | ENTRYPOINT ["dotnet", "Api.dll"] 21 | -------------------------------------------------------------------------------- /Api/Dtos/UserTaskDtos/UpdateUserTaskBodyDto.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Users; 2 | 3 | namespace Api.Dtos.UserTaskDtos; 4 | 5 | public class UpdateUserTaskBodyDto 6 | { 7 | public UserId AssignToUserId { get; init; } = null!; 8 | } -------------------------------------------------------------------------------- /Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Components; 2 | using DataModel; 3 | using Infrastructure.BackgroundJob; 4 | using Infrastructure.BackgroundJob.Hangfire; 5 | using Infrastructure.Cors; 6 | using Infrastructure.CQRS; 7 | using Infrastructure.ExceptionHandling; 8 | using Infrastructure.HealthChecks; 9 | using Infrastructure.StronglyTypedIds; 10 | using Infrastructure.Swagger; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | var dataModelConnectionString = builder.Configuration.GetConnectionString(DataModelOptions.ConnectionString) ?? 15 | throw new Exception( 16 | $"{DataModelOptions.ConnectionString} is not found in configuration"); 17 | 18 | var hangfireConnectionString = builder.Configuration.GetConnectionString(BackgroundJobOptions.ConnectionString) ?? 19 | throw new Exception( 20 | $"{BackgroundJobOptions.ConnectionString} is not found in configuration"); 21 | 22 | builder.Services 23 | .AddSwagger() 24 | .AddComponents() 25 | .AddDataModel(dataModelConnectionString) 26 | .AddHangfire(hangfireConnectionString) 27 | .AddCQRS() 28 | .AddExceptionHandling() 29 | .AddApiHealthChecks() 30 | .AddControllers() 31 | .AddStronglyTypedIds(); 32 | 33 | var allowAllOrigins = "_allowAllOrigins"; 34 | builder.Services.AddCorsPolicy(allowAllOrigins); 35 | 36 | var app = builder.Build(); 37 | 38 | if (!app.Environment.IsProduction()) 39 | { 40 | app.UseCorsPolicy(allowAllOrigins); 41 | app.UseSwaggerWithUI(); 42 | } 43 | 44 | app.UseHttpsRedirection(); 45 | 46 | app 47 | .UseCQRS() 48 | .UseExceptionhandling() 49 | .UseApiHealthChecks() 50 | .MapControllers(); 51 | 52 | app.Run(); 53 | 54 | // Needed for Api.IntegrationTests project 55 | public partial class Program 56 | { 57 | } -------------------------------------------------------------------------------- /Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:40632", 8 | "sslPort": 44304 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": false, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5290", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": false, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7048;http://localhost:5290", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": false, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DataModelConnectionString": "Server=db,1433;Database=app;User Id=sa;Password=pass123!;TrustServerCertificate=True;", 11 | "BackgroundJobConnectionString": "Server=db,1433;Database=jobs;User Id=sa;Password=pass123!;TrustServerCertificate=True;" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /BackgroundWorker/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /BackgroundWorker/BackgroundWorker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | Linux 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /BackgroundWorker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base 2 | WORKDIR /app 3 | EXPOSE 80 4 | EXPOSE 443 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build 7 | WORKDIR /src 8 | COPY ["BackgroundWorker/BackgroundWorker.csproj", "BackgroundWorker/"] 9 | RUN dotnet restore "BackgroundWorker/BackgroundWorker.csproj" 10 | COPY . . 11 | WORKDIR "/src/BackgroundWorker" 12 | RUN dotnet build "BackgroundWorker.csproj" -c Release -o /app/build 13 | 14 | FROM build AS publish 15 | RUN dotnet publish "BackgroundWorker.csproj" -c Release -o /app/publish 16 | 17 | FROM base AS final 18 | WORKDIR /app 19 | COPY --from=publish /app/publish . 20 | ENTRYPOINT ["dotnet", "BackgroundWorker.dll"] 21 | -------------------------------------------------------------------------------- /BackgroundWorker/Program.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using EventHandlers; 3 | using Infrastructure.BackgroundJob; 4 | using Infrastructure.BackgroundJob.Hangfire; 5 | using Infrastructure.CQRS; 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | 10 | var hangfireConnectionString = builder.Configuration.GetConnectionString(BackgroundJobOptions.ConnectionString) ?? 11 | throw new Exception( 12 | $"{BackgroundJobOptions.ConnectionString} is not found in configuration"); 13 | 14 | var dataModelConnectionString = builder.Configuration.GetConnectionString(DataModelOptions.ConnectionString) ?? 15 | throw new Exception( 16 | $"{DataModelOptions.ConnectionString} is not found in configuration"); 17 | 18 | builder.Services 19 | .AddDataModel(dataModelConnectionString) 20 | .AddHangfire(hangfireConnectionString) 21 | .AddCQRS() 22 | .AddEventHandlers() 23 | .AddHangfireWorker(); 24 | 25 | var app = builder.Build(); 26 | 27 | app 28 | .UseHangfireUI("/hangfire") 29 | .Run(); -------------------------------------------------------------------------------- /BackgroundWorker/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5706", 7 | "sslPort": 44323 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": false, 15 | "applicationUrl": "http://localhost:5115", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "https": { 21 | "commandName": "Project", 22 | "dotnetRunMessages": true, 23 | "launchBrowser": false, 24 | "applicationUrl": "https://localhost:7021;http://localhost:5115", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | }, 29 | "IIS Express": { 30 | "commandName": "IISExpress", 31 | "launchBrowser": false, 32 | "environmentVariables": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BackgroundWorker/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DataModelConnectionString": "Server=db,1433;Database=app;User Id=sa;Password=pass123!;TrustServerCertificate=True;", 11 | "BackgroundJobConnectionString": "Server=db,1433;Database=jobs;User Id=sa;Password=pass123!;TrustServerCertificate=True;" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Components.UnitTests/BaseUnitTest.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Diagnostics; 4 | 5 | namespace Components.UnitTests; 6 | 7 | public abstract class BaseUnitTest 8 | { 9 | protected readonly DbContextOptions DbContextOptions; 10 | 11 | public BaseUnitTest() 12 | { 13 | DbContextOptions = new DbContextOptionsBuilder() 14 | .UseInMemoryDatabase(Guid.NewGuid().ToString()) 15 | .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning)) 16 | .Options; 17 | } 18 | } -------------------------------------------------------------------------------- /Components.UnitTests/Components.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | all 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Components.UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /Components.UnitTests/UserComponents/Queries/GetUserUnitTests.cs: -------------------------------------------------------------------------------- 1 | using Bogus; 2 | using Components.UserComponents.Queries; 3 | using DataModel; 4 | using DataModel.Models.Refs.UserStatusRefs; 5 | using DataModel.Models.Users; 6 | using FluentAssertions; 7 | using FluentValidation.TestHelper; 8 | using Infrastructure.ExceptionHandling.Exceptions; 9 | 10 | namespace Components.UnitTests.UserComponents.Queries; 11 | 12 | public class GetUserUnitTests : BaseUnitTest 13 | { 14 | public static IEnumerable GetUserIds(string id) 15 | { 16 | return new List 17 | { 18 | new UserId[] {new(id)} 19 | }; 20 | } 21 | 22 | [Theory] 23 | [MemberData(nameof(GetUserIds), "Not valid id")] 24 | [MemberData(nameof(GetUserIds), "123456789")] 25 | [MemberData(nameof(GetUserIds), "us_123456789")] 26 | [MemberData(nameof(GetUserIds), "un_01h9h119dmpapchz8ap7x1f26b")] 27 | public void GetUserValidator_Should_ThrowValidationError_When_UserIdIsNotValid(UserId userId) 28 | { 29 | var query = new GetUser.Query 30 | { 31 | UserId = userId 32 | }; 33 | 34 | var validator = new GetUser.GetUserValidator(); 35 | var result = validator.TestValidate(query); 36 | 37 | result.ShouldHaveValidationErrorFor(q => q.UserId); 38 | } 39 | 40 | [Fact] 41 | public void GetUserValidator_Should_NotThrowValidationError_When_UserIdIsValid() 42 | { 43 | var query = new GetUser.Query 44 | { 45 | UserId = UserId.New() 46 | }; 47 | 48 | var validator = new GetUser.GetUserValidator(); 49 | var result = validator.TestValidate(query); 50 | 51 | result.ShouldNotHaveValidationErrorFor(q => q.UserId); 52 | } 53 | 54 | [Fact] 55 | public void Handle_Should_ReturnNotFoundException_When_UserIsNotFound() 56 | { 57 | var query = new GetUser.Query 58 | { 59 | UserId = UserId.New() 60 | }; 61 | 62 | using var db = new DatabaseContext(DbContextOptions); 63 | var handler = new GetUser.Handler(db); 64 | var act = () => handler.Handle(query, CancellationToken.None); 65 | 66 | act.Should().ThrowAsync(); 67 | } 68 | 69 | [Fact] 70 | public async Task Handle_Should_ReturnGetUserResult_When_UserIsFound() 71 | { 72 | var user = new Faker() 73 | .RuleFor(user => user.FirstName, f => f.Name.FirstName()) 74 | .RuleFor(user => user.LastName, f => f.Name.LastName()) 75 | .RuleFor(user => user.Email, f => f.Internet.Email()) 76 | .RuleFor(user => user.StatusEnum, UserStatusEnum.Active) 77 | .Generate(); 78 | var expectedResult = new GetUser.Result 79 | { 80 | FirstName = user.FirstName, 81 | LastName = user.LastName 82 | }; 83 | 84 | var query = new GetUser.Query 85 | { 86 | UserId = user.Id 87 | }; 88 | 89 | using var db = new DatabaseContext(DbContextOptions); 90 | db.Users.Add(user); 91 | db.SaveChanges(); 92 | 93 | var handler = new GetUser.Handler(db); 94 | var actualResult = await handler.Handle(query, CancellationToken.None); 95 | 96 | actualResult.Should().BeEquivalentTo(expectedResult); 97 | } 98 | } -------------------------------------------------------------------------------- /Components/Components.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Components/ComponentsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Components; 4 | 5 | public static class ComponentsExtensions 6 | { 7 | public static IServiceCollection AddComponents(this IServiceCollection services) 8 | { 9 | return services; 10 | } 11 | } -------------------------------------------------------------------------------- /Components/UserComponents/Commands/CreateUser.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Users; 3 | using Events.UserEvents; 4 | using FluentValidation; 5 | using Infrastructure.CQRS.Commands; 6 | 7 | namespace Components.UserComponents.Commands; 8 | 9 | public class CreateUser 10 | { 11 | public class Command : ICommand 12 | { 13 | public string FirstName { get; init; } = null!; 14 | public string LastName { get; init; } = null!; 15 | public string Email { get; init; } = null!; 16 | } 17 | 18 | public class CreateUserValidator : AbstractValidator 19 | { 20 | public CreateUserValidator() 21 | { 22 | RuleFor(command => command.FirstName).NotEmpty(); 23 | RuleFor(command => command.LastName).NotEmpty(); 24 | RuleFor(command => command.Email).NotEmpty().EmailAddress(); 25 | } 26 | } 27 | 28 | public class Result 29 | { 30 | public UserId Id { get; init; } = null!; 31 | } 32 | 33 | public class Handler : ICommandHandler 34 | { 35 | private readonly DatabaseContext _db; 36 | 37 | public Handler(DatabaseContext db) 38 | { 39 | _db = db; 40 | } 41 | 42 | public async Task Handle(Command request, CancellationToken cancellationToken) 43 | { 44 | var user = new User 45 | { 46 | FirstName = request.FirstName, 47 | LastName = request.LastName, 48 | Email = request.Email 49 | }; 50 | 51 | _db.Add(user); 52 | 53 | var @event = new UserCreatedEvent 54 | { 55 | UserId = user.Id 56 | }; 57 | await _db.SaveChangesAndCommitAsync(cancellationToken, @event); 58 | 59 | var result = new Result 60 | { 61 | Id = user.Id 62 | }; 63 | 64 | return result; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /Components/UserComponents/Commands/DeleteUser.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.UserStatusRefs; 3 | using DataModel.Models.Users; 4 | using Events.UserEvents; 5 | using FluentValidation; 6 | using Infrastructure.CQRS.Commands; 7 | using Infrastructure.ExceptionHandling.Exceptions; 8 | using Infrastructure.StronglyTypedIds; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace Components.UserComponents.Commands; 12 | 13 | public class DeleteUser 14 | { 15 | public class Command : ICommand 16 | { 17 | public string UserId { get; init; } = null!; 18 | } 19 | 20 | public class DeleteUserValidator : AbstractValidator 21 | { 22 | public DeleteUserValidator() 23 | { 24 | RuleFor(command => command.UserId).IdMustBeValid(); 25 | } 26 | } 27 | 28 | public class Handler : ICommandHandler 29 | { 30 | private readonly DatabaseContext _db; 31 | 32 | public Handler(DatabaseContext db) 33 | { 34 | _db = db; 35 | } 36 | 37 | public async Task Handle(Command request, CancellationToken cancellationToken) 38 | { 39 | var userId = new UserId(request.UserId); 40 | var user = await GetUser(userId, cancellationToken); 41 | 42 | if (user is null) 43 | { 44 | throw new NotFoundException(nameof(user), userId.ToString()); 45 | } 46 | 47 | _db.Remove(user); 48 | var @event = new UserDeletedEvent 49 | { 50 | UserId = user.Id 51 | }; 52 | 53 | await _db.SaveChangesAndCommitAsync(cancellationToken, @event); 54 | } 55 | 56 | private async Task GetUser(UserId userId, CancellationToken cancellationToken) 57 | { 58 | var query = from user in _db.Users 59 | where user.Id == userId && user.StatusEnum == UserStatusEnum.Active 60 | select user; 61 | 62 | var result = await query.FirstOrDefaultAsync(cancellationToken); 63 | 64 | return result; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /Components/UserComponents/Queries/GetUser.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.UserStatusRefs; 3 | using DataModel.Models.Users; 4 | using FluentValidation; 5 | using Infrastructure.CQRS.Queries; 6 | using Infrastructure.ExceptionHandling.Exceptions; 7 | using Infrastructure.StronglyTypedIds; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace Components.UserComponents.Queries; 11 | 12 | public class GetUser 13 | { 14 | public class Query : IQuery 15 | { 16 | public UserId UserId { get; init; } = null!; 17 | } 18 | 19 | public class GetUserValidator : AbstractValidator 20 | { 21 | public GetUserValidator() 22 | { 23 | RuleFor(query => query.UserId).IdMustBeValid(); 24 | } 25 | } 26 | 27 | public class Result 28 | { 29 | public string FirstName { get; init; } = null!; 30 | public string LastName { get; init; } = null!; 31 | } 32 | 33 | public class Handler : IQueryHandler 34 | { 35 | private readonly DatabaseContext _db; 36 | 37 | public Handler(DatabaseContext db) 38 | { 39 | _db = db; 40 | } 41 | 42 | public async Task Handle(Query request, CancellationToken cancellationToken) 43 | { 44 | var result = await GetActiveUser(request.UserId, cancellationToken); 45 | 46 | if (result is null) 47 | { 48 | throw new NotFoundException("User", request.UserId); 49 | } 50 | 51 | return result; 52 | } 53 | 54 | private async Task GetActiveUser(UserId userId, CancellationToken cancellationToken) 55 | { 56 | var query = from user in _db.Users 57 | where user.Id == userId && user.StatusEnum == UserStatusEnum.Active 58 | select new Result 59 | { 60 | FirstName = user.FirstName, 61 | LastName = user.LastName 62 | }; 63 | 64 | var result = await query.FirstOrDefaultAsync(cancellationToken); 65 | 66 | return result; 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /Components/UserTaskComponents/Commands/CreateUserTask.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Users; 3 | using DataModel.Models.UserTasks; 4 | using Events.UserTaskEvents; 5 | using FluentValidation; 6 | using Infrastructure.CQRS.Commands; 7 | using Infrastructure.StronglyTypedIds; 8 | 9 | namespace Components.UserTaskComponents.Commands; 10 | 11 | public class CreateUserTask 12 | { 13 | public class Command : ICommand 14 | { 15 | public string Title { get; init; } = null!; 16 | public string Description { get; init; } = null!; 17 | public UserId UserId { get; init; } = null!; 18 | public UserId? AssignedUserId { get; init; } 19 | } 20 | 21 | public class CreateUserTaskValidator : AbstractValidator 22 | { 23 | public CreateUserTaskValidator() 24 | { 25 | RuleFor(command => command.Title).NotEmpty(); 26 | RuleFor(command => command.Description).NotEmpty(); 27 | RuleFor(command => command.UserId).IdMustBeValid(); 28 | RuleFor(command => command.AssignedUserId).OptionalIdMustBeValid(); 29 | } 30 | } 31 | 32 | public class Result 33 | { 34 | public UserTaskId Id { get; init; } = null!; 35 | } 36 | 37 | public class Handler : ICommandHandler 38 | { 39 | private readonly DatabaseContext _db; 40 | 41 | public Handler(DatabaseContext db) 42 | { 43 | _db = db; 44 | } 45 | 46 | public async Task Handle(Command request, CancellationToken cancellationToken) 47 | { 48 | var task = new UserTask 49 | { 50 | Title = request.Title, 51 | Description = request.Description, 52 | AssignedToUserId = request.AssignedUserId, 53 | CreatedByUserId = request.UserId 54 | }; 55 | 56 | _db.Add(task); 57 | var @event = new UserTaskCreatedEvent 58 | { 59 | UserTaskId = task.Id 60 | }; 61 | await _db.SaveChangesAndCommitAsync(cancellationToken, @event); 62 | 63 | var result = new Result 64 | { 65 | Id = task.Id 66 | }; 67 | 68 | return result; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /Components/UserTaskComponents/Commands/DeleteUserTask.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.TaskStatusRefs; 3 | using DataModel.Models.UserTasks; 4 | using Events.UserTaskEvents; 5 | using FluentValidation; 6 | using Infrastructure.CQRS.Commands; 7 | using Infrastructure.ExceptionHandling.Exceptions; 8 | using Infrastructure.StronglyTypedIds; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace Components.UserTaskComponents.Commands; 12 | 13 | public class DeleteUserTask 14 | { 15 | public class Command : ICommand 16 | { 17 | public string UserTaskId { get; init; } = null!; 18 | } 19 | 20 | public class DeleteTaskValidator : AbstractValidator 21 | { 22 | public DeleteTaskValidator() 23 | { 24 | RuleFor(command => command.UserTaskId).IdMustBeValid(); 25 | } 26 | } 27 | 28 | public class Handler : ICommandHandler 29 | { 30 | private readonly DatabaseContext _db; 31 | 32 | public Handler(DatabaseContext db) 33 | { 34 | _db = db; 35 | } 36 | 37 | public async Task Handle(Command request, CancellationToken cancellationToken) 38 | { 39 | var taskId = new UserTaskId(request.UserTaskId); 40 | var task = await GetTask(taskId, cancellationToken); 41 | 42 | if (task is null) 43 | { 44 | throw new NotFoundException(nameof(task), taskId.ToString()); 45 | } 46 | 47 | _db.Remove(task); 48 | var @event = new UserTaskDeletedEvent 49 | { 50 | UserTaskId = taskId 51 | }; 52 | await _db.SaveChangesAndCommitAsync(cancellationToken, @event); 53 | } 54 | 55 | private async Task GetTask(UserTaskId userTaskId, CancellationToken cancellationToken) 56 | { 57 | var query = from task in _db.UserTasks 58 | where task.Id == userTaskId && task.StatusEnum != TaskStatusEnum.Removed 59 | select task; 60 | 61 | var result = await query.FirstOrDefaultAsync(cancellationToken); 62 | 63 | return result; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /Components/UserTaskComponents/Commands/UpdateUserTask.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.TaskStatusRefs; 3 | using DataModel.Models.Users; 4 | using DataModel.Models.UserTasks; 5 | using Events.UserTaskEvents; 6 | using FluentValidation; 7 | using Infrastructure.CQRS.Commands; 8 | using Infrastructure.ExceptionHandling.Exceptions; 9 | using Infrastructure.StronglyTypedIds; 10 | using Microsoft.EntityFrameworkCore; 11 | 12 | namespace Components.UserTaskComponents.Commands; 13 | 14 | public class UpdateUserTask 15 | { 16 | public class Command : ICommand 17 | { 18 | public UserTaskId UserTaskId { get; init; } = null!; 19 | public UserId AssignToUserId { get; init; } = null!; 20 | } 21 | 22 | public class AssignTaskToUserValidator : AbstractValidator 23 | { 24 | public AssignTaskToUserValidator() 25 | { 26 | RuleFor(command => command.UserTaskId).IdMustBeValid(); 27 | RuleFor(command => command.AssignToUserId).IdMustBeValid(); 28 | } 29 | } 30 | 31 | public class Handler : ICommandHandler 32 | { 33 | private readonly DatabaseContext _db; 34 | 35 | public Handler(DatabaseContext db) 36 | { 37 | _db = db; 38 | } 39 | 40 | public async Task Handle(Command request, CancellationToken cancellationToken) 41 | { 42 | var task = await GetTask(request.UserTaskId, cancellationToken); 43 | 44 | if (task is null) 45 | { 46 | throw new NotFoundException(nameof(task), request.UserTaskId); 47 | } 48 | 49 | task.AssignedToUserId = request.AssignToUserId; 50 | 51 | _db.Update(task); 52 | var @event = new UserTaskUpdatedEvent 53 | { 54 | UserTaskId = task.Id 55 | }; 56 | await _db.SaveChangesAndCommitAsync(cancellationToken, @event); 57 | } 58 | 59 | private async Task GetTask(UserTaskId userTaskId, CancellationToken cancellationToken) 60 | { 61 | var query = from task in _db.UserTasks 62 | where task.Id == userTaskId && task.StatusEnum != TaskStatusEnum.Removed 63 | select task; 64 | 65 | var result = await query.FirstOrDefaultAsync(cancellationToken); 66 | 67 | return result; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Components/UserTaskComponents/Queries/GetUserTask.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.TaskStatusRefs; 3 | using DataModel.Models.Users; 4 | using DataModel.Models.UserTasks; 5 | using FluentValidation; 6 | using Infrastructure.CQRS.Queries; 7 | using Infrastructure.ExceptionHandling.Exceptions; 8 | using Infrastructure.StronglyTypedIds; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace Components.UserTaskComponents.Queries; 12 | 13 | public class GetUserTask 14 | { 15 | public class Query : IQuery 16 | { 17 | public UserTaskId UserTaskId { get; init; } = null!; 18 | } 19 | 20 | public class GetUserTaskValidator : AbstractValidator 21 | { 22 | public GetUserTaskValidator() 23 | { 24 | RuleFor(query => query.UserTaskId)!.IdMustBeValid(); 25 | } 26 | } 27 | 28 | public class Result 29 | { 30 | public string Title { get; init; } = null!; 31 | public string Description { get; init; } = null!; 32 | public UserId CreatedByUserId { get; init; } = null!; 33 | public UserId? AssignedToUserId { get; init; } 34 | } 35 | 36 | public class Handler : IQueryHandler 37 | { 38 | private readonly DatabaseContext _db; 39 | 40 | public Handler(DatabaseContext db) 41 | { 42 | _db = db; 43 | } 44 | 45 | public async Task Handle(Query request, CancellationToken cancellationToken) 46 | { 47 | var result = await GetAvailableTask(request.UserTaskId, cancellationToken); 48 | 49 | if (result is null) 50 | { 51 | throw new NotFoundException("User", request.UserTaskId); 52 | } 53 | 54 | return result; 55 | } 56 | 57 | private async Task GetAvailableTask(UserTaskId userTaskId, CancellationToken cancellationToken) 58 | { 59 | var query = from task in _db.UserTasks 60 | where task.Id == userTaskId && task.StatusEnum != TaskStatusEnum.Removed 61 | select new Result 62 | { 63 | Title = task.Title, 64 | Description = task.Description, 65 | AssignedToUserId = task.AssignedToUserId, 66 | CreatedByUserId = task.CreatedByUserId 67 | }; 68 | 69 | var result = await query.FirstOrDefaultAsync(cancellationToken); 70 | 71 | return result; 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /Components/UserTaskComponents/Queries/GetUserTasksCreatedByUser.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.Refs.TaskStatusRefs; 3 | using DataModel.Models.Users; 4 | using FluentValidation; 5 | using Infrastructure.CQRS.Queries; 6 | using Infrastructure.StronglyTypedIds; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace Components.UserTaskComponents.Queries; 10 | 11 | public class GetUserTasksCreatedByUser 12 | { 13 | public class Query : IQuery 14 | { 15 | public string UserId { get; init; } = null!; 16 | } 17 | 18 | public class GetUserTasksCreatedByUserValidator : AbstractValidator 19 | { 20 | public GetUserTasksCreatedByUserValidator() 21 | { 22 | RuleFor(query => query.UserId).IdMustBeValid(); 23 | } 24 | } 25 | 26 | public class Result 27 | { 28 | public ICollection UserTasks { get; init; } = null!; 29 | 30 | public class UserTaskResult 31 | { 32 | public string Title { get; init; } = null!; 33 | public string Description { get; init; } = null!; 34 | public UserId? AssignedToUserId { get; init; } 35 | } 36 | } 37 | 38 | public class Handler : IQueryHandler 39 | { 40 | private readonly DatabaseContext _db; 41 | 42 | public Handler(DatabaseContext db) 43 | { 44 | _db = db; 45 | } 46 | 47 | public async Task Handle(Query request, CancellationToken cancellationToken) 48 | { 49 | var userId = new UserId(request.UserId); 50 | var tasks = await GetAvailableTasksFromUser(userId, cancellationToken); 51 | 52 | var result = new Result 53 | { 54 | UserTasks = tasks 55 | }; 56 | 57 | return result; 58 | } 59 | 60 | private async Task> GetAvailableTasksFromUser(UserId userId, 61 | CancellationToken cancellationToken) 62 | { 63 | var query = from task in _db.UserTasks 64 | where task.CreatedByUserId == userId && task.StatusEnum != TaskStatusEnum.Removed 65 | select new Result.UserTaskResult 66 | { 67 | Title = task.Title, 68 | Description = task.Description, 69 | AssignedToUserId = task.AssignedToUserId 70 | }; 71 | 72 | var result = await query.ToListAsync(cancellationToken); 73 | 74 | return result; 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /DataModel/DataModel.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /DataModel/DataModelExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace DataModel; 5 | 6 | public static class DataModelExtensions 7 | { 8 | public static IServiceCollection AddDataModel(this IServiceCollection services, string connectionString) 9 | { 10 | services.AddDbContext( 11 | options => { options.UseSqlServer(connectionString); }); 12 | 13 | return services; 14 | } 15 | } -------------------------------------------------------------------------------- /DataModel/DataModelOptions.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel; 2 | 3 | public class DataModelOptions 4 | { 5 | public static readonly string ConnectionString = "DataModelConnectionString"; 6 | } -------------------------------------------------------------------------------- /DataModel/DatabaseContext.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Refs.TaskStatusRefs; 2 | using DataModel.Models.Refs.UserStatusRefs; 3 | using DataModel.Models.Users; 4 | using DataModel.Models.UserTasks; 5 | using Infrastructure.CQRS.Events; 6 | using Infrastructure.StronglyTypedIds; 7 | using Microsoft.EntityFrameworkCore; 8 | 9 | namespace DataModel; 10 | 11 | public class DatabaseContext : DbContext 12 | { 13 | private readonly IEventBus? _eventBus; 14 | 15 | public DatabaseContext(DbContextOptions options, IEventBus? eventBus = null) : base(options) 16 | { 17 | _eventBus = eventBus; 18 | } 19 | 20 | public DbSet Users { get; set; } 21 | public DbSet UserTasks { get; set; } 22 | 23 | protected override void OnModelCreating(ModelBuilder builder) 24 | { 25 | base.OnModelCreating(builder); 26 | 27 | UserContext.Build(builder); 28 | UserTaskContext.Build(builder); 29 | 30 | // Refs 31 | UserStatusRefContext.Build(builder); 32 | TaskStatusRefContext.Build(builder); 33 | 34 | // Add strongly typed id ef core conversions 35 | builder.AddStronglyTypedIdConversions(); 36 | } 37 | 38 | public async Task SaveChangesAndCommitAsync(CancellationToken cancellationToken = default, 39 | params IEvent[] events) 40 | { 41 | await using var transaction = await Database.BeginTransactionAsync(cancellationToken); 42 | var result = await SaveChangesAsync(cancellationToken); 43 | if (_eventBus is not null) 44 | { 45 | await _eventBus.Commit(events); 46 | } 47 | 48 | await transaction.CommitAsync(cancellationToken); 49 | 50 | return result; 51 | } 52 | } -------------------------------------------------------------------------------- /DataModel/DatabaseContextFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace DataModel; 6 | 7 | public class DatabaseContextFactory : IDesignTimeDbContextFactory 8 | { 9 | public DatabaseContext CreateDbContext(string[] args) 10 | { 11 | var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); 12 | var configuration = new ConfigurationBuilder() 13 | .SetBasePath(Directory.GetCurrentDirectory()) 14 | .AddJsonFile(Directory.GetCurrentDirectory() + "/../Api/appsettings.json") 15 | .AddJsonFile(Directory.GetCurrentDirectory() + $"/../Api/appsettings.{environment}.json", true) 16 | .Build(); 17 | var builder = new DbContextOptionsBuilder(); 18 | 19 | var connectionString = configuration.GetConnectionString(DataModelOptions.ConnectionString) ?? 20 | throw new Exception( 21 | $"{DataModelOptions.ConnectionString} is not found in configuration"); 22 | 23 | builder.UseSqlServer(connectionString); 24 | 25 | return new DatabaseContext(builder.Options); 26 | } 27 | } -------------------------------------------------------------------------------- /DataModel/Migrations/20230905193848_Init.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using DataModel; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Metadata; 7 | using Microsoft.EntityFrameworkCore.Migrations; 8 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 9 | 10 | #nullable disable 11 | 12 | namespace DataModel.Migrations 13 | { 14 | [DbContext(typeof(DatabaseContext))] 15 | [Migration("20230905193848_Init")] 16 | partial class Init 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.10") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 128); 25 | 26 | SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("DataModel.Models.Refs.TaskStatusRefs.TaskStatusRef", b => 29 | { 30 | b.Property("Id") 31 | .HasColumnType("int"); 32 | 33 | b.Property("Name") 34 | .IsRequired() 35 | .HasColumnType("nvarchar(450)"); 36 | 37 | b.HasKey("Id"); 38 | 39 | b.HasIndex("Name") 40 | .IsUnique(); 41 | 42 | b.ToTable("TaskStatusRef"); 43 | 44 | b.HasData( 45 | new 46 | { 47 | Id = 1, 48 | Name = "Created" 49 | }, 50 | new 51 | { 52 | Id = 2, 53 | Name = "Active" 54 | }, 55 | new 56 | { 57 | Id = 3, 58 | Name = "Completed" 59 | }, 60 | new 61 | { 62 | Id = 4, 63 | Name = "Removed" 64 | }); 65 | }); 66 | 67 | modelBuilder.Entity("DataModel.Models.Refs.UserStatusRefs.UserStatusRef", b => 68 | { 69 | b.Property("Id") 70 | .HasColumnType("int"); 71 | 72 | b.Property("Name") 73 | .IsRequired() 74 | .HasColumnType("nvarchar(450)"); 75 | 76 | b.HasKey("Id"); 77 | 78 | b.HasIndex("Name") 79 | .IsUnique(); 80 | 81 | b.ToTable("UserStatusRef"); 82 | 83 | b.HasData( 84 | new 85 | { 86 | Id = 1, 87 | Name = "Active" 88 | }, 89 | new 90 | { 91 | Id = 2, 92 | Name = "Removed" 93 | }); 94 | }); 95 | 96 | modelBuilder.Entity("DataModel.Models.UserTasks.UserTask", b => 97 | { 98 | b.Property("Id") 99 | .ValueGeneratedOnAdd() 100 | .HasColumnType("nvarchar(450)"); 101 | 102 | b.Property("AssignedToUserId") 103 | .HasColumnType("nvarchar(450)"); 104 | 105 | b.Property("CreatedByUserId") 106 | .IsRequired() 107 | .HasColumnType("nvarchar(450)"); 108 | 109 | b.Property("CreatedUtc") 110 | .ValueGeneratedOnAdd() 111 | .HasColumnType("datetime2"); 112 | 113 | b.Property("Description") 114 | .IsRequired() 115 | .HasColumnType("nvarchar(max)"); 116 | 117 | b.Property("StatusEnum") 118 | .HasColumnType("int"); 119 | 120 | b.Property("Title") 121 | .IsRequired() 122 | .HasColumnType("nvarchar(max)"); 123 | 124 | b.HasKey("Id"); 125 | 126 | b.HasIndex("AssignedToUserId"); 127 | 128 | b.HasIndex("CreatedByUserId"); 129 | 130 | b.HasIndex("StatusEnum"); 131 | 132 | b.ToTable("UserTasks"); 133 | }); 134 | 135 | modelBuilder.Entity("DataModel.Models.Users.User", b => 136 | { 137 | b.Property("Id") 138 | .ValueGeneratedOnAdd() 139 | .HasColumnType("nvarchar(450)"); 140 | 141 | b.Property("CreatedUtc") 142 | .ValueGeneratedOnAdd() 143 | .HasColumnType("datetime2"); 144 | 145 | b.Property("Email") 146 | .IsRequired() 147 | .HasColumnType("nvarchar(max)"); 148 | 149 | b.Property("FirstName") 150 | .IsRequired() 151 | .HasColumnType("nvarchar(max)"); 152 | 153 | b.Property("LastName") 154 | .IsRequired() 155 | .HasColumnType("nvarchar(max)"); 156 | 157 | b.Property("StatusEnum") 158 | .HasColumnType("int"); 159 | 160 | b.HasKey("Id"); 161 | 162 | b.HasIndex("StatusEnum"); 163 | 164 | b.ToTable("Users"); 165 | }); 166 | 167 | modelBuilder.Entity("DataModel.Models.UserTasks.UserTask", b => 168 | { 169 | b.HasOne("DataModel.Models.Users.User", "AssignedToUser") 170 | .WithMany("AssignedToUserTasks") 171 | .HasForeignKey("AssignedToUserId") 172 | .OnDelete(DeleteBehavior.Restrict); 173 | 174 | b.HasOne("DataModel.Models.Users.User", "CreatedByUser") 175 | .WithMany("CreatedUserTasks") 176 | .HasForeignKey("CreatedByUserId") 177 | .OnDelete(DeleteBehavior.Restrict) 178 | .IsRequired(); 179 | 180 | b.HasOne("DataModel.Models.Refs.TaskStatusRefs.TaskStatusRef", "StatusRef") 181 | .WithMany() 182 | .HasForeignKey("StatusEnum") 183 | .OnDelete(DeleteBehavior.Restrict) 184 | .IsRequired(); 185 | 186 | b.Navigation("AssignedToUser"); 187 | 188 | b.Navigation("CreatedByUser"); 189 | 190 | b.Navigation("StatusRef"); 191 | }); 192 | 193 | modelBuilder.Entity("DataModel.Models.Users.User", b => 194 | { 195 | b.HasOne("DataModel.Models.Refs.UserStatusRefs.UserStatusRef", "StatusRef") 196 | .WithMany() 197 | .HasForeignKey("StatusEnum") 198 | .OnDelete(DeleteBehavior.Restrict) 199 | .IsRequired(); 200 | 201 | b.Navigation("StatusRef"); 202 | }); 203 | 204 | modelBuilder.Entity("DataModel.Models.Users.User", b => 205 | { 206 | b.Navigation("AssignedToUserTasks"); 207 | 208 | b.Navigation("CreatedUserTasks"); 209 | }); 210 | #pragma warning restore 612, 618 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /DataModel/Migrations/20230905193848_Init.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 7 | 8 | namespace DataModel.Migrations 9 | { 10 | /// 11 | public partial class Init : Migration 12 | { 13 | /// 14 | protected override void Up(MigrationBuilder migrationBuilder) 15 | { 16 | migrationBuilder.CreateTable( 17 | name: "TaskStatusRef", 18 | columns: table => new 19 | { 20 | Id = table.Column(type: "int", nullable: false), 21 | Name = table.Column(type: "nvarchar(450)", nullable: false) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_TaskStatusRef", x => x.Id); 26 | }); 27 | 28 | migrationBuilder.CreateTable( 29 | name: "UserStatusRef", 30 | columns: table => new 31 | { 32 | Id = table.Column(type: "int", nullable: false), 33 | Name = table.Column(type: "nvarchar(450)", nullable: false) 34 | }, 35 | constraints: table => 36 | { 37 | table.PrimaryKey("PK_UserStatusRef", x => x.Id); 38 | }); 39 | 40 | migrationBuilder.CreateTable( 41 | name: "Users", 42 | columns: table => new 43 | { 44 | Id = table.Column(type: "nvarchar(450)", nullable: false), 45 | FirstName = table.Column(type: "nvarchar(max)", nullable: false), 46 | LastName = table.Column(type: "nvarchar(max)", nullable: false), 47 | Email = table.Column(type: "nvarchar(max)", nullable: false), 48 | StatusEnum = table.Column(type: "int", nullable: false), 49 | CreatedUtc = table.Column(type: "datetime2", nullable: false) 50 | }, 51 | constraints: table => 52 | { 53 | table.PrimaryKey("PK_Users", x => x.Id); 54 | table.ForeignKey( 55 | name: "FK_Users_UserStatusRef_StatusEnum", 56 | column: x => x.StatusEnum, 57 | principalTable: "UserStatusRef", 58 | principalColumn: "Id", 59 | onDelete: ReferentialAction.Restrict); 60 | }); 61 | 62 | migrationBuilder.CreateTable( 63 | name: "UserTasks", 64 | columns: table => new 65 | { 66 | Id = table.Column(type: "nvarchar(450)", nullable: false), 67 | Title = table.Column(type: "nvarchar(max)", nullable: false), 68 | Description = table.Column(type: "nvarchar(max)", nullable: false), 69 | StatusEnum = table.Column(type: "int", nullable: false), 70 | CreatedByUserId = table.Column(type: "nvarchar(450)", nullable: false), 71 | AssignedToUserId = table.Column(type: "nvarchar(450)", nullable: true), 72 | CreatedUtc = table.Column(type: "datetime2", nullable: false) 73 | }, 74 | constraints: table => 75 | { 76 | table.PrimaryKey("PK_UserTasks", x => x.Id); 77 | table.ForeignKey( 78 | name: "FK_UserTasks_TaskStatusRef_StatusEnum", 79 | column: x => x.StatusEnum, 80 | principalTable: "TaskStatusRef", 81 | principalColumn: "Id", 82 | onDelete: ReferentialAction.Restrict); 83 | table.ForeignKey( 84 | name: "FK_UserTasks_Users_AssignedToUserId", 85 | column: x => x.AssignedToUserId, 86 | principalTable: "Users", 87 | principalColumn: "Id", 88 | onDelete: ReferentialAction.Restrict); 89 | table.ForeignKey( 90 | name: "FK_UserTasks_Users_CreatedByUserId", 91 | column: x => x.CreatedByUserId, 92 | principalTable: "Users", 93 | principalColumn: "Id", 94 | onDelete: ReferentialAction.Restrict); 95 | }); 96 | 97 | migrationBuilder.InsertData( 98 | table: "TaskStatusRef", 99 | columns: new[] { "Id", "Name" }, 100 | values: new object[,] 101 | { 102 | { 1, "Created" }, 103 | { 2, "Active" }, 104 | { 3, "Completed" }, 105 | { 4, "Removed" } 106 | }); 107 | 108 | migrationBuilder.InsertData( 109 | table: "UserStatusRef", 110 | columns: new[] { "Id", "Name" }, 111 | values: new object[,] 112 | { 113 | { 1, "Active" }, 114 | { 2, "Removed" } 115 | }); 116 | 117 | migrationBuilder.CreateIndex( 118 | name: "IX_TaskStatusRef_Name", 119 | table: "TaskStatusRef", 120 | column: "Name", 121 | unique: true); 122 | 123 | migrationBuilder.CreateIndex( 124 | name: "IX_Users_StatusEnum", 125 | table: "Users", 126 | column: "StatusEnum"); 127 | 128 | migrationBuilder.CreateIndex( 129 | name: "IX_UserStatusRef_Name", 130 | table: "UserStatusRef", 131 | column: "Name", 132 | unique: true); 133 | 134 | migrationBuilder.CreateIndex( 135 | name: "IX_UserTasks_AssignedToUserId", 136 | table: "UserTasks", 137 | column: "AssignedToUserId"); 138 | 139 | migrationBuilder.CreateIndex( 140 | name: "IX_UserTasks_CreatedByUserId", 141 | table: "UserTasks", 142 | column: "CreatedByUserId"); 143 | 144 | migrationBuilder.CreateIndex( 145 | name: "IX_UserTasks_StatusEnum", 146 | table: "UserTasks", 147 | column: "StatusEnum"); 148 | } 149 | 150 | /// 151 | protected override void Down(MigrationBuilder migrationBuilder) 152 | { 153 | migrationBuilder.DropTable( 154 | name: "UserTasks"); 155 | 156 | migrationBuilder.DropTable( 157 | name: "TaskStatusRef"); 158 | 159 | migrationBuilder.DropTable( 160 | name: "Users"); 161 | 162 | migrationBuilder.DropTable( 163 | name: "UserStatusRef"); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /DataModel/Migrations/DatabaseContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using DataModel; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Metadata; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace DataModel.Migrations 12 | { 13 | [DbContext(typeof(DatabaseContext))] 14 | partial class DatabaseContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.10") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 128); 22 | 23 | SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("DataModel.Models.Refs.TaskStatusRefs.TaskStatusRef", b => 26 | { 27 | b.Property("Id") 28 | .HasColumnType("int"); 29 | 30 | b.Property("Name") 31 | .IsRequired() 32 | .HasColumnType("nvarchar(450)"); 33 | 34 | b.HasKey("Id"); 35 | 36 | b.HasIndex("Name") 37 | .IsUnique(); 38 | 39 | b.ToTable("TaskStatusRef"); 40 | 41 | b.HasData( 42 | new 43 | { 44 | Id = 1, 45 | Name = "Created" 46 | }, 47 | new 48 | { 49 | Id = 2, 50 | Name = "Active" 51 | }, 52 | new 53 | { 54 | Id = 3, 55 | Name = "Completed" 56 | }, 57 | new 58 | { 59 | Id = 4, 60 | Name = "Removed" 61 | }); 62 | }); 63 | 64 | modelBuilder.Entity("DataModel.Models.Refs.UserStatusRefs.UserStatusRef", b => 65 | { 66 | b.Property("Id") 67 | .HasColumnType("int"); 68 | 69 | b.Property("Name") 70 | .IsRequired() 71 | .HasColumnType("nvarchar(450)"); 72 | 73 | b.HasKey("Id"); 74 | 75 | b.HasIndex("Name") 76 | .IsUnique(); 77 | 78 | b.ToTable("UserStatusRef"); 79 | 80 | b.HasData( 81 | new 82 | { 83 | Id = 1, 84 | Name = "Active" 85 | }, 86 | new 87 | { 88 | Id = 2, 89 | Name = "Removed" 90 | }); 91 | }); 92 | 93 | modelBuilder.Entity("DataModel.Models.UserTasks.UserTask", b => 94 | { 95 | b.Property("Id") 96 | .ValueGeneratedOnAdd() 97 | .HasColumnType("nvarchar(450)"); 98 | 99 | b.Property("AssignedToUserId") 100 | .HasColumnType("nvarchar(450)"); 101 | 102 | b.Property("CreatedByUserId") 103 | .IsRequired() 104 | .HasColumnType("nvarchar(450)"); 105 | 106 | b.Property("CreatedUtc") 107 | .ValueGeneratedOnAdd() 108 | .HasColumnType("datetime2"); 109 | 110 | b.Property("Description") 111 | .IsRequired() 112 | .HasColumnType("nvarchar(max)"); 113 | 114 | b.Property("StatusEnum") 115 | .HasColumnType("int"); 116 | 117 | b.Property("Title") 118 | .IsRequired() 119 | .HasColumnType("nvarchar(max)"); 120 | 121 | b.HasKey("Id"); 122 | 123 | b.HasIndex("AssignedToUserId"); 124 | 125 | b.HasIndex("CreatedByUserId"); 126 | 127 | b.HasIndex("StatusEnum"); 128 | 129 | b.ToTable("UserTasks"); 130 | }); 131 | 132 | modelBuilder.Entity("DataModel.Models.Users.User", b => 133 | { 134 | b.Property("Id") 135 | .ValueGeneratedOnAdd() 136 | .HasColumnType("nvarchar(450)"); 137 | 138 | b.Property("CreatedUtc") 139 | .ValueGeneratedOnAdd() 140 | .HasColumnType("datetime2"); 141 | 142 | b.Property("Email") 143 | .IsRequired() 144 | .HasColumnType("nvarchar(max)"); 145 | 146 | b.Property("FirstName") 147 | .IsRequired() 148 | .HasColumnType("nvarchar(max)"); 149 | 150 | b.Property("LastName") 151 | .IsRequired() 152 | .HasColumnType("nvarchar(max)"); 153 | 154 | b.Property("StatusEnum") 155 | .HasColumnType("int"); 156 | 157 | b.HasKey("Id"); 158 | 159 | b.HasIndex("StatusEnum"); 160 | 161 | b.ToTable("Users"); 162 | }); 163 | 164 | modelBuilder.Entity("DataModel.Models.UserTasks.UserTask", b => 165 | { 166 | b.HasOne("DataModel.Models.Users.User", "AssignedToUser") 167 | .WithMany("AssignedToUserTasks") 168 | .HasForeignKey("AssignedToUserId") 169 | .OnDelete(DeleteBehavior.Restrict); 170 | 171 | b.HasOne("DataModel.Models.Users.User", "CreatedByUser") 172 | .WithMany("CreatedUserTasks") 173 | .HasForeignKey("CreatedByUserId") 174 | .OnDelete(DeleteBehavior.Restrict) 175 | .IsRequired(); 176 | 177 | b.HasOne("DataModel.Models.Refs.TaskStatusRefs.TaskStatusRef", "StatusRef") 178 | .WithMany() 179 | .HasForeignKey("StatusEnum") 180 | .OnDelete(DeleteBehavior.Restrict) 181 | .IsRequired(); 182 | 183 | b.Navigation("AssignedToUser"); 184 | 185 | b.Navigation("CreatedByUser"); 186 | 187 | b.Navigation("StatusRef"); 188 | }); 189 | 190 | modelBuilder.Entity("DataModel.Models.Users.User", b => 191 | { 192 | b.HasOne("DataModel.Models.Refs.UserStatusRefs.UserStatusRef", "StatusRef") 193 | .WithMany() 194 | .HasForeignKey("StatusEnum") 195 | .OnDelete(DeleteBehavior.Restrict) 196 | .IsRequired(); 197 | 198 | b.Navigation("StatusRef"); 199 | }); 200 | 201 | modelBuilder.Entity("DataModel.Models.Users.User", b => 202 | { 203 | b.Navigation("AssignedToUserTasks"); 204 | 205 | b.Navigation("CreatedUserTasks"); 206 | }); 207 | #pragma warning restore 612, 618 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /DataModel/Models/BaseModel.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.StronglyTypedIds; 2 | 3 | namespace DataModel.Models; 4 | 5 | /// 6 | /// Data base model to include default columns such as Id and CreatedUtc 7 | /// 8 | /// Strongly typed id type 9 | public abstract class BaseModel where TId : StronglyTypedIdBaseEntity 10 | { 11 | public TId Id { get; } = StronglyTypedIdBaseEntity.New(); 12 | 13 | public DateTime CreatedUtc { get; } = DateTime.UtcNow; 14 | } -------------------------------------------------------------------------------- /DataModel/Models/BaseModelEnum.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel.Models; 2 | 3 | public abstract class BaseModelEnum where TEnum : Enum 4 | { 5 | protected BaseModelEnum() 6 | { 7 | } 8 | 9 | protected BaseModelEnum(TEnum baseModelEnum) 10 | { 11 | Id = baseModelEnum; 12 | Name = baseModelEnum.ToString(); 13 | } 14 | 15 | public TEnum Id { get; } = default!; 16 | public string Name { get; } = null!; 17 | } -------------------------------------------------------------------------------- /DataModel/Models/BaseModelEnumExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 2 | 3 | namespace DataModel.Models; 4 | 5 | public static class BaseModelEnumExtensions 6 | { 7 | public static void AddBaseModelEnumExtensions(this EntityTypeBuilder builder) 8 | where T : BaseModelEnum 9 | where TEnum : Enum 10 | { 11 | builder.Property(p => p.Id) 12 | .IsRequired(); 13 | 14 | builder.Property(p => p.Name) 15 | .IsRequired(); 16 | 17 | builder.HasIndex(k => k.Name) 18 | .IsUnique(); 19 | 20 | builder.HasKey(k => k.Id); 21 | 22 | builder.HasData(Enum.GetValues(typeof(TEnum)).Cast() 23 | .Select(baseModelEnum => (T) Activator.CreateInstance(typeof(T), baseModelEnum)!).ToArray()); 24 | } 25 | } -------------------------------------------------------------------------------- /DataModel/Models/BaseModelExtensions.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.StronglyTypedIds; 2 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 3 | 4 | namespace DataModel.Models; 5 | 6 | public static class BaseModelExtensions 7 | { 8 | public static void AddBaseModelContext(this EntityTypeBuilder builder) 9 | where T : BaseModel where TId : StronglyTypedIdBaseEntity 10 | { 11 | builder.Property(p => p.Id) 12 | .IsRequired() 13 | .ValueGeneratedOnAdd(); 14 | 15 | builder.Property(p => p.CreatedUtc) 16 | .IsRequired() 17 | .ValueGeneratedOnAdd(); 18 | 19 | builder.HasKey(k => k.Id); 20 | } 21 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/TaskStatusRefs/TaskStatusEnum.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel.Models.Refs.TaskStatusRefs; 2 | 3 | public enum TaskStatusEnum 4 | { 5 | Created = 1, 6 | Active = 2, 7 | Completed = 3, 8 | Removed = 4 9 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/TaskStatusRefs/TaskStatusRef.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel.Models.Refs.TaskStatusRefs; 2 | 3 | public class TaskStatusRef : BaseModelEnum 4 | { 5 | public TaskStatusRef() 6 | { 7 | } 8 | 9 | public TaskStatusRef(TaskStatusEnum baseModelEnum) : base(baseModelEnum) 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/TaskStatusRefs/TaskStatusRefContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace DataModel.Models.Refs.TaskStatusRefs; 4 | 5 | public class TaskStatusRefContext 6 | { 7 | public static void Build(ModelBuilder builder) 8 | { 9 | builder.Entity(b => { b.AddBaseModelEnumExtensions(); }); 10 | } 11 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/UserStatusRefs/UserStatusEnum.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel.Models.Refs.UserStatusRefs; 2 | 3 | public enum UserStatusEnum 4 | { 5 | Active = 1, 6 | Removed = 2 7 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/UserStatusRefs/UserStatusRef.cs: -------------------------------------------------------------------------------- 1 | namespace DataModel.Models.Refs.UserStatusRefs; 2 | 3 | public class UserStatusRef : BaseModelEnum 4 | { 5 | public UserStatusRef() 6 | { 7 | } 8 | 9 | public UserStatusRef(UserStatusEnum baseModelEnum) : base(baseModelEnum) 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /DataModel/Models/Refs/UserStatusRefs/UserStatusRefContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace DataModel.Models.Refs.UserStatusRefs; 4 | 5 | public class UserStatusRefContext 6 | { 7 | public static void Build(ModelBuilder builder) 8 | { 9 | builder.Entity(b => { b.AddBaseModelEnumExtensions(); }); 10 | } 11 | } -------------------------------------------------------------------------------- /DataModel/Models/UserTasks/UserTask.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Refs.TaskStatusRefs; 2 | using DataModel.Models.Users; 3 | using Infrastructure.StronglyTypedIds; 4 | 5 | namespace DataModel.Models.UserTasks; 6 | 7 | public record UserTaskId(string Value) : StronglyTypedIdBaseEntity("ut_", Value); 8 | 9 | public class UserTask : BaseModel 10 | { 11 | public string Title { get; set; } = null!; 12 | public string Description { get; set; } = null!; 13 | 14 | public TaskStatusRef StatusRef { get; set; } = null!; 15 | public TaskStatusEnum StatusEnum { get; set; } = TaskStatusEnum.Created; 16 | 17 | public User CreatedByUser { get; set; } = null!; 18 | public UserId CreatedByUserId { get; set; } = null!; 19 | 20 | public User? AssignedToUser { get; set; } 21 | public UserId? AssignedToUserId { get; set; } 22 | } -------------------------------------------------------------------------------- /DataModel/Models/UserTasks/UserTaskContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace DataModel.Models.UserTasks; 4 | 5 | public class UserTaskContext 6 | { 7 | public static void Build(ModelBuilder builder) 8 | { 9 | builder.Entity(b => 10 | { 11 | b.AddBaseModelContext(); 12 | 13 | b.Property(p => p.Title); 14 | b.Property(p => p.Description); 15 | 16 | b.HasOne(task => task.StatusRef) 17 | .WithMany() 18 | .HasForeignKey(task => task.StatusEnum) 19 | .OnDelete(DeleteBehavior.Restrict) 20 | .IsRequired(); 21 | 22 | b.HasOne(task => task.CreatedByUser) 23 | .WithMany(user => user.CreatedUserTasks) 24 | .HasForeignKey(task => task.CreatedByUserId) 25 | .OnDelete(DeleteBehavior.Restrict) 26 | .IsRequired(); 27 | 28 | b.HasOne(task => task.AssignedToUser) 29 | .WithMany(user => user.AssignedToUserTasks) 30 | .HasForeignKey(task => task.AssignedToUserId) 31 | .OnDelete(DeleteBehavior.Restrict); 32 | }); 33 | } 34 | } -------------------------------------------------------------------------------- /DataModel/Models/Users/User.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Refs.UserStatusRefs; 2 | using DataModel.Models.UserTasks; 3 | using Infrastructure.StronglyTypedIds; 4 | 5 | namespace DataModel.Models.Users; 6 | 7 | public record UserId(string Value) : StronglyTypedIdBaseEntity("us_", Value); 8 | 9 | public class User : BaseModel 10 | { 11 | public string FirstName { get; set; } = null!; 12 | public string LastName { get; set; } = null!; 13 | public string Email { get; set; } = null!; 14 | 15 | public UserStatusRef StatusRef { get; set; } = null!; 16 | public UserStatusEnum StatusEnum { get; set; } = UserStatusEnum.Active; 17 | 18 | public ICollection CreatedUserTasks { get; set; } = null!; 19 | public ICollection AssignedToUserTasks { get; set; } = null!; 20 | } -------------------------------------------------------------------------------- /DataModel/Models/Users/UserContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace DataModel.Models.Users; 4 | 5 | public class UserContext 6 | { 7 | public static void Build(ModelBuilder builder) 8 | { 9 | builder.Entity(b => 10 | { 11 | b.AddBaseModelContext(); 12 | b.Property(p => p.FirstName); 13 | b.Property(p => p.LastName); 14 | b.Property(p => p.Email); 15 | 16 | b.HasOne(user => user.StatusRef) 17 | .WithMany() 18 | .HasForeignKey(user => user.StatusEnum) 19 | .OnDelete(DeleteBehavior.Restrict) 20 | .IsRequired(); 21 | }); 22 | } 23 | } -------------------------------------------------------------------------------- /EventHandlers.UnitTests/BaseUnitTest.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Diagnostics; 4 | 5 | namespace EventHandlers.UnitTests; 6 | 7 | public abstract class BaseUnitTest 8 | { 9 | protected readonly DbContextOptions DbContextOptions; 10 | 11 | public BaseUnitTest() 12 | { 13 | DbContextOptions = new DbContextOptionsBuilder() 14 | .UseInMemoryDatabase(Guid.NewGuid().ToString()) 15 | .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning)) 16 | .Options; 17 | } 18 | } -------------------------------------------------------------------------------- /EventHandlers.UnitTests/EventHandlers.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | all 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /EventHandlers.UnitTests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /EventHandlers.UnitTests/UserTaskEventHandlers/NotifyAssignedUserEventHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Bogus; 2 | using DataModel; 3 | using DataModel.Models.Refs.UserStatusRefs; 4 | using DataModel.Models.Users; 5 | using DataModel.Models.UserTasks; 6 | using EventHandlers.UserTaskEventHandlers; 7 | using Events.UserTaskEvents; 8 | using NotificationService; 9 | using NSubstitute; 10 | 11 | namespace EventHandlers.UnitTests.UserTaskEventHandlers; 12 | 13 | public class NotifyAssignedUserEventHandlerTests : BaseUnitTest 14 | { 15 | [Fact] 16 | public async Task Handle_ShouldSendEmail_When_AssignedUserIsDifferentFromCreatedByUser() 17 | { 18 | var notificationServiceMock = Substitute.For(); 19 | var userTask = new Faker() 20 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 21 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 22 | .RuleFor(userTask => userTask.AssignedToUser, GetUser()) 23 | .RuleFor(userTask => userTask.CreatedByUser, GetUser()) 24 | .Generate(); 25 | var @event = new UserTaskCreatedEvent 26 | { 27 | UserTaskId = userTask.Id 28 | }; 29 | 30 | using var db = new DatabaseContext(DbContextOptions); 31 | db.UserTasks.Add(userTask); 32 | db.SaveChanges(); 33 | 34 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 35 | 36 | // Act 37 | await handler.Handle(@event, CancellationToken.None); 38 | 39 | // Assert 40 | await notificationServiceMock.Received(1).SendEmail( 41 | Arg.Any(), 42 | Arg.Any(), 43 | Arg.Any() 44 | ); 45 | } 46 | 47 | [Fact] 48 | public async Task Handle_ShouldAddTitle_When_SendingEmail() 49 | { 50 | var notificationServiceMock = Substitute.For(); 51 | var userTask = new Faker() 52 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 53 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 54 | .RuleFor(userTask => userTask.AssignedToUser, GetUser()) 55 | .RuleFor(userTask => userTask.CreatedByUser, GetUser()) 56 | .Generate(); 57 | 58 | var expectedTitle = $"New Task: {userTask.Title}"; 59 | 60 | var @event = new UserTaskCreatedEvent 61 | { 62 | UserTaskId = userTask.Id 63 | }; 64 | 65 | using var db = new DatabaseContext(DbContextOptions); 66 | db.UserTasks.Add(userTask); 67 | db.SaveChanges(); 68 | 69 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 70 | 71 | // Act 72 | await handler.Handle(@event, CancellationToken.None); 73 | 74 | // Assert 75 | await notificationServiceMock.Received(1).SendEmail( 76 | expectedTitle, 77 | Arg.Any(), 78 | Arg.Any() 79 | ); 80 | } 81 | 82 | [Fact] 83 | public async Task Handle_ShouldAddMessage_When_SendingEmail() 84 | { 85 | var notificationServiceMock = Substitute.For(); 86 | var userTask = new Faker() 87 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 88 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 89 | .RuleFor(userTask => userTask.AssignedToUser, GetUser()) 90 | .RuleFor(userTask => userTask.CreatedByUser, GetUser()) 91 | .Generate(); 92 | 93 | var expectedMessage = $"{userTask.CreatedByUser.FirstName} has assigned you the task '{userTask.Title}'"; 94 | 95 | var @event = new UserTaskCreatedEvent 96 | { 97 | UserTaskId = userTask.Id 98 | }; 99 | 100 | using var db = new DatabaseContext(DbContextOptions); 101 | db.UserTasks.Add(userTask); 102 | db.SaveChanges(); 103 | 104 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 105 | 106 | // Act 107 | await handler.Handle(@event, CancellationToken.None); 108 | 109 | // Assert 110 | await notificationServiceMock.Received(1).SendEmail( 111 | Arg.Any(), 112 | expectedMessage, 113 | Arg.Any() 114 | ); 115 | } 116 | 117 | [Fact] 118 | public async Task Handle_ShouldSendToUserEmail_When_SendingEmail() 119 | { 120 | var notificationServiceMock = Substitute.For(); 121 | var userTask = new Faker() 122 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 123 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 124 | .RuleFor(userTask => userTask.AssignedToUser, GetUser()) 125 | .RuleFor(userTask => userTask.CreatedByUser, GetUser()) 126 | .Generate(); 127 | 128 | var expectedEmail = userTask.AssignedToUser!.Email; 129 | 130 | var @event = new UserTaskCreatedEvent 131 | { 132 | UserTaskId = userTask.Id 133 | }; 134 | 135 | using var db = new DatabaseContext(DbContextOptions); 136 | db.UserTasks.Add(userTask); 137 | db.SaveChanges(); 138 | 139 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 140 | 141 | // Act 142 | await handler.Handle(@event, CancellationToken.None); 143 | 144 | // Assert 145 | await notificationServiceMock.Received(1).SendEmail( 146 | Arg.Any(), 147 | Arg.Any(), 148 | expectedEmail 149 | ); 150 | } 151 | 152 | [Fact] 153 | public async Task Handle_ShouldNotSendEmail_When_UserTaskHasNoAssignedUser() 154 | { 155 | var notificationServiceMock = Substitute.For(); 156 | var userTask = new Faker() 157 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 158 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 159 | .RuleFor(userTask => userTask.CreatedByUser, GetUser()) 160 | .Generate(); 161 | 162 | var @event = new UserTaskCreatedEvent 163 | { 164 | UserTaskId = userTask.Id 165 | }; 166 | 167 | using var db = new DatabaseContext(DbContextOptions); 168 | db.UserTasks.Add(userTask); 169 | db.SaveChanges(); 170 | 171 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 172 | 173 | // Act 174 | await handler.Handle(@event, CancellationToken.None); 175 | 176 | // Assert 177 | await notificationServiceMock.DidNotReceive().SendEmail( 178 | Arg.Any(), 179 | Arg.Any(), 180 | Arg.Any() 181 | ); 182 | } 183 | 184 | [Fact] 185 | public async Task Handle_ShouldNotSendEmail_When_AssignedUserIsTheSameAsCreatedUser() 186 | { 187 | var notificationServiceMock = Substitute.For(); 188 | var user = GetUser(); 189 | var userTask = new Faker() 190 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 191 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 192 | .RuleFor(userTask => userTask.AssignedToUser, user) 193 | .RuleFor(userTask => userTask.CreatedByUser, user) 194 | .Generate(); 195 | 196 | var @event = new UserTaskCreatedEvent 197 | { 198 | UserTaskId = userTask.Id 199 | }; 200 | 201 | using var db = new DatabaseContext(DbContextOptions); 202 | db.UserTasks.Add(userTask); 203 | db.SaveChanges(); 204 | 205 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 206 | 207 | // Act 208 | await handler.Handle(@event, CancellationToken.None); 209 | 210 | // Assert 211 | await notificationServiceMock.DidNotReceive().SendEmail( 212 | Arg.Any(), 213 | Arg.Any(), 214 | Arg.Any() 215 | ); 216 | } 217 | 218 | [Fact] 219 | public async Task Handle_ShouldNotSendEmail_When_UserTaskDoesNotExist() 220 | { 221 | var notificationServiceMock = Substitute.For(); 222 | var user = GetUser(); 223 | var userTask = new Faker() 224 | .RuleFor(userTask => userTask.Title, f => f.Lorem.Sentence()) 225 | .RuleFor(userTask => userTask.Description, f => f.Lorem.Sentences()) 226 | .RuleFor(userTask => userTask.AssignedToUser, user) 227 | .RuleFor(userTask => userTask.CreatedByUser, user) 228 | .Generate(); 229 | 230 | var @event = new UserTaskCreatedEvent 231 | { 232 | UserTaskId = userTask.Id 233 | }; 234 | 235 | using var db = new DatabaseContext(DbContextOptions); 236 | 237 | var handler = new NotifyAssignedUserEventHandler(db, notificationServiceMock); 238 | 239 | // Act 240 | await handler.Handle(@event, CancellationToken.None); 241 | 242 | // Assert 243 | await notificationServiceMock.DidNotReceive().SendEmail( 244 | Arg.Any(), 245 | Arg.Any(), 246 | Arg.Any() 247 | ); 248 | } 249 | 250 | private User GetUser() 251 | { 252 | var result = new Faker() 253 | .RuleFor(user => user.FirstName, f => f.Name.FirstName()) 254 | .RuleFor(user => user.LastName, f => f.Name.LastName()) 255 | .RuleFor(user => user.Email, f => f.Internet.Email()) 256 | .RuleFor(user => user.StatusEnum, UserStatusEnum.Active) 257 | .Generate(); 258 | 259 | return result; 260 | } 261 | } -------------------------------------------------------------------------------- /EventHandlers/EventHandlers.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /EventHandlers/EventHandlersExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using NotificationService; 3 | 4 | namespace EventHandlers; 5 | 6 | public static class EventHandlersExtensions 7 | { 8 | public static IServiceCollection AddEventHandlers(this IServiceCollection services) 9 | { 10 | services.AddScoped(); 11 | 12 | return services; 13 | } 14 | } -------------------------------------------------------------------------------- /EventHandlers/UserEventHandlers/LogUserCreatedEventHandler.cs: -------------------------------------------------------------------------------- 1 | using Events.UserEvents; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace EventHandlers.UserEventHandlers; 5 | 6 | public class LogUserCreatedEventHandler : IEventHandler 7 | { 8 | public Task Handle(UserCreatedEvent @event, CancellationToken cancellationToken) 9 | { 10 | Console.WriteLine($"Created with id '{@event.UserId}' created"); 11 | return Task.CompletedTask; 12 | } 13 | } -------------------------------------------------------------------------------- /EventHandlers/UserEventHandlers/LogUserDeletedEventHandler.cs: -------------------------------------------------------------------------------- 1 | using Events.UserEvents; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace EventHandlers.UserEventHandlers; 5 | 6 | public class LogUserDeletedEventHandler : IEventHandler 7 | { 8 | public Task Handle(UserDeletedEvent @event, CancellationToken cancellationToken) 9 | { 10 | Console.WriteLine($"User with id '{@event.UserId}' deleted"); 11 | return Task.CompletedTask; 12 | } 13 | } -------------------------------------------------------------------------------- /EventHandlers/UserTaskEventHandlers/NotifyAssignedUserEventHandler.cs: -------------------------------------------------------------------------------- 1 | using DataModel; 2 | using DataModel.Models.UserTasks; 3 | using Events.UserTaskEvents; 4 | using Infrastructure.CQRS.Events; 5 | using Microsoft.EntityFrameworkCore; 6 | using NotificationService; 7 | 8 | namespace EventHandlers.UserTaskEventHandlers; 9 | 10 | public class NotifyAssignedUserEventHandler : IEventHandler 11 | { 12 | private readonly DatabaseContext _db; 13 | private readonly INotificationService _notificationService; 14 | 15 | public NotifyAssignedUserEventHandler(DatabaseContext db, INotificationService notificationService) 16 | { 17 | _db = db; 18 | _notificationService = notificationService; 19 | } 20 | 21 | public async Task Handle(UserTaskCreatedEvent @event, CancellationToken cancellationToken) 22 | { 23 | var userTask = await GetTaskWithAssignedUser(@event.UserTaskId, cancellationToken); 24 | if (userTask?.AssignedToUser is null) 25 | { 26 | return; 27 | } 28 | 29 | var isAuthorAssignedToUserTask = userTask.AssignedToUserId == userTask.CreatedByUserId; 30 | if (isAuthorAssignedToUserTask) 31 | { 32 | return; 33 | } 34 | 35 | var title = $"New Task: {userTask.Title}"; 36 | var message = $"{userTask.CreatedByUser.FirstName} has assigned you the task '{userTask.Title}'"; 37 | await _notificationService.SendEmail(title, message, userTask.AssignedToUser.Email); 38 | } 39 | 40 | private async Task GetTaskWithAssignedUser(UserTaskId userTaskId, CancellationToken cancellationToken) 41 | { 42 | var result = await _db.UserTasks 43 | .Include(userTask => userTask.CreatedByUser) 44 | .Include(userTask => userTask.AssignedToUser) 45 | .Where(userTask => userTask.Id == userTaskId) 46 | .FirstOrDefaultAsync(cancellationToken); 47 | 48 | return result; 49 | } 50 | } -------------------------------------------------------------------------------- /Events/Events.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Events/UserEvents/UserCreatedEvent.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Users; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace Events.UserEvents; 5 | 6 | public class UserCreatedEvent : Event 7 | { 8 | public UserId UserId { get; init; } = null!; 9 | } -------------------------------------------------------------------------------- /Events/UserEvents/UserDeletedEvent.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.Users; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace Events.UserEvents; 5 | 6 | public class UserDeletedEvent : Event 7 | { 8 | public UserId UserId { get; init; } = null!; 9 | } -------------------------------------------------------------------------------- /Events/UserTaskEvents/UserTaskCreatedEvent.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.UserTasks; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace Events.UserTaskEvents; 5 | 6 | public class UserTaskCreatedEvent : Event 7 | { 8 | public UserTaskId UserTaskId { get; init; } = null!; 9 | } -------------------------------------------------------------------------------- /Events/UserTaskEvents/UserTaskDeletedEvent.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.UserTasks; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace Events.UserTaskEvents; 5 | 6 | public class UserTaskDeletedEvent : Event 7 | { 8 | public UserTaskId UserTaskId { get; init; } = null!; 9 | } -------------------------------------------------------------------------------- /Events/UserTaskEvents/UserTaskUpdatedEvent.cs: -------------------------------------------------------------------------------- 1 | using DataModel.Models.UserTasks; 2 | using Infrastructure.CQRS.Events; 3 | 4 | namespace Events.UserTaskEvents; 5 | 6 | public class UserTaskUpdatedEvent : Event 7 | { 8 | public UserTaskId UserTaskId { get; init; } = null!; 9 | } -------------------------------------------------------------------------------- /Infrastructure/Authentication/AuthenticationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Infrastructure.Authentication; 6 | 7 | public static class AuthenticationExtensions 8 | { 9 | public static IServiceCollection AddAuthentication(this IServiceCollection services, 10 | IConfiguration configuration) 11 | { 12 | // services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 13 | // .AddMicrosoftIdentityWebApi(options => 14 | // { 15 | // configuration.Bind("AuthOptions", options); 16 | // 17 | // options.TokenValidationParameters.NameClaimType = "name"; 18 | // }, 19 | // options => { configuration.Bind("AuthOptions", options); }); 20 | 21 | return services; 22 | } 23 | 24 | public static WebApplication UseAuthenticationWithAuthorization(this WebApplication app) 25 | { 26 | app.UseAuthentication(); 27 | app.UseAuthorization(); 28 | 29 | return app; 30 | } 31 | 32 | public static ControllerActionEndpointConventionBuilder ApplyAuthorization( 33 | this ControllerActionEndpointConventionBuilder builder, bool hasAuthorization = false) 34 | { 35 | if (hasAuthorization) 36 | { 37 | builder.RequireAuthorization(); 38 | } 39 | 40 | return builder; 41 | } 42 | } -------------------------------------------------------------------------------- /Infrastructure/BackgroundJob/BackgroundJobOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.BackgroundJob; 2 | 3 | public class BackgroundJobOptions 4 | { 5 | public static readonly string ConnectionString = "BackgroundJobConnectionString"; 6 | public string DashboardPath { get; } = null!; 7 | } -------------------------------------------------------------------------------- /Infrastructure/BackgroundJob/Hangfire/HangfireExtensions.cs: -------------------------------------------------------------------------------- 1 | using Hangfire; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Newtonsoft.Json; 5 | 6 | namespace Infrastructure.BackgroundJob.Hangfire; 7 | 8 | public static class HangfireExtensions 9 | { 10 | public static IServiceCollection AddHangfire(this IServiceCollection services, string connectionString) 11 | { 12 | services.AddHangfire(configuration => 13 | { 14 | configuration.UseSqlServerStorage(connectionString); 15 | configuration.UseColouredConsoleLogProvider(); 16 | 17 | var jsonSettings = new JsonSerializerSettings 18 | { 19 | TypeNameHandling = TypeNameHandling.All 20 | }; 21 | configuration.UseSerializerSettings(jsonSettings); 22 | } 23 | ); 24 | 25 | services.AddSingleton(); 26 | 27 | return services; 28 | } 29 | 30 | public static IServiceCollection AddHangfireWorker(this IServiceCollection services) 31 | { 32 | services.AddHangfireServer(); 33 | 34 | return services; 35 | } 36 | 37 | public static WebApplication UseHangfireUI(this WebApplication app, string path) 38 | { 39 | app.UseHangfireDashboard(path); 40 | 41 | return app; 42 | } 43 | } -------------------------------------------------------------------------------- /Infrastructure/BackgroundJob/Hangfire/HangfireJobBus.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Linq.Expressions; 3 | using Hangfire; 4 | 5 | namespace Infrastructure.BackgroundJob.Hangfire; 6 | 7 | public class HangfireJobBus : IBackgroundJobBus 8 | { 9 | private readonly IBackgroundJobClient _backgroundJobClient; 10 | 11 | public HangfireJobBus(IBackgroundJobClient backgroundJobClient) 12 | { 13 | _backgroundJobClient = backgroundJobClient; 14 | } 15 | 16 | [DisplayName("{0}")] 17 | public Task Enqueue(Expression methodCall) 18 | { 19 | var jobId = _backgroundJobClient.Enqueue(methodCall); 20 | return Task.FromResult(jobId); 21 | } 22 | 23 | public Task Schedule(Expression methodCall, TimeSpan timeSpan) 24 | { 25 | var jobId = _backgroundJobClient.Schedule(methodCall, timeSpan); 26 | return Task.FromResult(jobId); 27 | } 28 | 29 | public Task EnqueueAfter(string jobId, Expression methodCall) 30 | { 31 | var newJobId = _backgroundJobClient.ContinueJobWith(jobId, methodCall); 32 | return Task.FromResult(newJobId); 33 | } 34 | 35 | public static Task AddRecurringJob(string jobId, Expression methodCall, string cron) 36 | { 37 | RecurringJob.AddOrUpdate(jobId, methodCall, cron); 38 | return Task.CompletedTask; 39 | } 40 | 41 | public static Task RemoveRecurringJob(string jobId) 42 | { 43 | RecurringJob.RemoveIfExists(jobId); 44 | return Task.CompletedTask; 45 | } 46 | 47 | public static Task StartRecurringJob(string jobId) 48 | { 49 | RecurringJob.TriggerJob(jobId); 50 | return Task.CompletedTask; 51 | } 52 | } -------------------------------------------------------------------------------- /Infrastructure/BackgroundJob/IBackgroundJobBus.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | 3 | namespace Infrastructure.BackgroundJob; 4 | 5 | public interface IBackgroundJobBus 6 | { 7 | Task Enqueue(Expression methodCall); 8 | Task Schedule(Expression methodCall, TimeSpan timeSpan); 9 | Task EnqueueAfter(string jobId, Expression methodCall); 10 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/CQRSExtensions.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | using Infrastructure.CQRS.Commands; 3 | using Infrastructure.CQRS.Events; 4 | using Infrastructure.CQRS.Queries; 5 | using Infrastructure.ExceptionHandling; 6 | using MediatR; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.Extensions.DependencyInjection; 9 | 10 | namespace Infrastructure.CQRS; 11 | 12 | public static class CQRSExtensions 13 | { 14 | public static IServiceCollection AddCQRS(this IServiceCollection services) 15 | { 16 | var assemblies = AppDomain.CurrentDomain.GetAssemblies(); 17 | 18 | services.AddMediatR(options => { options.RegisterServicesFromAssemblies(assemblies); }); 19 | 20 | services.AddValidatorsFromAssemblies(assemblies); 21 | 22 | services.AddScoped(); 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | 26 | services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); 27 | 28 | return services; 29 | } 30 | 31 | public static WebApplication UseCQRS(this WebApplication app) 32 | { 33 | return app; 34 | } 35 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Commands/CommandBus.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Commands; 4 | 5 | public sealed class CommandBus : ICommandBus 6 | { 7 | private readonly IMediator _mediator; 8 | 9 | public CommandBus(IMediator mediator) 10 | { 11 | _mediator = mediator ?? throw new Exception($"Missing dependency '{nameof(IMediator)}'"); 12 | } 13 | 14 | public async Task Send(ICommand command, 15 | CancellationToken cancellationToken = default) 16 | { 17 | var result = await _mediator.Send(command, cancellationToken); 18 | 19 | return result; 20 | } 21 | 22 | public async Task Send(ICommand command, CancellationToken cancellationToken = default) 23 | { 24 | await _mediator.Send(command, cancellationToken); 25 | } 26 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Commands/ICommand.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Commands; 4 | 5 | public interface ICommand : IRequest 6 | { 7 | } 8 | 9 | public interface ICommand : IRequest 10 | { 11 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Commands/ICommandBus.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.CQRS.Commands; 2 | 3 | public interface ICommandBus 4 | { 5 | Task Send(ICommand command, CancellationToken cancellationToken = default); 6 | 7 | Task Send(ICommand command, CancellationToken cancellationToken = default); 8 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Commands/ICommandHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Commands; 4 | 5 | public interface ICommandHandler : IRequestHandler 6 | where TRequest : ICommand 7 | { 8 | } 9 | 10 | public interface ICommandHandler : IRequestHandler where TRequest : ICommand 11 | { 12 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Events/Event.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.CQRS.Events; 2 | 3 | public abstract class Event : IEvent 4 | { 5 | public Guid Id { get; } = Guid.NewGuid(); 6 | public DateTime CreatedUtc { get; } = DateTime.UtcNow; 7 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Events/EventBus.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.BackgroundJob; 2 | using MediatR; 3 | 4 | namespace Infrastructure.CQRS.Events; 5 | 6 | public sealed class EventBus : IEventBus 7 | { 8 | private readonly IBackgroundJobBus _backgroundJobBus; 9 | private readonly IMediator _mediator; 10 | 11 | public EventBus(IMediator mediator, IBackgroundJobBus backgroundJobBus) 12 | { 13 | _mediator = mediator; 14 | _backgroundJobBus = backgroundJobBus; 15 | } 16 | 17 | public async Task Commit(params IEvent[] events) 18 | { 19 | foreach (var @event in events) 20 | { 21 | await Commit(@event); 22 | } 23 | } 24 | 25 | private async Task Commit(IEvent @event) 26 | { 27 | await _backgroundJobBus.Enqueue(() => PublishEvent(@event)); 28 | } 29 | 30 | public void PublishEvent(IEvent @event) 31 | { 32 | Task.Run(async () => await _mediator.Publish(@event)).Wait(); 33 | } 34 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Events/IEvent.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Events; 4 | 5 | public interface IEvent : INotification 6 | { 7 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Events/IEventBus.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.CQRS.Events; 2 | 3 | public interface IEventBus 4 | { 5 | Task Commit(params IEvent[] events); 6 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Events/IEventHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Events; 4 | 5 | public interface IEventHandler : INotificationHandler where T : IEvent 6 | { 7 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Queries/IQuery.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Queries; 4 | 5 | public interface IQuery : IRequest 6 | { 7 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Queries/IQueryBus.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.CQRS.Queries; 2 | 3 | public interface IQueryBus 4 | { 5 | Task Send(IQuery query, CancellationToken cancellationToken = default); 6 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Queries/IQueryHandler.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Queries; 4 | 5 | public interface IQueryHandler : IRequestHandler 6 | where TRequest : IQuery 7 | { 8 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/Queries/QueryBus.cs: -------------------------------------------------------------------------------- 1 | using MediatR; 2 | 3 | namespace Infrastructure.CQRS.Queries; 4 | 5 | public sealed class QueryBus : IQueryBus 6 | { 7 | private readonly IMediator _mediator; 8 | 9 | public QueryBus(IMediator mediator) 10 | { 11 | _mediator = mediator ?? throw new Exception($"Missing dependency '{nameof(IMediator)}'"); 12 | } 13 | 14 | public async Task Send(IQuery query, CancellationToken cancellationToken = default) 15 | { 16 | var result = await _mediator.Send(query, cancellationToken); 17 | 18 | return result; 19 | } 20 | } -------------------------------------------------------------------------------- /Infrastructure/CQRS/ValidationBehavior.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using FluentValidation; 3 | using MediatR; 4 | 5 | namespace Infrastructure.CQRS; 6 | 7 | public class ValidationBehavior : IPipelineBehavior where TRequest : notnull 8 | { 9 | private readonly IEnumerable> _validators; 10 | 11 | public ValidationBehavior(IEnumerable> validators) 12 | { 13 | _validators = validators; 14 | } 15 | 16 | public async Task Handle(TRequest request, RequestHandlerDelegate next, 17 | CancellationToken cancellationToken) 18 | { 19 | if (!_validators.Any()) 20 | { 21 | return await next(); 22 | } 23 | 24 | var context = new ValidationContext(request); 25 | var errorsDictionary = _validators 26 | .Select(x => x.Validate(context)) 27 | .SelectMany(x => x.Errors) 28 | .Where(x => x != null) 29 | .GroupBy( 30 | x => x.PropertyName, 31 | x => x.ErrorMessage, 32 | (propertyName, errorMessages) => new 33 | { 34 | Key = propertyName, 35 | Values = errorMessages.Distinct().ToArray() 36 | }) 37 | .ToDictionary(x => x.Key, x => x.Values); 38 | if (errorsDictionary.Any()) 39 | { 40 | throw new ValidationException(JsonSerializer.Serialize(errorsDictionary)); 41 | } 42 | 43 | return await next(); 44 | } 45 | } -------------------------------------------------------------------------------- /Infrastructure/Cors/CorsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Infrastructure.Cors; 5 | 6 | public static class CorsExtensions 7 | { 8 | public static IServiceCollection AddCorsPolicy(this IServiceCollection services, string name, string origin = "*", 9 | string methods = "*", string headers = "*") 10 | { 11 | services.AddCors(options => 12 | { 13 | options.AddPolicy(name, 14 | b => 15 | { 16 | b.WithOrigins(origin); 17 | b.WithMethods(methods); 18 | b.WithHeaders(headers); 19 | }); 20 | }); 21 | 22 | return services; 23 | } 24 | 25 | public static WebApplication UseCorsPolicy(this WebApplication app, string name) 26 | { 27 | app.UseCors(name); 28 | 29 | return app; 30 | } 31 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/ExceptionHandlingExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Infrastructure.ExceptionHandling; 5 | 6 | public static class ExceptionHandlingExtensions 7 | { 8 | public static IServiceCollection AddExceptionHandling(this IServiceCollection services) 9 | { 10 | services.AddScoped(); 11 | 12 | return services; 13 | } 14 | 15 | public static WebApplication UseExceptionhandling(this WebApplication app) 16 | { 17 | app.UseMiddleware(); 18 | return app; 19 | } 20 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Components.Exceptions; 3 | using FluentValidation; 4 | using Infrastructure.ExceptionHandling.Exceptions; 5 | using Infrastructure.StronglyTypedIds; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Infrastructure.ExceptionHandling; 10 | 11 | internal sealed class ExceptionHandlingMiddleware : IMiddleware 12 | { 13 | private readonly ILogger _logger; 14 | 15 | public ExceptionHandlingMiddleware(ILogger logger) 16 | { 17 | _logger = logger; 18 | } 19 | 20 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 21 | { 22 | try 23 | { 24 | await next(context); 25 | } 26 | catch (Exception e) 27 | { 28 | _logger.LogError(e, e.Message); 29 | 30 | await HandleExceptionAsync(context, e); 31 | } 32 | } 33 | 34 | private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception) 35 | { 36 | var statusCode = GetStatusCode(exception); 37 | 38 | var response = new 39 | { 40 | title = GetTitle(exception), 41 | status = statusCode, 42 | detail = exception.Message, 43 | errors = GetErrors(exception) 44 | }; 45 | 46 | httpContext.Response.ContentType = "application/json"; 47 | 48 | httpContext.Response.StatusCode = statusCode; 49 | 50 | await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response)); 51 | } 52 | 53 | private static int GetStatusCode(Exception exception) 54 | { 55 | return exception switch 56 | { 57 | BadRequestException => StatusCodes.Status400BadRequest, 58 | NotFoundException => StatusCodes.Status404NotFound, 59 | ValidationException => StatusCodes.Status400BadRequest, 60 | InvalidStronglyTypedIdException => StatusCodes.Status501NotImplemented, 61 | _ => StatusCodes.Status500InternalServerError 62 | }; 63 | } 64 | 65 | private static string GetTitle(Exception exception) 66 | { 67 | return exception switch 68 | { 69 | ApplicationException applicationException => applicationException.Message, 70 | _ => "Server Error" 71 | }; 72 | } 73 | 74 | private static IReadOnlyList GetErrors(Exception exception) 75 | { 76 | IReadOnlyList errors = new List(); 77 | 78 | if (exception is ValidationException validationException) 79 | { 80 | errors = validationException.Errors.Select(error => error.ErrorMessage).ToList(); 81 | } 82 | 83 | return errors; 84 | } 85 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/Exceptions/BadRequestException.cs: -------------------------------------------------------------------------------- 1 | namespace Components.Exceptions; 2 | 3 | public class BadRequestException : Exception 4 | { 5 | public BadRequestException(string message) : base(message) 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/Exceptions/ForbiddenException.cs: -------------------------------------------------------------------------------- 1 | namespace Components.Exceptions; 2 | 3 | public class ForbiddenException : Exception 4 | { 5 | public ForbiddenException(string message) : base(message) 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/Exceptions/NotFoundException.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.StronglyTypedIds; 2 | 3 | namespace Infrastructure.ExceptionHandling.Exceptions; 4 | 5 | public class NotFoundException : Exception 6 | { 7 | public NotFoundException(string propertyName, string id) : base(PropertyIdMessage(propertyName, id)) 8 | { 9 | } 10 | 11 | public NotFoundException(string propertyName, StronglyTypedIdBaseEntity id) : base( 12 | PropertyIdMessage(propertyName, id.ToString())) 13 | { 14 | } 15 | 16 | public NotFoundException(string message) : base(message) 17 | { 18 | } 19 | 20 | private static string PropertyIdMessage(string propertyName, string id) 21 | { 22 | return $"{propertyName} with id '{id}' was not found"; 23 | } 24 | } -------------------------------------------------------------------------------- /Infrastructure/ExceptionHandling/Exceptions/NotSupportedException.cs: -------------------------------------------------------------------------------- 1 | namespace Components.Exceptions; 2 | 3 | public class NotSupportedException : Exception 4 | { 5 | public NotSupportedException(string propertyName, string value) : base( 6 | $"{propertyName} with value '{value}' is not supported") 7 | { 8 | } 9 | } -------------------------------------------------------------------------------- /Infrastructure/HealthChecks/HealthChecksExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Infrastructure.HealthChecks; 5 | 6 | public static class HealthChecksExtensions 7 | { 8 | public static IServiceCollection AddApiHealthChecks(this IServiceCollection services) 9 | { 10 | services 11 | .AddHealthChecks(); 12 | 13 | return services; 14 | } 15 | 16 | public static WebApplication UseApiHealthChecks(this WebApplication app, string path = "/health") 17 | { 18 | app.UseHealthChecks(path); 19 | return app; 20 | } 21 | } -------------------------------------------------------------------------------- /Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/InvalidStronglyTypedIdException.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.StronglyTypedIds; 2 | 3 | public class InvalidStronglyTypedIdException : Exception 4 | { 5 | public InvalidStronglyTypedIdException(string message) : base(message) 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdBaseEntity.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace Infrastructure.StronglyTypedIds; 4 | 5 | public abstract record StronglyTypedIdBaseEntity 6 | { 7 | protected string Prefix { get; init; } = null!; 8 | protected string Value { get; init; } = null!; 9 | 10 | public sealed override string ToString() => Value; 11 | 12 | public static string GetPattern() where T : StronglyTypedIdBaseEntity 13 | { 14 | var instance = (T) Activator.CreateInstance(typeof(T), Ulid.NewUlid().ToString())!; 15 | return instance.Prefix + "[0-7][0-9A-HJKMNP-TV-Z]{25}".ToLowerInvariant(); 16 | } 17 | 18 | public static string GetPlaceholder() where T : StronglyTypedIdBaseEntity 19 | { 20 | var instance = (T) Activator.CreateInstance(typeof(T), Ulid.NewUlid().ToString())!; 21 | return $"{instance.Prefix}{Ulid.Empty}"; 22 | } 23 | 24 | protected static bool TryParse(string id, out T? result) where T : StronglyTypedIdBaseEntity 25 | { 26 | result = null; 27 | 28 | if (string.IsNullOrWhiteSpace(id)) 29 | { 30 | return false; 31 | } 32 | 33 | var instance = (T) Activator.CreateInstance(typeof(T), id)!; 34 | result = instance; 35 | 36 | return true; 37 | } 38 | } 39 | 40 | [TypeConverter(typeof(StronglyTypedIdConverter))] 41 | public abstract record StronglyTypedIdBaseEntity : StronglyTypedIdBaseEntity where T : StronglyTypedIdBaseEntity 42 | { 43 | protected StronglyTypedIdBaseEntity(string prefix, string value) 44 | { 45 | if (string.IsNullOrWhiteSpace(prefix)) 46 | { 47 | throw new InvalidStronglyTypedIdException($"{nameof(prefix)} is not defined"); 48 | } 49 | 50 | if (string.IsNullOrWhiteSpace(value)) 51 | { 52 | throw new InvalidStronglyTypedIdException($"{nameof(value)} is not defined"); 53 | } 54 | 55 | Prefix = prefix; 56 | Value = value.ToLowerInvariant(); 57 | } 58 | 59 | public static T New() 60 | { 61 | var instance = (T) Activator.CreateInstance(typeof(T), Ulid.NewUlid().ToString())!; 62 | var id = $"{instance.Prefix}{instance.Value}"; 63 | return (T) Activator.CreateInstance(typeof(T), id)!; 64 | } 65 | 66 | public static bool TryParse(string id, out T? result) 67 | { 68 | return TryParse(id, out result); 69 | } 70 | 71 | public static string GetPlaceholder() 72 | { 73 | return GetPlaceholder(); 74 | } 75 | 76 | public bool IsValid() 77 | { 78 | if (!Value.StartsWith(Prefix, StringComparison.InvariantCultureIgnoreCase)) 79 | { 80 | return false; 81 | } 82 | 83 | return Ulid.TryParse(Value.Substring(Prefix.Length), out _); 84 | } 85 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdConversions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Linq.Expressions; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 5 | 6 | namespace Infrastructure.StronglyTypedIds; 7 | 8 | public static class StronglyTypedIdConversions 9 | { 10 | private static readonly ConcurrentDictionary StronglyTypedIdConverters = new(); 11 | 12 | public static void AddStronglyTypedIdConversions(this ModelBuilder builder) 13 | { 14 | foreach (var entityType in builder.Model.GetEntityTypes()) 15 | { 16 | foreach (var property in entityType.GetProperties()) 17 | { 18 | if (StronglyTypedIdHelper.IsStronglyTypedId(property.ClrType)) 19 | { 20 | var converter = StronglyTypedIdConverters.GetOrAdd( 21 | property.ClrType, 22 | _ => CreateStronglyTypedIdConverter(property.ClrType)!); 23 | property.SetValueConverter(converter); 24 | } 25 | } 26 | } 27 | } 28 | 29 | public static ValueConverter? CreateStronglyTypedIdConverter(Type stronglyTypedIdType) 30 | { 31 | // id => id.Value 32 | var toProviderFuncType = typeof(Func<,>) 33 | .MakeGenericType(stronglyTypedIdType, typeof(string)); 34 | var stronglyTypedIdParam = Expression.Parameter(stronglyTypedIdType, "id"); 35 | var toProviderExpression = Expression.Lambda( 36 | toProviderFuncType, 37 | Expression.Property(stronglyTypedIdParam, "Value"), 38 | stronglyTypedIdParam); 39 | 40 | // value => new >(value) 41 | var fromProviderFuncType = typeof(Func<,>) 42 | .MakeGenericType(typeof(string), stronglyTypedIdType); 43 | var valueParam = Expression.Parameter(typeof(string), "value"); 44 | var ctor = stronglyTypedIdType.GetConstructor(new[] {typeof(string)}); 45 | var fromProviderExpression = Expression.Lambda( 46 | fromProviderFuncType, 47 | Expression.New(ctor!, valueParam), 48 | valueParam); 49 | 50 | var converterType = typeof(ValueConverter<,>) 51 | .MakeGenericType(stronglyTypedIdType, typeof(string)); 52 | 53 | return (ValueConverter) Activator.CreateInstance( 54 | converterType, 55 | toProviderExpression, 56 | fromProviderExpression, 57 | null)!; 58 | } 59 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.ComponentModel; 3 | using System.Globalization; 4 | 5 | namespace Infrastructure.StronglyTypedIds; 6 | 7 | public class StronglyTypedIdConverter : TypeConverter 8 | { 9 | private static readonly ConcurrentDictionary ActualConverters = new(); 10 | 11 | private readonly TypeConverter _innerConverter; 12 | 13 | public StronglyTypedIdConverter(Type stronglyTypedIdType) 14 | { 15 | _innerConverter = ActualConverters.GetOrAdd(stronglyTypedIdType, CreateActualConverter); 16 | } 17 | 18 | public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) 19 | { 20 | return _innerConverter.CanConvertFrom(context, sourceType); 21 | } 22 | 23 | public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) 24 | { 25 | return _innerConverter.CanConvertTo(context, destinationType); 26 | } 27 | 28 | public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) 29 | { 30 | return _innerConverter.ConvertFrom(context, culture, value); 31 | } 32 | 33 | public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, 34 | Type destinationType) 35 | { 36 | return _innerConverter.ConvertTo(context, culture, value, destinationType); 37 | } 38 | 39 | 40 | private static TypeConverter CreateActualConverter(Type stronglyTypedIdType) 41 | { 42 | if (!StronglyTypedIdHelper.IsStronglyTypedId(stronglyTypedIdType)) 43 | { 44 | throw new InvalidStronglyTypedIdException($"The type '{stronglyTypedIdType}' is not a strongly typed id"); 45 | } 46 | 47 | var actualConverterType = typeof(StronglyTypedIdConverter<>).MakeGenericType(stronglyTypedIdType); 48 | return (TypeConverter) Activator.CreateInstance(actualConverterType, stronglyTypedIdType)!; 49 | } 50 | } 51 | 52 | public class StronglyTypedIdConverter : TypeConverter 53 | where TValue : StronglyTypedIdBaseEntity 54 | { 55 | private readonly Type _type; 56 | 57 | public StronglyTypedIdConverter(Type type) 58 | { 59 | _type = type; 60 | } 61 | 62 | public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) 63 | { 64 | return sourceType == typeof(string) 65 | || sourceType == typeof(TValue) 66 | || base.CanConvertFrom(context, sourceType); 67 | } 68 | 69 | public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) 70 | { 71 | return destinationType == typeof(string) 72 | || destinationType == typeof(TValue) 73 | || base.CanConvertTo(context, destinationType); 74 | } 75 | 76 | public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) 77 | { 78 | if (value is string s) 79 | { 80 | StronglyTypedIdBaseEntity.TryParse(s, out var result); 81 | return result; 82 | } 83 | 84 | return base.ConvertFrom(context, culture, value); 85 | } 86 | 87 | public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, 88 | Type destinationType) 89 | { 90 | if (value is null) 91 | { 92 | return value; 93 | } 94 | 95 | var stronglyTypedId = (StronglyTypedIdBaseEntity) value; 96 | var idValue = stronglyTypedId; 97 | if (destinationType == typeof(string)) 98 | { 99 | return idValue.ToString(); 100 | } 101 | 102 | return base.ConvertTo(context, culture, value, destinationType); 103 | } 104 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdHelper.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.StronglyTypedIds; 2 | 3 | public static class StronglyTypedIdHelper 4 | { 5 | public static bool IsStronglyTypedId(Type? type) 6 | { 7 | if (type is null) 8 | { 9 | return false; 10 | } 11 | 12 | return type.BaseType is {IsGenericType: true} baseType && 13 | baseType.GetGenericTypeDefinition() == typeof(StronglyTypedIdBaseEntity<>); 14 | } 15 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdJsonConverterFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Infrastructure.StronglyTypedIds; 6 | 7 | public class StronglyTypedIdJsonConverterFactory : JsonConverterFactory 8 | { 9 | private static readonly ConcurrentDictionary Cache = new(); 10 | 11 | public override bool CanConvert(Type typeToConvert) 12 | { 13 | return StronglyTypedIdHelper.IsStronglyTypedId(typeToConvert); 14 | } 15 | 16 | public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) 17 | { 18 | return Cache.GetOrAdd(typeToConvert, CreateConverter); 19 | } 20 | 21 | private static JsonConverter CreateConverter(Type typeToConvert) 22 | { 23 | if (!StronglyTypedIdHelper.IsStronglyTypedId(typeToConvert)) 24 | { 25 | throw new InvalidStronglyTypedIdException($"Cannot create converter for '{typeToConvert}'"); 26 | } 27 | 28 | var type = typeof(StronglyTypedIdJsonConverter<>).MakeGenericType(typeToConvert); 29 | return (JsonConverter) Activator.CreateInstance(type)!; 30 | } 31 | } 32 | 33 | public class StronglyTypedIdJsonConverter : JsonConverter 34 | where TStronglyTypedId : StronglyTypedIdBaseEntity 35 | { 36 | public override TStronglyTypedId? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 37 | { 38 | if (reader.TokenType is JsonTokenType.Null) 39 | { 40 | return null; 41 | } 42 | 43 | var value = JsonSerializer.Deserialize(ref reader, options); 44 | if (value is not null) 45 | { 46 | StronglyTypedIdBaseEntity.TryParse(value, out var result); 47 | return result; 48 | } 49 | 50 | return null; 51 | } 52 | 53 | public override void Write(Utf8JsonWriter writer, TStronglyTypedId? value, JsonSerializerOptions options) 54 | { 55 | if (value is null) 56 | { 57 | writer.WriteNullValue(); 58 | } 59 | else 60 | { 61 | JsonSerializer.Serialize(writer, value.ToString(), options); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdValidator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace Infrastructure.StronglyTypedIds; 4 | 5 | public static class StronglyTypedIdValidator 6 | { 7 | public static void IdMustBeValid(this IRuleBuilder ruleBuilder) 8 | where TId : StronglyTypedIdBaseEntity 9 | { 10 | ruleBuilder.Must((_, id, _) => !string.IsNullOrWhiteSpace(id) && 11 | ((TId) Activator.CreateInstance(typeof(TId), id)!).IsValid()) 12 | .WithMessage( 13 | $"Id is not valid. Valid format is {StronglyTypedIdBaseEntity.GetPlaceholder()}"); 14 | } 15 | 16 | public static void OptionalIdMustBeValid(this IRuleBuilder ruleBuilder) 17 | where TId : StronglyTypedIdBaseEntity 18 | { 19 | ruleBuilder.Must((_, id, _) => 20 | string.IsNullOrWhiteSpace(id) || ((TId) Activator.CreateInstance(typeof(TId), id)!).IsValid()) 21 | .WithMessage( 22 | $"Id is not valid. Valid format is {StronglyTypedIdBaseEntity.GetPlaceholder()}"); 23 | } 24 | 25 | public static void IdMustBeValid(this IRuleBuilder ruleBuilder) 26 | where TId : StronglyTypedIdBaseEntity 27 | { 28 | ruleBuilder.Must((_, id, _) => id.IsValid()) 29 | .WithMessage( 30 | $"Id is not valid. Valid format is {StronglyTypedIdBaseEntity.GetPlaceholder()}"); 31 | } 32 | 33 | public static void OptionalIdMustBeValid(this IRuleBuilder ruleBuilder) 34 | where TId : StronglyTypedIdBaseEntity 35 | { 36 | ruleBuilder.Must((_, id, _) => id is null || id.IsValid()) 37 | .WithMessage( 38 | $"Id is not valid. Valid format is {StronglyTypedIdBaseEntity.GetPlaceholder()}"); 39 | } 40 | } -------------------------------------------------------------------------------- /Infrastructure/StronglyTypedIds/StronglyTypedIdsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Infrastructure.StronglyTypedIds; 4 | 5 | public static class StronglyTypedIdsExtensions 6 | { 7 | public static IMvcBuilder AddStronglyTypedIds(this IMvcBuilder builder) 8 | { 9 | builder.AddJsonOptions(options => 10 | { 11 | options.JsonSerializerOptions.Converters.Add( 12 | new StronglyTypedIdJsonConverterFactory()); 13 | }); 14 | 15 | return builder; 16 | } 17 | } -------------------------------------------------------------------------------- /Infrastructure/Swagger/AuthOperationsFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.OpenApi.Models; 3 | using Swashbuckle.AspNetCore.SwaggerGen; 4 | 5 | namespace Infrastructure.Swagger; 6 | 7 | public class AuthOperationsFilter : IOperationFilter 8 | { 9 | public void Apply(OpenApiOperation operation, OperationFilterContext context) 10 | { 11 | var noAuthRequired = context.ApiDescription.CustomAttributes() 12 | .Any(attr => attr.GetType() == typeof(AllowAnonymousAttribute)); 13 | 14 | if (noAuthRequired) 15 | { 16 | return; 17 | } 18 | 19 | operation.Security = new List 20 | { 21 | new() 22 | { 23 | { 24 | new OpenApiSecurityScheme 25 | { 26 | Reference = new OpenApiReference 27 | { 28 | Type = ReferenceType.SecurityScheme, 29 | Id = "oauth2" 30 | } 31 | }, 32 | new List() 33 | } 34 | } 35 | }; 36 | } 37 | } -------------------------------------------------------------------------------- /Infrastructure/Swagger/StronglyTypedIdSchemaFilter.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.StronglyTypedIds; 2 | using Microsoft.OpenApi.Models; 3 | using Swashbuckle.AspNetCore.SwaggerGen; 4 | 5 | namespace Infrastructure.Swagger; 6 | 7 | public class StronglyTypedIdSchemaFilter : ISchemaFilter 8 | { 9 | public void Apply(OpenApiSchema? schema, SchemaFilterContext context) 10 | { 11 | if (schema is not null && context?.Type.IsAssignableTo(typeof(StronglyTypedIdBaseEntity)) == true) 12 | { 13 | var methodPlaceholder = typeof(StronglyTypedIdBaseEntity).GetMethod(nameof(StronglyTypedIdBaseEntity.GetPlaceholder)); 14 | var genericPlaceholder = methodPlaceholder!.MakeGenericMethod(context.Type); 15 | var placeholder = genericPlaceholder.Invoke(this, null) as string; 16 | schema.Format = placeholder; 17 | 18 | var methodPattern = typeof(StronglyTypedIdBaseEntity).GetMethod(nameof(StronglyTypedIdBaseEntity.GetPattern)); 19 | var genericPattern = methodPattern!.MakeGenericMethod(context.Type); 20 | var pattern = genericPattern.Invoke(this, null) as string; 21 | schema.Pattern = pattern; 22 | 23 | schema.Type = "string"; 24 | schema.AdditionalPropertiesAllowed = false; 25 | schema.Properties.Clear(); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Infrastructure/Swagger/SwaggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Infrastructure.Swagger; 6 | 7 | public static class SwaggerExtensions 8 | { 9 | public static IServiceCollection AddSwagger(this IServiceCollection services) 10 | { 11 | // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle 12 | services.AddEndpointsApiExplorer(); 13 | services.AddSwaggerGen(options => 14 | { 15 | options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First()); 16 | options.IgnoreObsoleteActions(); 17 | options.IgnoreObsoleteProperties(); 18 | // c.CustomSchemaIds(type => type.FullName); 19 | options.CustomSchemaIds(type => type.FullName?.Replace("+", ".")); 20 | options.OperationFilter(); 21 | options.SchemaFilter(); 22 | 23 | // options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme 24 | // { 25 | // In = ParameterLocation.Header, 26 | // Description = "Please enter a valid token", 27 | // Name = "Authorization", 28 | // Type = SecuritySchemeType.OAuth2, 29 | // BearerFormat = "JWT", 30 | // Scheme = "Bearer", 31 | // Flows = new OpenApiOAuthFlows 32 | // { 33 | // Implicit = new OpenApiOAuthFlow 34 | // { 35 | // AuthorizationUrl = new Uri("/auth-server/connect/authorize", UriKind.Absolute), 36 | // Scopes = new Dictionary() 37 | // } 38 | // } 39 | // }); 40 | }); 41 | 42 | return services; 43 | } 44 | 45 | public static WebApplication UseSwaggerWithUI(this WebApplication app) 46 | { 47 | app.UseSwagger(); 48 | app.UseSwaggerUI(); 49 | 50 | return app; 51 | } 52 | 53 | public static IMvcBuilder AddSwaggerJsonOptions(this IMvcBuilder builder) 54 | { 55 | builder.AddJsonOptions(options => 56 | options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); 57 | 58 | return builder; 59 | } 60 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mads Engel Lundt 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 | -------------------------------------------------------------------------------- /NotificationService/INotificationService.cs: -------------------------------------------------------------------------------- 1 | namespace NotificationService; 2 | 3 | public interface INotificationService 4 | { 5 | Task SendEmail(string title, string message, string email); 6 | Task SendPushNotification(string title, string message, string recipient); 7 | } -------------------------------------------------------------------------------- /NotificationService/NotificationService.cs: -------------------------------------------------------------------------------- 1 | namespace NotificationService; 2 | 3 | public class NotificationService : INotificationService 4 | { 5 | public Task SendEmail(string title, string message, string email) 6 | { 7 | Console.WriteLine("Sending email notification..."); 8 | 9 | return Task.CompletedTask; 10 | } 11 | 12 | public Task SendPushNotification(string title, string message, string recipient) 13 | { 14 | Console.WriteLine("Sending push notification..."); 15 | 16 | return Task.CompletedTask; 17 | } 18 | } -------------------------------------------------------------------------------- /NotificationService/NotificationService.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The project has been developed using .NET 7, demonstrating a fusion of monolithic architecture principles with microservices methodologies. It follows a structured approach, segmenting the project into distinct domains to achieve a well-organized and scalable system. 2 | 3 | [My Medium article that references this repository](https://medium.com/@madslundt/how-to-write-a-well-structured-api-from-the-beginning-1b15992e09ce) 4 | 5 | The example undertaking comprises of both users and their corresponding tasks. 6 | A user is described by an id, a status, as well as their first and last name. 7 | A user's task is outlined by an id, a status, a title, a description, an author (the user), and potentially an assigned user identification number. 8 | 9 | # Run application 10 | To execute the project, you must ensure that Docker is properly installed. Subsequently, you can initiate the project by executing the following command from the project's root directory: 11 | ```bash 12 | docker-compose up 13 | ``` 14 | *To avoid using a cached version, simply include the `--build --force-recreate` arguments when running the command. Please be aware that these arguments will reset all contents within the Docker image.* 15 | 16 | This command will initialize the API, the background worker, and a SQL Server database. The API will be accessible on port 80, and the Hangfire dashboard will be accessible on port 81. 17 | 18 | If you prefer to selectively run specific services, such as just the SQL Server database, you can achieve this by executing the following command: 19 | ```bash 20 | docker-compose start db 21 | ``` 22 | 23 | Before commencing the API, it is crucial to verify the creation of databases and the successful execution of migrations. 24 | After successfully setting up the database, two databases should be established: 25 | 1. One database is designated for the API and is typically named 'app' by default in the appsettings.json file. You have the flexibility to change this name as necessary. 26 | 2. Another database is allocated for the Background worker, and it is typically named 'jobs' in the appsettings.json file. You can adjust this name to suit your requirements. 27 | 28 | Once the databases are established, it's essential to apply the necessary migrations. This can be accomplished by navigating to the DataModel directory and executing the subsequent command: 29 | ```bash 30 | dotnet ef database update 31 | ``` 32 | *Please ensure that you have correctly configured the ASPNETCORE_ENVIRONMENT environment variable to correspond with the appropriate appsettings configuration. To use the default configuration, set the environment variable to "Development".* 33 | 34 | # Project structure 35 | The project has been structured into different domains: 36 | - [**Api**](#Api) 37 | - [**Components**](#Component) 38 | - [**DataModel** ](#DataModel) 39 | - [**Events** ](#Events) 40 | - [**EventHandlers** ](#EventHandlers) 41 | - [**Infrastructure** ](#Infrastructure) 42 | - [**BackgroundWorker**](#BackgroundWorker) 43 | 44 | Overall, the project exemplifies a well-considered architectural approach that combines the advantages of both monolithic and microservices paradigms, fostering modularity, maintainability, and scalability. 45 | 46 | The project leverages essential external dependencies: 47 | 48 | - **Ulid**: This library provides unique identifiers that serve as primary keys in the database, contributing to data integrity and accuracy. 49 | - **Mediatr**: As an in-process messaging framework, Mediatr streamlines communication between components, minimizing dependencies and enhancing efficiency. 50 | - **FluentValidation**: By ensuring proper validation of incoming requests, FluentValidation contributes to the reliability of the application's data handling. 51 | - **Hangfire**: Permits the application to execute background processing essential for event management. 52 | 53 | ## Generating strongly typed ids 54 | A distinctive aspect of the project is the use of strongly typed IDs, which are employed as unique identifiers throughout the database. These IDs are prefixed with the corresponding domain abbreviation, mitigating potential errors when dealing with foreign keys and database joins—drawing inspiration from the concept of Stripes object IDs. 55 | 56 | ## Api 57 | Serving as the primary interface for the application, the API is built using .NET and is responsible for routing incoming requests. This involves directing requests to their respective handlers within the "Components" section. Additionally, the "Api" domain encompasses the creation of Data Transfer Objects (DTOs) that bridge the gap between the API and the components, along with filters like action and authorization filters, if needed. 58 | 59 | It consists of the following: 60 | - Controllers: Exposing api routes and directing routes to the different handlers in components 61 | - Dtos: If some DTOs differs from the requests/responses in components 62 | - Filters: In case filters are needed (e.g. action-, authorization-filters, etc.) 63 | 64 | ## Components 65 | At the heart of the application's business logic, the "Components" domain handles all CRUD (Create, Read, Update, Delete) operations. This domain is subdivided into queries (for read operations) and commands (for write operations), adhering to the principles of Command Query Responsibility Segregation (CQRS). 66 | 67 | ## DataModel 68 | Within this section, the application's data structures and relationships are defined. This underpins the database schema and the models that interact with the data. 69 | 70 | ## Events 71 | Events, encapsulated as event models or objects, facilitate communication across the application's various components. By utilizing an "IEvent" interface, events are decoupled from specific components and can trigger actions more flexibly. 72 | 73 | ## EventHandlers 74 | Event handlers play a crucial role in responding to events propagated within the application. Through the use of an "IEventHandler" interface, these handlers execute specific actions in response to events, fostering loose coupling between different parts of the system. 75 | 76 | ## Infrastructure 77 | The "Infrastructure" section addresses cross-cutting concerns and setup tasks. This includes implementing authentication, Cross-Origin Resource Sharing (CORS), middleware, and more. Notably, the infrastructure layer incorporates the concepts of CQRS, employing a mediator, command, query, and event bus pattern to facilitate communication and interaction between components. 78 | 79 | Lastly, the inclusion of Swagger for API documentation enhances the project's usability and developer experience by providing clear insights into the API's structure and endpoints. 80 | 81 | ## BackgroundWorker 82 | A background worker serves the purpose of event management. Events are employed to manage side effects, and consequently, we aim to prevent events from obstructing endpoint execution. In this project, Hangfire is employed, and to reduce the workload on the API instance, it has been relocated to a separate instance. This can be effortlessly accomplished by leveraging the infrastructure to configure its dependencies. 83 | -------------------------------------------------------------------------------- /SampleApplication.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api\Api.csproj", "{8667AF17-5FA5-4185-BBCC-FBB7AC54872B}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{77556BCB-F53A-46C2-82ED-C2536ECDA8DE}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events", "Events\Events.csproj", "{50C965BB-D3FB-4CAB-A229-9602DAB117EC}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventHandlers", "EventHandlers\EventHandlers.csproj", "{31020EBB-489B-4A01-8BE5-F5833240089B}" 10 | EndProject 11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{23419B50-248E-4AD7-8F59-B727263C0704}" 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataModel", "DataModel\DataModel.csproj", "{C443A34C-A9D0-49A3-8258-A2E9E4778361}" 14 | EndProject 15 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BackgroundWorker", "BackgroundWorker\BackgroundWorker.csproj", "{3E5FB4CE-4E33-4562-BDCA-C6C6D395209D}" 16 | EndProject 17 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components.UnitTests", "Components.UnitTests\Components.UnitTests.csproj", "{FE4232B9-16E0-4454-B179-C2EEDC5F39A7}" 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.IntegrationTests", "Api.IntegrationTests\Api.IntegrationTests.csproj", "{26458907-18B0-4302-9884-CBB25AB83264}" 20 | EndProject 21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A8293E7C-E5B8-4DBF-A20D-7BA69EACAF27}" 22 | EndProject 23 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventHandlers.UnitTests", "EventHandlers.UnitTests\EventHandlers.UnitTests.csproj", "{65B0E294-56AA-4E3B-BC4C-D0C721334A8B}" 24 | EndProject 25 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{3B3B6578-7EA9-4475-A5F6-C33F372AAE19}" 26 | EndProject 27 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotificationService", "NotificationService\NotificationService.csproj", "{3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5}" 28 | EndProject 29 | Global 30 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 31 | Debug|Any CPU = Debug|Any CPU 32 | Release|Any CPU = Release|Any CPU 33 | EndGlobalSection 34 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 35 | {8667AF17-5FA5-4185-BBCC-FBB7AC54872B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {8667AF17-5FA5-4185-BBCC-FBB7AC54872B}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {8667AF17-5FA5-4185-BBCC-FBB7AC54872B}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {8667AF17-5FA5-4185-BBCC-FBB7AC54872B}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {77556BCB-F53A-46C2-82ED-C2536ECDA8DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {77556BCB-F53A-46C2-82ED-C2536ECDA8DE}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {77556BCB-F53A-46C2-82ED-C2536ECDA8DE}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {77556BCB-F53A-46C2-82ED-C2536ECDA8DE}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {50C965BB-D3FB-4CAB-A229-9602DAB117EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {50C965BB-D3FB-4CAB-A229-9602DAB117EC}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {50C965BB-D3FB-4CAB-A229-9602DAB117EC}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {50C965BB-D3FB-4CAB-A229-9602DAB117EC}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {31020EBB-489B-4A01-8BE5-F5833240089B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 48 | {31020EBB-489B-4A01-8BE5-F5833240089B}.Debug|Any CPU.Build.0 = Debug|Any CPU 49 | {31020EBB-489B-4A01-8BE5-F5833240089B}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {31020EBB-489B-4A01-8BE5-F5833240089B}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {23419B50-248E-4AD7-8F59-B727263C0704}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 52 | {23419B50-248E-4AD7-8F59-B727263C0704}.Debug|Any CPU.Build.0 = Debug|Any CPU 53 | {23419B50-248E-4AD7-8F59-B727263C0704}.Release|Any CPU.ActiveCfg = Release|Any CPU 54 | {23419B50-248E-4AD7-8F59-B727263C0704}.Release|Any CPU.Build.0 = Release|Any CPU 55 | {C443A34C-A9D0-49A3-8258-A2E9E4778361}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {C443A34C-A9D0-49A3-8258-A2E9E4778361}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {C443A34C-A9D0-49A3-8258-A2E9E4778361}.Release|Any CPU.ActiveCfg = Release|Any CPU 58 | {C443A34C-A9D0-49A3-8258-A2E9E4778361}.Release|Any CPU.Build.0 = Release|Any CPU 59 | {3E5FB4CE-4E33-4562-BDCA-C6C6D395209D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 60 | {3E5FB4CE-4E33-4562-BDCA-C6C6D395209D}.Debug|Any CPU.Build.0 = Debug|Any CPU 61 | {3E5FB4CE-4E33-4562-BDCA-C6C6D395209D}.Release|Any CPU.ActiveCfg = Release|Any CPU 62 | {3E5FB4CE-4E33-4562-BDCA-C6C6D395209D}.Release|Any CPU.Build.0 = Release|Any CPU 63 | {FE4232B9-16E0-4454-B179-C2EEDC5F39A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 64 | {FE4232B9-16E0-4454-B179-C2EEDC5F39A7}.Debug|Any CPU.Build.0 = Debug|Any CPU 65 | {FE4232B9-16E0-4454-B179-C2EEDC5F39A7}.Release|Any CPU.ActiveCfg = Release|Any CPU 66 | {FE4232B9-16E0-4454-B179-C2EEDC5F39A7}.Release|Any CPU.Build.0 = Release|Any CPU 67 | {26458907-18B0-4302-9884-CBB25AB83264}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 68 | {26458907-18B0-4302-9884-CBB25AB83264}.Debug|Any CPU.Build.0 = Debug|Any CPU 69 | {26458907-18B0-4302-9884-CBB25AB83264}.Release|Any CPU.ActiveCfg = Release|Any CPU 70 | {26458907-18B0-4302-9884-CBB25AB83264}.Release|Any CPU.Build.0 = Release|Any CPU 71 | {65B0E294-56AA-4E3B-BC4C-D0C721334A8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 72 | {65B0E294-56AA-4E3B-BC4C-D0C721334A8B}.Debug|Any CPU.Build.0 = Debug|Any CPU 73 | {65B0E294-56AA-4E3B-BC4C-D0C721334A8B}.Release|Any CPU.ActiveCfg = Release|Any CPU 74 | {65B0E294-56AA-4E3B-BC4C-D0C721334A8B}.Release|Any CPU.Build.0 = Release|Any CPU 75 | {3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 76 | {3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5}.Debug|Any CPU.Build.0 = Debug|Any CPU 77 | {3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5}.Release|Any CPU.ActiveCfg = Release|Any CPU 78 | {3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5}.Release|Any CPU.Build.0 = Release|Any CPU 79 | EndGlobalSection 80 | GlobalSection(NestedProjects) = preSolution 81 | {26458907-18B0-4302-9884-CBB25AB83264} = {A8293E7C-E5B8-4DBF-A20D-7BA69EACAF27} 82 | {FE4232B9-16E0-4454-B179-C2EEDC5F39A7} = {A8293E7C-E5B8-4DBF-A20D-7BA69EACAF27} 83 | {65B0E294-56AA-4E3B-BC4C-D0C721334A8B} = {A8293E7C-E5B8-4DBF-A20D-7BA69EACAF27} 84 | {3F9C2AB1-4E5D-47B4-9CC7-B0EC4E67C3F5} = {3B3B6578-7EA9-4475-A5F6-C33F372AAE19} 85 | EndGlobalSection 86 | EndGlobal 87 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | db: 5 | image: mcr.microsoft.com/azure-sql-edge:latest 6 | cap_add: [ 'SYS_PTRACE' ] 7 | container_name: db 8 | restart: on-failure 9 | environment: 10 | - "ACCEPT_EULA=1" 11 | - "MSSQL_SA_PASSWORD=pass123!" 12 | - "MSSQL_PID=Developer" 13 | - "MSSQL_USER=SA" 14 | ports: 15 | - "1433:1433" 16 | 17 | api: 18 | build: 19 | context: . 20 | dockerfile: Api/Dockerfile 21 | container_name: api 22 | restart: unless-stopped 23 | environment: 24 | - "ASPNETCORE_ENVIRONMENT=DockerDevelopment" 25 | ports: 26 | - "80:80" 27 | depends_on: 28 | - db 29 | 30 | background-worker: 31 | build: 32 | context: . 33 | dockerfile: BackgroundWorker/Dockerfile 34 | container_name: background-worker 35 | restart: unless-stopped 36 | environment: 37 | - "ASPNETCORE_ENVIRONMENT=DockerDevelopment" 38 | ports: 39 | - "81:80" 40 | depends_on: 41 | - db 42 | --------------------------------------------------------------------------------