├── .dockerignore ├── .gitattributes ├── Empty ├── Warehouse.Api │ ├── appsettings.Development.json │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.json │ ├── Warehouse.Api.csproj │ ├── Middlewares │ │ └── ExceptionHandling │ │ │ ├── ExceptionToHttpStatusMapper.cs │ │ │ └── ExceptionHandlingMiddleware.cs │ ├── Migrations │ │ ├── 20230205120130_Initial.cs │ │ ├── WarehouseDBContextModelSnapshot.cs │ │ └── 20230205120130_Initial.Designer.cs │ ├── Core │ │ └── Validation.cs │ └── Program.cs └── Warehouse.Api.Tests │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── WarehouseTestWebApplicationFactory.cs │ ├── Products │ ├── GettingProductDetails │ │ └── GetProductDetailsTests.cs │ ├── RegisteringProduct │ │ └── RegisterProductTests.cs │ └── GettingProducts │ │ └── GetProductsTests.cs │ └── Warehouse.Api.Tests.csproj ├── Final ├── Warehouse.Api │ ├── appsettings.Development.json │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.json │ ├── Program.cs │ ├── Warehouse.Api.csproj │ └── Middlewares │ │ └── ExceptionHandling │ │ ├── ExceptionToHttpStatusMapper.cs │ │ └── ExceptionHandlingMiddleware.cs ├── Warehouse.Api.Tests │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── WarehouseTestWebApplicationFactory.cs │ ├── Products │ │ ├── GettingProductDetails │ │ │ └── GetProductDetailsTests.cs │ │ ├── RegisteringProduct │ │ │ └── RegisterProductTests.cs │ │ └── GettingProducts │ │ │ └── GetProductsTests.cs │ └── Warehouse.Api.Tests.csproj └── Warehouse │ ├── Products │ ├── Primitives │ │ └── Primitives.cs │ ├── ProductsRepository.cs │ ├── GettingProductDetails │ │ ├── Endpoint.cs │ │ └── GetProductDetails.cs │ ├── Product.cs │ ├── GettingProducts │ │ ├── Endpoint.cs │ │ └── GetProductsEndpoint.cs │ ├── RegisteringProduct │ │ ├── RegisterProduct.cs │ │ └── Endpoint.cs │ └── Configuration.cs │ ├── Core │ ├── Entities │ │ └── EntitiesExtensions.cs │ └── Validation.cs │ ├── Warehouse.csproj │ ├── Configuration.cs │ ├── Migrations │ ├── 20230205120130_Initial.cs │ ├── WarehouseDBContextModelSnapshot.cs │ └── 20230205120130_Initial.Designer.cs │ └── Storage │ └── WarehouseDBContext.cs ├── Mediator ├── Warehouse.Api │ ├── appsettings.Development.json │ ├── Requests │ │ └── ProductsRequests.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.json │ ├── Program.cs │ ├── Controllers │ │ ├── ProductsCommandsController.cs │ │ └── ProductsQueriesController.cs │ ├── Warehouse.Api.csproj │ └── Middlewares │ │ └── ExceptionHandling │ │ ├── ExceptionToHttpStatusMapper.cs │ │ └── ExceptionHandlingMiddleware.cs ├── Warehouse.Api.Tests │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── WarehouseTestWebApplicationFactory.cs │ ├── Products │ │ ├── GettingProductDetails │ │ │ └── GetProductDetailsTests.cs │ │ ├── RegisteringProduct │ │ │ └── RegisterProductTests.cs │ │ └── GettingProducts │ │ │ └── GetProductsTests.cs │ └── Warehouse.Api.Tests.csproj └── Warehouse │ ├── Products │ ├── ValueObjects │ │ └── Primitives.cs │ ├── QueryHandlers │ │ ├── GetProductDetailsHandler.cs │ │ ├── GetProductsHandler.cs │ │ └── Queries.cs │ ├── CommandHandlers │ │ ├── Commands.cs │ │ └── RegisterProductHandler.cs │ ├── Configuration.cs │ ├── Product.cs │ └── Repositories │ │ └── ProductsRepository.cs │ ├── Migrations │ ├── 20230205120130_Initial.cs │ ├── WarehouseDBContextModelSnapshot.cs │ └── 20230205120130_Initial.Designer.cs │ ├── Warehouse.csproj │ ├── Configuration.cs │ ├── Core │ ├── Commands.cs │ ├── Queries.cs │ └── Validation.cs │ └── Storage │ └── WarehouseDBContext.cs ├── ApplicationService ├── Warehouse.Api │ ├── appsettings.Development.json │ ├── Requests │ │ └── ProductsRequests.cs │ ├── Properties │ │ └── launchSettings.json │ ├── appsettings.json │ ├── Program.cs │ ├── Controllers │ │ ├── ProductsCommandsController.cs │ │ └── ProductsQueriesController.cs │ ├── Warehouse.Api.csproj │ └── Middlewares │ │ └── ExceptionHandling │ │ ├── ExceptionToHttpStatusMapper.cs │ │ └── ExceptionHandlingMiddleware.cs ├── Warehouse.Api.Tests │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── WarehouseTestWebApplicationFactory.cs │ ├── Products │ │ ├── GettingProductDetails │ │ │ └── GetProductDetailsTests.cs │ │ ├── RegisteringProduct │ │ │ └── RegisterProductTests.cs │ │ └── GettingProducts │ │ │ └── GetProductsTests.cs │ └── Warehouse.Api.Tests.csproj └── Warehouse │ ├── Products │ ├── Primitives.cs │ ├── ProductsRepository.cs │ ├── Commands.cs │ ├── Queries.cs │ ├── ProductsCommandService.cs │ ├── Product.cs │ ├── Configuration.cs │ └── ProductsQueryService.cs │ ├── Core │ ├── EntitiesExtensions.cs │ └── Validation.cs │ ├── Migrations │ ├── 20230205120130_Initial.cs │ ├── WarehouseDBContextModelSnapshot.cs │ └── 20230205120130_Initial.Designer.cs │ ├── Warehouse.csproj │ ├── Configuration.cs │ └── Storage │ └── WarehouseDBContext.cs ├── Directory.Build.props ├── Core.Build.props ├── .github ├── FUNDING.yml └── workflows │ ├── build.docker.yml │ └── build.dotnet.yml ├── docker-compose.yml ├── Dockerfile ├── README.md ├── .gitignore ├── .editorconfig └── Warehouse.sln /.dockerignore: -------------------------------------------------------------------------------- 1 | **/bin/ 2 | **/obj/ 3 | **/out/ 4 | **/TestResults/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.png binary 3 | *.ttf binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.pdf binary -------------------------------------------------------------------------------- /Empty/Warehouse.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enable 5 | true 6 | enable 7 | 8 | 9 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Requests/ProductsRequests.cs: -------------------------------------------------------------------------------- 1 | namespace Warehouse.Api.Requests; 2 | 3 | public record RegisterProductRequest( 4 | string SKU, 5 | string Name, 6 | string? Description 7 | ); 8 | 9 | public record GetProductsRequest( 10 | string? Filter, 11 | int? Page, 12 | int? PageSize 13 | ); 14 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Requests/ProductsRequests.cs: -------------------------------------------------------------------------------- 1 | namespace Warehouse.Api.Requests; 2 | 3 | public record RegisterProductRequest( 4 | string SKU, 5 | string Name, 6 | string? Description 7 | ); 8 | 9 | public record GetProductsRequest( 10 | string? Filter, 11 | int? Page, 12 | int? PageSize 13 | ); 14 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/Primitives.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public readonly record struct ProductId(Guid Value) 6 | { 7 | public static ProductId From(Guid? productId) => 8 | new(productId.AssertNotEmpty()); 9 | } 10 | 11 | public record SKU(string Value) 12 | { 13 | public static SKU From(string? sku) => 14 | new(sku.AssertMatchesRegex("[A-Z]{2,4}[0-9]{4,18}")); 15 | } 16 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/ValueObjects/Primitives.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public readonly record struct ProductId(Guid Value) 6 | { 7 | public static ProductId From(Guid? productId) => 8 | new(productId.AssertNotEmpty()); 9 | } 10 | 11 | public record SKU(string Value) 12 | { 13 | public static SKU From(string? sku) => 14 | new(sku.AssertMatchesRegex("[A-Z]{2,4}[0-9]{4,18}")); 15 | } 16 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/Primitives/Primitives.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products.Primitives; 4 | 5 | public readonly record struct ProductId(Guid Value) 6 | { 7 | public static ProductId From(Guid? productId) => 8 | new(productId.AssertNotEmpty()); 9 | } 10 | 11 | public record SKU(string Value) 12 | { 13 | public static SKU From(string? sku) => 14 | new(sku.AssertMatchesRegex("[A-Z]{2,4}[0-9]{4,18}")); 15 | } 16 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/ProductsRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Warehouse.Products.Primitives; 3 | using Warehouse.Storage; 4 | 5 | namespace Warehouse.Products; 6 | 7 | internal static class ProductsRepository 8 | { 9 | public static ValueTask ProductWithSKUExists(this WarehouseDBContext dbContext, SKU productSKU, CancellationToken ct) 10 | => new (dbContext.Set().AnyAsync(product => product.Sku.Value == productSKU.Value, ct)); 11 | } -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/ProductsRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Warehouse.Storage; 3 | 4 | namespace Warehouse.Products; 5 | 6 | internal static class ProductsRepository 7 | { 8 | public static ValueTask ProductWithSKUExists( 9 | this WarehouseDBContext dbContext, 10 | SKU productSKU, 11 | CancellationToken ct 12 | ) => 13 | new(dbContext.Set().AnyAsync(product => product.Sku.Value == productSKU.Value, ct)); 14 | } 15 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Products.Api": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": "true", 7 | "launchBrowser": true, 8 | "launchUrl": "api/products", 9 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Products.Api": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": "true", 7 | "launchBrowser": true, 8 | "launchUrl": "api/products", 9 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Products.Api": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": "true", 7 | "launchBrowser": true, 8 | "launchUrl": "api/products", 9 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Products.Api": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": "true", 7 | "launchBrowser": true, 8 | "launchUrl": "api/products", 9 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres';" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres';" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'; searchpath = 'public'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ConnectionStrings": { 10 | "WarehouseDB": "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'; searchpath = 'public'" 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/QueryHandlers/GetProductDetailsHandler.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | internal class GetProductDetailsHandler: IQueryHandler 6 | { 7 | private readonly IProductRepository repository; 8 | 9 | public GetProductDetailsHandler(IProductRepository repository) => 10 | this.repository = repository; 11 | 12 | public ValueTask Handle(GetProductDetails query, CancellationToken ct) => 13 | repository.GetProductDetails(query.ProductId, ct); 14 | } 15 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/QueryHandlers/GetProductsHandler.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | internal class GetProductsHandler: IQueryHandler> 6 | { 7 | private readonly IProductRepository repository; 8 | 9 | public GetProductsHandler(IProductRepository repository) => 10 | this.repository = repository; 11 | 12 | public ValueTask> Handle(GetProducts query, CancellationToken ct) => 13 | repository.GetProducts(query.Filter, query.Page, query.PageSize, ct); 14 | } 15 | -------------------------------------------------------------------------------- /Core.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | true 6 | latest 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Final/Warehouse/Core/Entities/EntitiesExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace Warehouse.Core.Entities; 4 | 5 | public static class EntitiesExtensions 6 | { 7 | public static async ValueTask AddAndSave(this DbContext dbContext, T entity, CancellationToken ct) 8 | where T : notnull 9 | { 10 | await dbContext.AddAsync(entity, ct); 11 | await dbContext.SaveChangesAsync(ct); 12 | } 13 | 14 | public static ValueTask Find(this DbContext dbContext, TId id, CancellationToken ct) 15 | where T : class where TId : notnull 16 | => dbContext.FindAsync(new object[] {id}, ct); 17 | } -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Core/EntitiesExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace Warehouse.Core; 4 | 5 | public static class EntitiesExtensions 6 | { 7 | public static async ValueTask AddAndSave(this DbContext dbContext, T entity, CancellationToken ct) 8 | where T : notnull 9 | { 10 | await dbContext.AddAsync(entity, ct); 11 | await dbContext.SaveChangesAsync(ct); 12 | } 13 | 14 | public static ValueTask Find(this DbContext dbContext, TId id, CancellationToken ct) 15 | where T : class where TId : notnull 16 | => dbContext.FindAsync(new object[] { id }, ct); 17 | } 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [oskardudycz] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # eventsourcingnetcore 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Warehouse; 2 | using Warehouse.Api.Middlewares.ExceptionHandling; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.Services 7 | .AddWarehouseServices() 8 | .AddEndpointsApiExplorer() 9 | .AddSwaggerGen() 10 | .AddControllers(); 11 | 12 | var app = builder.Build(); 13 | 14 | app.UseExceptionHandlingMiddleware() 15 | .UseRouting() 16 | .UseEndpoints(endpoints => 17 | endpoints.MapControllers() 18 | ) 19 | .ConfigureWarehouse() 20 | .UseSwagger() 21 | .UseSwaggerUI(); 22 | 23 | app.Run(); 24 | 25 | namespace Warehouse.Api 26 | { 27 | public partial class Program 28 | { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Warehouse; 2 | using Warehouse.Api.Middlewares.ExceptionHandling; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.Services 7 | .AddWarehouseServices() 8 | .AddEndpointsApiExplorer() 9 | .AddSwaggerGen() 10 | .AddControllers(); 11 | 12 | var app = builder.Build(); 13 | 14 | app.UseExceptionHandlingMiddleware() 15 | .UseRouting() 16 | .UseEndpoints(endpoints => 17 | endpoints.MapControllers() 18 | ) 19 | .ConfigureWarehouse() 20 | .UseSwagger() 21 | .UseSwaggerUI(); 22 | 23 | app.Run(); 24 | 25 | namespace Warehouse.Api 26 | { 27 | public partial class Program 28 | { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Warehouse; 2 | using Warehouse.Api.Middlewares.ExceptionHandling; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.Services 7 | .AddRouting() 8 | .AddWarehouseServices() 9 | .AddEndpointsApiExplorer() 10 | .AddSwaggerGen(); 11 | 12 | var app = builder.Build(); 13 | 14 | app.UseExceptionHandlingMiddleware() 15 | .UseRouting() 16 | .UseEndpoints(endpoints => 17 | { 18 | endpoints.UseWarehouseEndpoints(); 19 | }) 20 | .ConfigureWarehouse() 21 | .UseSwagger() 22 | .UseSwaggerUI(); 23 | 24 | app.Run(); 25 | 26 | namespace Warehouse.Api 27 | { 28 | public partial class Program 29 | { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/Commands.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public record RegisterProduct( 6 | ProductId ProductId, 7 | SKU SKU, 8 | string Name, 9 | string? Description 10 | ) 11 | { 12 | public static RegisterProduct From(Guid id, string sku, string name, string? description) => 13 | new( 14 | ProductId.From(id), 15 | SKU.From(sku), 16 | name.AssertNotEmpty(), 17 | description.AssertNullOrNotEmpty() 18 | ); 19 | } 20 | 21 | public record DeactivateProduct( 22 | ProductId ProductId 23 | ) 24 | { 25 | public static DeactivateProduct From(Guid? id) => 26 | new(ProductId.From(id)); 27 | } 28 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/CommandHandlers/Commands.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public record RegisterProduct( 6 | ProductId ProductId, 7 | SKU SKU, 8 | string Name, 9 | string? Description 10 | ) 11 | { 12 | public static RegisterProduct From(Guid id, string sku, string name, string? description) => 13 | new( 14 | ProductId.From(id), 15 | SKU.From(sku), 16 | name.AssertNotEmpty(), 17 | description.AssertNullOrNotEmpty() 18 | ); 19 | } 20 | 21 | public record DeactivateProduct( 22 | ProductId ProductId 23 | ) 24 | { 25 | public static DeactivateProduct From(Guid? id) => 26 | new(ProductId.From(id)); 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/build.docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Check Out Repo 11 | uses: actions/checkout@v3 12 | 13 | - name: Set up Docker Buildx 14 | id: buildx 15 | uses: docker/setup-buildx-action@v2 16 | 17 | - name: Build and push 18 | id: docker_build 19 | uses: docker/build-push-action@v4 20 | with: 21 | push: false 22 | tags: oskardudycz/cqrs_is_simpler_with_net:latest 23 | 24 | - name: Image digest 25 | run: echo ${{ steps.docker_build.outputs.digest }} 26 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/CommandHandlers/RegisterProductHandler.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | internal class RegisterProductHandler: ICommandHandler 6 | { 7 | private readonly IProductRepository repository; 8 | 9 | public RegisterProductHandler(IProductRepository repository) => 10 | this.repository = repository; 11 | 12 | public async ValueTask Handle(RegisterProduct command, CancellationToken ct) 13 | { 14 | if (await repository.ProductWithSKUExists(command.SKU, ct)) 15 | throw new InvalidOperationException( 16 | $"Product with SKU `{command.SKU} already exists."); 17 | 18 | var product = new Product( 19 | command.ProductId, 20 | command.SKU, 21 | command.Name, 22 | command.Description 23 | ); 24 | 25 | await repository.Add(product, ct); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Controllers/ProductsCommandsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Core; 4 | using Warehouse.Products; 5 | 6 | namespace Warehouse.Api.Controllers; 7 | 8 | [Route("api/products")] 9 | public class ProductsCommandsController: Controller 10 | { 11 | private readonly ICommandBus commandBus; 12 | 13 | public ProductsCommandsController(ICommandBus commandBus) => 14 | this.commandBus = commandBus; 15 | 16 | [HttpPost] 17 | public async Task Register([FromBody] RegisterProductRequest request, CancellationToken ct) 18 | { 19 | var (sku, name, description) = request; 20 | var productId = Guid.NewGuid(); 21 | 22 | var command = RegisterProduct.From(productId, sku, name, description); 23 | 24 | await commandBus.Send(command, ct); 25 | 26 | return Created($"api/products/{productId}", productId); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/Queries.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public record GetProducts(string? Filter, int Page, int PageSize) 6 | { 7 | private const int DefaultPage = 1; 8 | private const int DefaultPageSize = 10; 9 | 10 | public static GetProducts From(string? filter, int? page, int? pageSize) => 11 | new( 12 | filter, 13 | (page ?? DefaultPage).AssertPositive(), 14 | (pageSize ?? DefaultPageSize).AssertPositive() 15 | ); 16 | } 17 | 18 | public record ProductListItem( 19 | Guid Id, 20 | string Sku, 21 | string Name 22 | ); 23 | 24 | public record GetProductDetails(ProductId ProductId) 25 | { 26 | public static GetProductDetails From(Guid productId) => 27 | new(ProductId.From(productId)); 28 | } 29 | 30 | public record ProductDetails( 31 | Guid Id, 32 | string Sku, 33 | string Name, 34 | string? Description 35 | ); 36 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/QueryHandlers/Queries.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public record GetProducts(string? Filter, int Page, int PageSize) 6 | { 7 | private const int DefaultPage = 1; 8 | private const int DefaultPageSize = 10; 9 | 10 | public static GetProducts From(string? filter, int? page, int? pageSize) => 11 | new( 12 | filter, 13 | (page ?? DefaultPage).AssertPositive(), 14 | (pageSize ?? DefaultPageSize).AssertPositive() 15 | ); 16 | } 17 | 18 | public record ProductListItem( 19 | Guid Id, 20 | string Sku, 21 | string Name 22 | ); 23 | 24 | public record GetProductDetails(ProductId ProductId) 25 | { 26 | public static GetProductDetails From(Guid productId) => 27 | new(ProductId.From(productId)); 28 | } 29 | 30 | public record ProductDetails( 31 | Guid Id, 32 | string Sku, 33 | string Name, 34 | string? Description 35 | ); 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | ####################################################### 4 | # Postgres 5 | ####################################################### 6 | postgres: 7 | image: postgres:15.1-alpine 8 | container_name: postgres 9 | ports: 10 | - "5432:5432" 11 | environment: 12 | - POSTGRES_DB=postgres 13 | - POSTGRES_PASSWORD=Password12! 14 | networks: 15 | - pg_network 16 | 17 | pgadmin: 18 | image: dpage/pgadmin4 19 | container_name: pgadmin 20 | environment: 21 | PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@pgadmin.org} 22 | PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} 23 | ports: 24 | - "${PGADMIN_PORT:-5050}:80" 25 | networks: 26 | - pg_network 27 | 28 | networks: 29 | pg_network: 30 | driver: bridge 31 | 32 | volumes: 33 | postgres: 34 | pgadmin: 35 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Controllers/ProductsCommandsController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | 5 | namespace Warehouse.Api.Controllers; 6 | 7 | [Route("api/products")] 8 | public class ProductsCommandsController: Controller 9 | { 10 | private readonly ProductsCommandService productsCommandService; 11 | 12 | public ProductsCommandsController(ProductsCommandService productsCommandService) => 13 | this.productsCommandService = productsCommandService; 14 | 15 | [HttpPost] 16 | public async Task Register([FromBody] RegisterProductRequest request, CancellationToken ct) 17 | { 18 | var (sku, name, description) = request; 19 | var productId = Guid.NewGuid(); 20 | 21 | var command = RegisterProduct.From(productId, sku, name, description); 22 | 23 | await productsCommandService.Handle(command, ct); 24 | 25 | return Created($"api/products/{productId}", productId); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Controllers/ProductsQueriesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | 5 | namespace Warehouse.Api.Controllers; 6 | 7 | [Route("api/products")] 8 | public class ProductsQueriesController: Controller 9 | { 10 | private readonly ProductsQueryService queryService; 11 | 12 | public ProductsQueriesController(ProductsQueryService queryService) => 13 | this.queryService = queryService; 14 | 15 | [HttpGet] 16 | public ValueTask> Get( 17 | [FromQuery] GetProductsRequest request, 18 | CancellationToken ct 19 | ) => 20 | queryService.Handle(GetProducts.From(request.Filter, request.Page, request.PageSize), ct); 21 | 22 | [HttpGet("{id:guid}")] 23 | public async Task GetById([FromRoute] Guid id, CancellationToken ct) 24 | { 25 | var product = await queryService.Handle(GetProductDetails.From(id), ct); 26 | 27 | return product != null ? Ok(product) : NotFound(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/build.dotnet.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check Out Repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Start containers 18 | run: docker-compose -f "docker-compose.yml" up -d 19 | 20 | - name: Setup .NET Core 21 | uses: actions/setup-dotnet@v3 22 | with: 23 | dotnet-version: "7.0.x" 24 | 25 | - name: Restore NuGet packages 26 | run: dotnet restore 27 | 28 | - name: Build 29 | run: dotnet build --configuration Release --no-restore 30 | 31 | - name: Run tests 32 | run: dotnet test --configuration Release --no-build --filter Category!=SkipCI 33 | 34 | - name: Stop containers 35 | if: always() 36 | run: docker-compose -f "docker-compose.yml" down 37 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Warehouse.Core; 4 | 5 | namespace Warehouse.Products; 6 | 7 | internal static class Configuration 8 | { 9 | public static IServiceCollection AddProductServices(this IServiceCollection services) => 10 | services 11 | .AddTransient() 12 | .AddCommandHandler() 13 | .AddQueryHandler() 14 | .AddQueryHandler, GetProductsHandler>(); 15 | 16 | public static void SetupProductsModel(this ModelBuilder modelBuilder) 17 | { 18 | var product = modelBuilder.Entity(); 19 | 20 | product 21 | .Property(e => e.Id) 22 | .HasConversion( 23 | typed => typed.Value, 24 | plain => new ProductId(plain) 25 | ); 26 | 27 | product 28 | .OwnsOne(e => e.Sku); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/GettingProductDetails/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Routing; 4 | using static Microsoft.AspNetCore.Http.Results; 5 | 6 | namespace Warehouse.Products.GettingProductDetails; 7 | 8 | internal static class GetProductDetailsEndpoint 9 | { 10 | internal static IEndpointRouteBuilder UseGetProductDetailsEndpoint(this IEndpointRouteBuilder endpoints) 11 | { 12 | endpoints 13 | .MapGet( 14 | "/api/products/{id:guid}", 15 | async ( 16 | IQueryable queryable, 17 | Guid id, 18 | CancellationToken ct 19 | ) => 20 | { 21 | var query = GetProductDetails.From(id); 22 | 23 | var result = await queryable.Query(query, ct); 24 | 25 | return result != null ? Ok(result) : NotFound(); 26 | }) 27 | .Produces() 28 | .Produces(StatusCodes.Status400BadRequest); 29 | 30 | return endpoints; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Controllers/ProductsQueriesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Core; 4 | using Warehouse.Products; 5 | 6 | namespace Warehouse.Api.Controllers; 7 | 8 | [Route("api/products")] 9 | public class ProductsQueriesController: Controller 10 | { 11 | private readonly IQueryBus queryBus; 12 | 13 | public ProductsQueriesController(IQueryBus queryBus) => 14 | this.queryBus = queryBus; 15 | 16 | [HttpGet] 17 | public ValueTask> Get( 18 | [FromQuery] GetProductsRequest request, 19 | CancellationToken ct 20 | ) => 21 | queryBus.Query>( 22 | GetProducts.From(request.Filter, request.Page, request.PageSize), ct 23 | ); 24 | 25 | [HttpGet("{id:guid}")] 26 | public async Task GetById([FromRoute] Guid id, CancellationToken ct) 27 | { 28 | var product = await queryBus.Query(GetProductDetails.From(id), ct); 29 | 30 | return product != null ? Ok(product) : NotFound(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/ProductsCommandService.cs: -------------------------------------------------------------------------------- 1 | namespace Warehouse.Products; 2 | 3 | public class ProductsCommandService 4 | { 5 | private readonly Func addProduct; 6 | private readonly Func> productWithSKUExists; 7 | 8 | internal ProductsCommandService( 9 | Func addProduct, 10 | Func> productWithSKUExists 11 | ) 12 | { 13 | this.addProduct = addProduct; 14 | this.productWithSKUExists = productWithSKUExists; 15 | } 16 | 17 | public async Task Handle(RegisterProduct command, CancellationToken ct) 18 | { 19 | var product = new Product( 20 | command.ProductId, 21 | command.SKU, 22 | command.Name, 23 | command.Description 24 | ); 25 | 26 | if (await productWithSKUExists(command.SKU, ct)) 27 | throw new InvalidOperationException( 28 | $"Product with SKU `{command.SKU} already exists."); 29 | 30 | await addProduct(product, ct); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/GettingProductDetails/GetProductDetails.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Warehouse.Products.Primitives; 3 | 4 | namespace Warehouse.Products.GettingProductDetails; 5 | 6 | public record GetProductDetails(ProductId ProductId) 7 | { 8 | public static GetProductDetails From(Guid productId) => 9 | new(ProductId.From(productId)); 10 | } 11 | 12 | public record ProductDetails( 13 | Guid Id, 14 | string Sku, 15 | string Name, 16 | string? Description 17 | ); 18 | 19 | internal static class GetProductDetailsQuery 20 | { 21 | internal static async ValueTask Query( 22 | this IQueryable products, 23 | GetProductDetails query, 24 | CancellationToken ct 25 | ) 26 | { 27 | var product = await products 28 | .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); 29 | 30 | if (product == null) 31 | return null; 32 | 33 | return new ProductDetails( 34 | product.Id.Value, 35 | product.Sku.Value, 36 | product.Name, 37 | product.Description 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/Product.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace Warehouse.Products; 4 | 5 | internal record Product 6 | { 7 | public required ProductId Id { get; init; } 8 | 9 | /// 10 | /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. 11 | /// 12 | /// 13 | public required SKU Sku { get; init; } 14 | 15 | /// 16 | /// Product Name 17 | /// 18 | public required string Name { get; init; } 19 | 20 | /// 21 | /// Optional Product description 22 | /// 23 | public string? Description { get; init; } 24 | 25 | // Note: this is needed because we're using SKU DTO. 26 | // It would work if we had just primitives 27 | // Should be fixed in .NET 6 28 | private Product() { } 29 | 30 | [SetsRequiredMembers] 31 | public Product(ProductId id, SKU sku, string name, string? description) 32 | { 33 | Id = id; 34 | Sku = sku; 35 | Name = name; 36 | Description = description; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/Product.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace Warehouse.Products; 4 | 5 | internal record Product 6 | { 7 | public required ProductId Id { get; init; } 8 | 9 | /// 10 | /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. 11 | /// 12 | /// 13 | public required SKU Sku { get; init; } 14 | 15 | /// 16 | /// Product Name 17 | /// 18 | public required string Name { get; init; } 19 | 20 | /// 21 | /// Optional Product description 22 | /// 23 | public string? Description { get; init; } 24 | 25 | // Note: this is needed because we're using SKU DTO. 26 | // It would work if we had just primitives 27 | // Should be fixed in .NET 6 28 | private Product() { } 29 | 30 | [SetsRequiredMembers] 31 | public Product(ProductId id, SKU sku, string name, string? description) 32 | { 33 | Id = id; 34 | Sku = sku; 35 | Name = name; 36 | Description = description; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/Product.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Warehouse.Products.Primitives; 3 | 4 | namespace Warehouse.Products; 5 | 6 | public record Product 7 | { 8 | public required ProductId Id { get; init; } 9 | 10 | /// 11 | /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. 12 | /// 13 | /// 14 | public required SKU Sku { get; init; } 15 | 16 | /// 17 | /// Product Name 18 | /// 19 | public required string Name { get; init; } 20 | 21 | /// 22 | /// Optional Product description 23 | /// 24 | public string? Description { get; init; } 25 | 26 | // Note: this is needed because we're using SKU DTO. 27 | // It would work if we had just primitives 28 | // Should be fixed in .NET 6 29 | private Product() { } 30 | 31 | [SetsRequiredMembers] 32 | public Product(ProductId id, SKU sku, string name, string? description) 33 | { 34 | Id = id; 35 | Sku = sku; 36 | Name = name; 37 | Description = description; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/Warehouse.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 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 | <_Parameter1>$(MSBuildProjectName).Tests 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Warehouse.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 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 | <_Parameter1>$(MSBuildProjectName).Tests 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Warehouse.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 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 | <_Parameter1>$(MSBuildProjectName).Tests 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Final/Warehouse/Warehouse.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <_Parameter1>$(MSBuildProjectName).Tests 14 | 15 | 16 | <_Parameter1>$(MSBuildProjectName).Api.Tests 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Final/Warehouse/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Routing; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Warehouse.Products; 6 | using Warehouse.Storage; 7 | 8 | namespace Warehouse; 9 | 10 | public static class WarehouseConfiguration 11 | { 12 | public static IServiceCollection AddWarehouseServices(this IServiceCollection services) 13 | => services 14 | .AddDbContext( 15 | options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB")) 16 | .AddProductServices(); 17 | 18 | public static IEndpointRouteBuilder UseWarehouseEndpoints(this IEndpointRouteBuilder endpoints) 19 | => endpoints.UseProductsEndpoints(); 20 | 21 | public static IApplicationBuilder ConfigureWarehouse(this IApplicationBuilder app) 22 | { 23 | var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; 24 | 25 | if (environment == "Development") 26 | { 27 | app.ApplicationServices.CreateScope().ServiceProvider 28 | .GetRequiredService().Database.Migrate(); 29 | } 30 | 31 | return app; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Warehouse.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <_Parameter1>$(MSBuildProjectName).Tests 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionToHttpStatusMapper.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class HttpStatusCodeInfo 7 | { 8 | public HttpStatusCode Code { get; } 9 | public string Message { get; } 10 | 11 | public HttpStatusCodeInfo(HttpStatusCode code, string message) 12 | { 13 | Code = code; 14 | Message = message; 15 | } 16 | } 17 | 18 | public static class ExceptionToHttpStatusMapper 19 | { 20 | public static Func? CustomMap { get; set; } 21 | 22 | public static HttpStatusCodeInfo Map(Exception exception) 23 | { 24 | var code = exception switch 25 | { 26 | UnauthorizedAccessException _ => HttpStatusCode.Unauthorized, 27 | NotImplementedException _ => HttpStatusCode.NotImplemented, 28 | InvalidOperationException _ => HttpStatusCode.Conflict, 29 | ArgumentException _ => HttpStatusCode.BadRequest, 30 | ValidationException _ => HttpStatusCode.BadRequest, 31 | _ => CustomMap?.Invoke(exception) ?? HttpStatusCode.InternalServerError 32 | }; 33 | 34 | return new HttpStatusCodeInfo(code, exception.Message); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionToHttpStatusMapper.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class HttpStatusCodeInfo 7 | { 8 | public HttpStatusCode Code { get; } 9 | public string Message { get; } 10 | 11 | public HttpStatusCodeInfo(HttpStatusCode code, string message) 12 | { 13 | Code = code; 14 | Message = message; 15 | } 16 | } 17 | 18 | public static class ExceptionToHttpStatusMapper 19 | { 20 | public static Func? CustomMap { get; set; } 21 | 22 | public static HttpStatusCodeInfo Map(Exception exception) 23 | { 24 | var code = exception switch 25 | { 26 | UnauthorizedAccessException _ => HttpStatusCode.Unauthorized, 27 | NotImplementedException _ => HttpStatusCode.NotImplemented, 28 | InvalidOperationException _ => HttpStatusCode.Conflict, 29 | ArgumentException _ => HttpStatusCode.BadRequest, 30 | ValidationException _ => HttpStatusCode.BadRequest, 31 | _ => CustomMap?.Invoke(exception) ?? HttpStatusCode.InternalServerError 32 | }; 33 | 34 | return new HttpStatusCodeInfo(code, exception.Message); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionToHttpStatusMapper.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class HttpStatusCodeInfo 7 | { 8 | public HttpStatusCode Code { get; } 9 | public string Message { get; } 10 | 11 | public HttpStatusCodeInfo(HttpStatusCode code, string message) 12 | { 13 | Code = code; 14 | Message = message; 15 | } 16 | } 17 | 18 | public static class ExceptionToHttpStatusMapper 19 | { 20 | public static Func? CustomMap { get; set; } 21 | 22 | public static HttpStatusCodeInfo Map(Exception exception) 23 | { 24 | var code = exception switch 25 | { 26 | UnauthorizedAccessException _ => HttpStatusCode.Unauthorized, 27 | NotImplementedException _ => HttpStatusCode.NotImplemented, 28 | InvalidOperationException _ => HttpStatusCode.Conflict, 29 | ArgumentException _ => HttpStatusCode.BadRequest, 30 | ValidationException _ => HttpStatusCode.BadRequest, 31 | _ => CustomMap?.Invoke(exception) ?? HttpStatusCode.InternalServerError 32 | }; 33 | 34 | return new HttpStatusCodeInfo(code, exception.Message); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Migrations/20230205120130_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Warehouse.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Product", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "uuid", nullable: false), 19 | SkuValue = table.Column(name: "Sku_Value", type: "text", nullable: false), 20 | Name = table.Column(type: "text", nullable: false), 21 | Description = table.Column(type: "text", nullable: true) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_Product", x => x.Id); 26 | }); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropTable( 33 | name: "Product"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Final/Warehouse/Migrations/20230205120130_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Warehouse.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Product", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "uuid", nullable: false), 19 | SkuValue = table.Column(name: "Sku_Value", type: "text", nullable: false), 20 | Name = table.Column(type: "text", nullable: false), 21 | Description = table.Column(type: "text", nullable: true) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_Product", x => x.Id); 26 | }); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropTable( 33 | name: "Product"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/GettingProducts/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Routing; 5 | using static Microsoft.AspNetCore.Http.Results; 6 | 7 | namespace Warehouse.Products.GettingProducts; 8 | 9 | internal static class GetProductsEndpoint 10 | { 11 | internal static IEndpointRouteBuilder UseGetProductsEndpoint(this IEndpointRouteBuilder endpoints) 12 | { 13 | endpoints 14 | .MapGet( 15 | "/api/products", 16 | async ( 17 | IQueryable products, 18 | [FromQuery] string? filter, 19 | [FromQuery] int? page, 20 | [FromQuery] int? pageSize, 21 | CancellationToken ct 22 | ) => 23 | { 24 | var query = GetProducts.From(filter, page, pageSize); 25 | 26 | var result = await products.Query(query, ct); 27 | 28 | return Ok(result); 29 | }) 30 | .Produces>() 31 | .Produces(StatusCodes.Status400BadRequest) 32 | .Produces(StatusCodes.Status404NotFound); 33 | 34 | return endpoints; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Migrations/20230205120130_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Warehouse.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Product", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "uuid", nullable: false), 19 | SkuValue = table.Column(name: "Sku_Value", type: "text", nullable: false), 20 | Name = table.Column(type: "text", nullable: false), 21 | Description = table.Column(type: "text", nullable: true) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_Product", x => x.Id); 26 | }); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropTable( 33 | name: "Product"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionToHttpStatusMapper.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Net; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class HttpStatusCodeInfo 7 | { 8 | public HttpStatusCode Code { get; } 9 | public string Message { get; } 10 | 11 | public HttpStatusCodeInfo(HttpStatusCode code, string message) 12 | { 13 | Code = code; 14 | Message = message; 15 | } 16 | } 17 | 18 | public static class ExceptionToHttpStatusMapper 19 | { 20 | public static Func? CustomMap { get; set; } 21 | 22 | public static HttpStatusCodeInfo Map(Exception exception) 23 | { 24 | var code = exception switch 25 | { 26 | UnauthorizedAccessException _ => HttpStatusCode.Unauthorized, 27 | NotImplementedException _ => HttpStatusCode.NotImplemented, 28 | InvalidOperationException _ => HttpStatusCode.Conflict, 29 | ArgumentException _ => HttpStatusCode.BadRequest, 30 | ValidationException _ => HttpStatusCode.BadRequest, 31 | _ => CustomMap?.Invoke(exception) ?? HttpStatusCode.InternalServerError 32 | }; 33 | 34 | return new HttpStatusCodeInfo(code, exception.Message); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Migrations/20230205120130_Initial.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace Warehouse.Migrations 7 | { 8 | /// 9 | public partial class Initial : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "Product", 16 | columns: table => new 17 | { 18 | Id = table.Column(type: "uuid", nullable: false), 19 | SkuValue = table.Column(name: "Sku_Value", type: "text", nullable: false), 20 | Name = table.Column(type: "text", nullable: false), 21 | Description = table.Column(type: "text", nullable: true) 22 | }, 23 | constraints: table => 24 | { 25 | table.PrimaryKey("PK_Product", x => x.Id); 26 | }); 27 | } 28 | 29 | /// 30 | protected override void Down(MigrationBuilder migrationBuilder) 31 | { 32 | migrationBuilder.DropTable( 33 | name: "Product"); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Warehouse.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <_Parameter1>$(MSBuildProjectName).Tests 14 | 15 | 16 | <_Parameter1>$(MSBuildProjectName).Api.Tests 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Warehouse.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <_Parameter1>$(MSBuildProjectName).Tests 14 | 15 | 16 | <_Parameter1>$(MSBuildProjectName).Api.Tests 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Warehouse.Products; 5 | using Warehouse.Storage; 6 | 7 | namespace Warehouse; 8 | 9 | public static class WarehouseConfiguration 10 | { 11 | public static IServiceCollection AddWarehouseServices(this IServiceCollection services) 12 | { 13 | return services 14 | .AddDbContext( 15 | options => options.UseNpgsql( 16 | "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'; searchpath = 'public'")) 17 | .AddProductServices(); 18 | } 19 | 20 | public static IApplicationBuilder ConfigureWarehouse(this IApplicationBuilder app) 21 | { 22 | var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; 23 | 24 | if (environment == "Development") 25 | { 26 | var dbContext = app.ApplicationServices.CreateScope().ServiceProvider 27 | .GetRequiredService(); 28 | 29 | dbContext.Database.Migrate(); 30 | } 31 | 32 | return app; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Warehouse.Core; 4 | using Warehouse.Storage; 5 | 6 | namespace Warehouse.Products; 7 | 8 | internal static class Configuration 9 | { 10 | public static IServiceCollection AddProductServices(this IServiceCollection services) => 11 | services 12 | .AddScoped(s => 13 | { 14 | var dbContext = s.GetRequiredService(); 15 | return new ProductsCommandService(dbContext.AddAndSave, dbContext.ProductWithSKUExists); 16 | }) 17 | .AddScoped(s => 18 | { 19 | var dbContext = s.GetRequiredService(); 20 | return new ProductsQueryService(dbContext.Set().AsNoTracking()); 21 | }); 22 | 23 | public static void SetupProductsModel(this ModelBuilder modelBuilder) 24 | { 25 | var product = modelBuilder.Entity(); 26 | 27 | product 28 | .Property(e => e.Id) 29 | .HasConversion( 30 | typed => typed.Value, 31 | plain => new ProductId(plain) 32 | ); 33 | 34 | product 35 | .OwnsOne(e => e.Sku); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/RegisteringProduct/RegisterProduct.cs: -------------------------------------------------------------------------------- 1 | using Warehouse.Core; 2 | using Warehouse.Products.Primitives; 3 | 4 | namespace Warehouse.Products.RegisteringProduct; 5 | 6 | internal static class RegisterProductHandler 7 | { 8 | internal static async Task Handle( 9 | Func addProduct, 10 | Func> productWithSKUExists, 11 | RegisterProduct command, 12 | CancellationToken ct 13 | ) 14 | { 15 | var product = new Product( 16 | command.ProductId, 17 | command.SKU, 18 | command.Name, 19 | command.Description 20 | ); 21 | 22 | if (await productWithSKUExists(command.SKU, ct)) 23 | throw new InvalidOperationException( 24 | $"Product with SKU `{command.SKU} already exists."); 25 | 26 | await addProduct(product, ct); 27 | } 28 | } 29 | 30 | public record RegisterProduct( 31 | ProductId ProductId, 32 | SKU SKU, 33 | string Name, 34 | string? Description 35 | ) 36 | { 37 | public static RegisterProduct From(Guid? id, string sku, string name, string? description) => 38 | new( 39 | ProductId.From(id), 40 | SKU.From(sku), 41 | name.AssertNotEmpty(), 42 | description.AssertNullOrNotEmpty() 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Routing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Warehouse.Products.GettingProductDetails; 5 | using Warehouse.Products.GettingProducts; 6 | using Warehouse.Products.Primitives; 7 | using Warehouse.Products.RegisteringProduct; 8 | using Warehouse.Storage; 9 | 10 | namespace Warehouse.Products; 11 | 12 | internal static class Configuration 13 | { 14 | public static IServiceCollection AddProductServices(this IServiceCollection services) 15 | => services 16 | .AddTransient(sp => 17 | sp.GetRequiredService().Set().AsNoTracking() 18 | ); 19 | 20 | 21 | public static IEndpointRouteBuilder UseProductsEndpoints(this IEndpointRouteBuilder endpoints) => 22 | endpoints 23 | .UseRegisterProductEndpoint() 24 | .UseGetProductsEndpoint() 25 | .UseGetProductDetailsEndpoint(); 26 | 27 | public static void SetupProductsModel(this ModelBuilder modelBuilder) 28 | { 29 | var product = modelBuilder.Entity(); 30 | 31 | product 32 | .Property(e => e.Id) 33 | .HasConversion( 34 | typed => typed.Value, 35 | plain => new ProductId(plain) 36 | ); 37 | 38 | product 39 | .OwnsOne(e => e.Sku); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Warehouse.Core; 5 | using Warehouse.Products; 6 | using Warehouse.Storage; 7 | 8 | namespace Warehouse; 9 | 10 | public static class WarehouseConfiguration 11 | { 12 | public static IServiceCollection AddWarehouseServices(this IServiceCollection services) 13 | { 14 | return services 15 | .AddDbContext( 16 | options => options.UseNpgsql( 17 | "PORT = 5432; HOST = 127.0.0.1; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'; searchpath = 'public'")) 18 | .AddInMemoryCommandBus() 19 | .AddInMemoryQueryBus() 20 | .AddProductServices(); 21 | } 22 | 23 | public static IApplicationBuilder ConfigureWarehouse(this IApplicationBuilder app) 24 | { 25 | var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; 26 | 27 | if (environment == "Development") 28 | { 29 | var dbContext = app.ApplicationServices.CreateScope().ServiceProvider 30 | .GetRequiredService(); 31 | 32 | dbContext.Database.Migrate(); 33 | } 34 | 35 | return app; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/GettingProducts/GetProductsEndpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Warehouse.Core; 3 | 4 | namespace Warehouse.Products.GettingProducts; 5 | 6 | public record GetProducts(string? Filter, int Page, int PageSize) 7 | { 8 | private const int DefaultPage = 1; 9 | private const int DefaultPageSize = 10; 10 | 11 | public static GetProducts From(string? filter, int? page, int? pageSize) => 12 | new( 13 | filter, 14 | (page ?? DefaultPage).AssertPositive(), 15 | (pageSize ?? DefaultPageSize).AssertPositive() 16 | ); 17 | } 18 | 19 | public record ProductListItem( 20 | Guid Id, 21 | string Sku, 22 | string Name 23 | ); 24 | 25 | internal static class GetProductsQuery 26 | { 27 | internal static async ValueTask> Query( 28 | this IQueryable products, 29 | GetProducts query, 30 | CancellationToken ct 31 | ) 32 | { 33 | var (filter, page, pageSize) = query; 34 | 35 | var filteredProducts = string.IsNullOrEmpty(filter) 36 | ? products 37 | : products 38 | .Where(p => 39 | p.Sku.Value.Contains(query.Filter!) || 40 | p.Name.Contains(query.Filter!) || 41 | p.Description!.Contains(query.Filter!) 42 | ); 43 | 44 | return await filteredProducts 45 | .Skip(pageSize * (page - 1)) 46 | .Take(pageSize) 47 | .Select(p => new ProductListItem(p.Id.Value, p.Sku.Value, p.Name)) 48 | .ToListAsync(ct); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/WarehouseTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; 7 | 8 | namespace Warehouse.Api.Tests; 9 | 10 | public class WarehouseTestWebApplicationFactory: WebApplicationFactory 11 | { 12 | private readonly string schemaName = Guid.NewGuid().ToString("N").ToLower(); 13 | 14 | protected override IHost CreateHost(IHostBuilder builder) 15 | { 16 | builder.ConfigureServices(services => 17 | { 18 | services 19 | .AddTransient(s => 20 | { 21 | var connectionString = s.GetRequiredService() 22 | .GetConnectionString("WarehouseDB"); 23 | var options = new DbContextOptionsBuilder(); 24 | options.UseNpgsql( 25 | $"{connectionString}; searchpath = {schemaName.ToLower()}", 26 | x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); 27 | return options.Options; 28 | }); 29 | }); 30 | 31 | var host = base.CreateHost(builder); 32 | 33 | using var scope = host.Services.CreateScope(); 34 | var database = scope.ServiceProvider.GetRequiredService().Database; 35 | database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); 36 | 37 | return host; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/WarehouseTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using Warehouse.Storage; 7 | using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; 8 | 9 | namespace Warehouse.Api.Tests; 10 | 11 | public class WarehouseTestWebApplicationFactory: WebApplicationFactory 12 | { 13 | private readonly string schemaName = Guid.NewGuid().ToString("N").ToLower(); 14 | 15 | protected override IHost CreateHost(IHostBuilder builder) 16 | { 17 | builder.ConfigureServices(services => 18 | { 19 | services 20 | .AddTransient(s => 21 | { 22 | var connectionString = s.GetRequiredService() 23 | .GetConnectionString("WarehouseDB"); 24 | var options = new DbContextOptionsBuilder(); 25 | options.UseNpgsql( 26 | $"{connectionString}; searchpath = {schemaName.ToLower()}", 27 | x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); 28 | return options.Options; 29 | }); 30 | }); 31 | 32 | var host = base.CreateHost(builder); 33 | 34 | using var scope = host.Services.CreateScope(); 35 | var database = scope.ServiceProvider.GetRequiredService().Database; 36 | database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); 37 | 38 | return host; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Final/Warehouse/Storage/WarehouseDBContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | using Warehouse.Products; 5 | 6 | namespace Warehouse.Storage; 7 | 8 | public class WarehouseDBContext: DbContext 9 | { 10 | public WarehouseDBContext(DbContextOptions options) 11 | : base(options) 12 | { 13 | } 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | modelBuilder.SetupProductsModel(); 18 | } 19 | } 20 | 21 | public class WarehouseDBContextFactory: IDesignTimeDbContextFactory 22 | { 23 | public WarehouseDBContext CreateDbContext(params string[] args) 24 | { 25 | var optionsBuilder = new DbContextOptionsBuilder(); 26 | 27 | if (optionsBuilder.IsConfigured) 28 | return new WarehouseDBContext(optionsBuilder.Options); 29 | 30 | //Called by parameterless ctor Usually Migrations 31 | var environmentName = Environment.GetEnvironmentVariable("EnvironmentName") ?? "Development"; 32 | 33 | var connectionString = 34 | new ConfigurationBuilder() 35 | .SetBasePath(AppContext.BaseDirectory) 36 | .AddJsonFile("appsettings.json") 37 | .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false) 38 | .AddEnvironmentVariables() 39 | .Build() 40 | .GetConnectionString("WarehouseDB"); 41 | 42 | optionsBuilder.UseNpgsql(connectionString); 43 | 44 | return new WarehouseDBContext(optionsBuilder.Options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/WarehouseTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using Warehouse.Storage; 7 | using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; 8 | 9 | namespace Warehouse.Api.Tests; 10 | 11 | public class WarehouseTestWebApplicationFactory: WebApplicationFactory 12 | { 13 | private readonly string schemaName = Guid.NewGuid().ToString("N").ToLower(); 14 | 15 | protected override IHost CreateHost(IHostBuilder builder) 16 | { 17 | builder.ConfigureServices(services => 18 | { 19 | services 20 | .AddTransient(s => 21 | { 22 | var connectionString = s.GetRequiredService() 23 | .GetConnectionString("WarehouseDB"); 24 | var options = new DbContextOptionsBuilder(); 25 | options.UseNpgsql( 26 | $"{connectionString}; searchpath = {schemaName.ToLower()}", 27 | x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); 28 | return options.Options; 29 | }); 30 | }); 31 | 32 | var host = base.CreateHost(builder); 33 | 34 | using var scope = host.Services.CreateScope(); 35 | var database = scope.ServiceProvider.GetRequiredService().Database; 36 | database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); 37 | 38 | return host; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/WarehouseTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | using Warehouse.Storage; 7 | using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; 8 | 9 | namespace Warehouse.Api.Tests; 10 | 11 | public class WarehouseTestWebApplicationFactory: WebApplicationFactory 12 | { 13 | private readonly string schemaName = Guid.NewGuid().ToString("N").ToLower(); 14 | 15 | protected override IHost CreateHost(IHostBuilder builder) 16 | { 17 | builder.ConfigureServices(services => 18 | { 19 | services 20 | .AddTransient(s => 21 | { 22 | var connectionString = s.GetRequiredService() 23 | .GetConnectionString("WarehouseDB"); 24 | var options = new DbContextOptionsBuilder(); 25 | options.UseNpgsql( 26 | $"{connectionString}; searchpath = {schemaName.ToLower()}", 27 | x => x.MigrationsHistoryTable("__EFMigrationsHistory", schemaName.ToLower())); 28 | return options.Options; 29 | }); 30 | }); 31 | 32 | var host = base.CreateHost(builder); 33 | 34 | using var scope = host.Services.CreateScope(); 35 | var database = scope.ServiceProvider.GetRequiredService().Database; 36 | database.ExecuteSqlRaw("TRUNCATE TABLE \"Product\""); 37 | 38 | return host; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Storage/WarehouseDBContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | using Warehouse.Products; 5 | 6 | namespace Warehouse.Storage; 7 | 8 | public class WarehouseDBContext: DbContext 9 | { 10 | public WarehouseDBContext(DbContextOptions options) 11 | : base(options) 12 | { 13 | } 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | modelBuilder.SetupProductsModel(); 18 | } 19 | } 20 | 21 | public class WarehouseDBContextFactory: IDesignTimeDbContextFactory 22 | { 23 | public WarehouseDBContext CreateDbContext(params string[] args) 24 | { 25 | var optionsBuilder = new DbContextOptionsBuilder(); 26 | 27 | if (optionsBuilder.IsConfigured) 28 | return new WarehouseDBContext(optionsBuilder.Options); 29 | 30 | //Called by parameterless ctor Usually Migrations 31 | var environmentName = Environment.GetEnvironmentVariable("EnvironmentName") ?? "Development"; 32 | 33 | var connectionString = 34 | new ConfigurationBuilder() 35 | .SetBasePath(AppContext.BaseDirectory) 36 | .AddJsonFile("appsettings.json") 37 | .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false) 38 | .AddEnvironmentVariables() 39 | .Build() 40 | .GetConnectionString("WarehouseDB"); 41 | 42 | optionsBuilder.UseNpgsql(connectionString); 43 | 44 | return new WarehouseDBContext(optionsBuilder.Options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Products/ProductsQueryService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace Warehouse.Products; 4 | 5 | public class ProductsQueryService 6 | { 7 | private readonly IQueryable products; 8 | 9 | internal ProductsQueryService(IQueryable products) => 10 | this.products = products; 11 | 12 | public async ValueTask> Handle(GetProducts query, CancellationToken ct) 13 | { 14 | var (filter, page, pageSize) = query; 15 | 16 | var filteredProducts = string.IsNullOrEmpty(filter) 17 | ? products 18 | : products 19 | .Where(p => 20 | p.Sku.Value.Contains(query.Filter!) || 21 | p.Name.Contains(query.Filter!) || 22 | p.Description!.Contains(query.Filter!) 23 | ); 24 | 25 | var result = await filteredProducts 26 | .Skip(pageSize * (page - 1)) 27 | .Take(pageSize) 28 | .ToListAsync(ct); 29 | 30 | var sth = result 31 | .Select(p => new ProductListItem(p.Id.Value, p.Sku.Value, p.Name)) 32 | .ToList(); 33 | 34 | return sth; 35 | } 36 | 37 | public async ValueTask Handle(GetProductDetails query, CancellationToken ct) 38 | { 39 | var product = await products 40 | .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); 41 | 42 | if (product == null) 43 | return null; 44 | 45 | return new ProductDetails( 46 | product.Id.Value, 47 | product.Sku.Value, 48 | product.Name, 49 | product.Description 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Final/Warehouse/Products/RegisteringProduct/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Routing; 4 | using Warehouse.Core.Entities; 5 | using Warehouse.Storage; 6 | using static Microsoft.AspNetCore.Http.Results; 7 | using static Warehouse.Products.RegisteringProduct.RegisterProductHandler; 8 | 9 | namespace Warehouse.Products.RegisteringProduct; 10 | 11 | public record RegisterProductRequest( 12 | string SKU, 13 | string Name, 14 | string? Description 15 | ); 16 | 17 | internal static class RegisterProductEndpoint 18 | { 19 | internal static IEndpointRouteBuilder UseRegisterProductEndpoint(this IEndpointRouteBuilder endpoints) 20 | { 21 | endpoints.MapPost( 22 | "api/products/", 23 | async ( 24 | WarehouseDBContext dbContext, 25 | RegisterProductRequest request, 26 | CancellationToken ct 27 | ) => 28 | { 29 | var (sku, name, description) = request; 30 | var productId = Guid.NewGuid(); 31 | 32 | var command = RegisterProduct.From(productId, sku, name, description); 33 | 34 | await Handle( 35 | dbContext.AddAndSave, 36 | dbContext.ProductWithSKUExists, 37 | command, 38 | ct 39 | ); 40 | 41 | return Created($"/api/products/{productId}", productId); 42 | }) 43 | .Produces(StatusCodes.Status201Created) 44 | .Produces(StatusCodes.Status400BadRequest); 45 | 46 | return endpoints; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Core/Commands.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Warehouse.Core; 4 | 5 | public interface ICommandHandler 6 | { 7 | ValueTask Handle(T command, CancellationToken token); 8 | } 9 | 10 | public interface ICommandBus 11 | { 12 | ValueTask Send(TCommand command, CancellationToken ct); 13 | } 14 | 15 | public class InMemoryCommandBus: ICommandBus 16 | { 17 | private readonly IServiceProvider serviceProvider; 18 | 19 | public InMemoryCommandBus(IServiceProvider serviceProvider) => 20 | this.serviceProvider = serviceProvider; 21 | 22 | public ValueTask Send(TCommand command, CancellationToken ct) => 23 | serviceProvider.GetRequiredService>().Handle(command, ct); 24 | } 25 | 26 | public static class CommandHandlerConfiguration 27 | { 28 | public static IServiceCollection AddInMemoryCommandBus(this IServiceCollection services) => 29 | services.AddScoped(); 30 | 31 | public static IServiceCollection AddCommandHandler( 32 | this IServiceCollection services, 33 | Func? configure = null 34 | ) where TCommandHandler : class, ICommandHandler 35 | { 36 | if (configure == null) 37 | { 38 | services.AddTransient(); 39 | services.AddTransient, TCommandHandler>(); 40 | } 41 | else 42 | { 43 | services.AddTransient(configure); 44 | services.AddTransient, TCommandHandler>(configure); 45 | } 46 | 47 | return services; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Core/Queries.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Warehouse.Core; 4 | 5 | public interface IQueryHandler 6 | { 7 | ValueTask Handle(TQuery query, CancellationToken ct); 8 | } 9 | 10 | public interface IQueryBus 11 | { 12 | ValueTask Query(TQuery query, CancellationToken ct); 13 | } 14 | 15 | public class InMemoryQueryBus: IQueryBus 16 | { 17 | private readonly IServiceProvider serviceProvider; 18 | 19 | public InMemoryQueryBus(IServiceProvider serviceProvider) => 20 | this.serviceProvider = serviceProvider; 21 | 22 | public ValueTask Query(TQuery query, CancellationToken ct) => 23 | serviceProvider.GetRequiredService>().Handle(query, ct); 24 | } 25 | 26 | public static class QueryHandlerConfiguration 27 | { 28 | public static IServiceCollection AddInMemoryQueryBus(this IServiceCollection services) => 29 | services.AddScoped(); 30 | 31 | public static IServiceCollection AddQueryHandler( 32 | this IServiceCollection services, 33 | Func? configure = null 34 | ) where TQueryHandler : class, IQueryHandler 35 | { 36 | if (configure == null) 37 | { 38 | services.AddTransient(); 39 | services.AddTransient, TQueryHandler>(); 40 | } 41 | else 42 | { 43 | services.AddTransient(configure); 44 | services.AddTransient, TQueryHandler>(configure); 45 | } 46 | 47 | return services; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Storage/WarehouseDBContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Design; 3 | using Microsoft.Extensions.Configuration; 4 | using Warehouse.Products; 5 | 6 | namespace Warehouse.Storage; 7 | 8 | public class WarehouseDBContext: DbContext 9 | { 10 | public WarehouseDBContext(DbContextOptions options) 11 | : base(options) 12 | { 13 | } 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | modelBuilder.SetupProductsModel(); 18 | } 19 | } 20 | 21 | public class WarehouseDBContextFactory: IDesignTimeDbContextFactory 22 | { 23 | public WarehouseDBContext CreateDbContext(params string[] args) 24 | { 25 | var optionsBuilder = new DbContextOptionsBuilder(); 26 | 27 | if (optionsBuilder.IsConfigured) 28 | return new WarehouseDBContext(optionsBuilder.Options); 29 | 30 | //Called by parameterless ctor Usually Migrations 31 | var environmentName = Environment.GetEnvironmentVariable("EnvironmentName") ?? "Development"; 32 | 33 | var connectionString = 34 | new ConfigurationBuilder() 35 | .SetBasePath(AppContext.BaseDirectory) 36 | .AddJsonFile("appsettings.json") 37 | .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false) 38 | .AddEnvironmentVariables() 39 | .Build() 40 | .GetConnectionString("WarehouseDB"); 41 | 42 | optionsBuilder.UseNpgsql(connectionString); 43 | 44 | return new WarehouseDBContext(optionsBuilder.Options); 45 | } 46 | 47 | public static WarehouseDBContext From() 48 | => new WarehouseDBContextFactory().CreateDbContext(); 49 | } 50 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Xunit; 4 | 5 | namespace Warehouse.Api.Tests.Products.GettingProductDetails; 6 | 7 | public class GetProductDetailsTests: IClassFixture 8 | { 9 | private readonly GetProductDetailsFixture API; 10 | 11 | public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; 12 | 13 | [Fact] 14 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 15 | API.Given(URI($"/api/products/{API.ExistingProduct.Id}")) 16 | .When(GET) 17 | .Then(OK, RESPONSE_BODY(API.ExistingProduct)); 18 | 19 | [Theory] 20 | [InlineData(12)] 21 | [InlineData("not-a-guid")] 22 | public Task InvalidGuidId_ShouldReturn_404(object invalidId) => 23 | API.Given(URI($"/api/products/{invalidId}")) 24 | .When(GET) 25 | .Then(NOT_FOUND); 26 | 27 | [Fact] 28 | public Task NotExistingId_ShouldReturn_404() => 29 | API.Given(URI($"/api/products/{Guid.NewGuid()}")) 30 | .When(GET) 31 | .Then(NOT_FOUND); 32 | } 33 | 34 | public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime 35 | { 36 | public ProductDetails ExistingProduct = default!; 37 | 38 | public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 39 | 40 | public async Task InitializeAsync() 41 | { 42 | var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); 43 | var registerResponse = await Send( 44 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 45 | ); 46 | 47 | await CREATED(registerResponse); 48 | 49 | var (sku, name, description) = registerProduct; 50 | ExistingProduct = new ProductDetails(registerResponse.GetCreatedId(), sku!, name!, description); 51 | } 52 | 53 | public Task DisposeAsync() => Task.CompletedTask; 54 | } 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # First stage of multistage build 3 | ######################################## 4 | # Use Build image with label `builder 5 | ######################################## 6 | FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS builder 7 | 8 | # Setup working directory for project 9 | WORKDIR /app 10 | 11 | COPY ./.editorconfig ./ 12 | COPY ./Directory.Build.props ./ 13 | COPY ./Core.Build.props ./ 14 | COPY ./Final/Warehouse/Warehouse.csproj ./Final/Warehouse/ 15 | COPY ./Final/Warehouse.Api/Warehouse.Api.csproj ./Final/Warehouse.Api/ 16 | 17 | # Restore nuget packages 18 | RUN dotnet restore ./Final/Warehouse.Api/Warehouse.Api.csproj 19 | 20 | # Copy project files 21 | COPY ./Final/Warehouse ./Final/Warehouse 22 | COPY ./Final/Warehouse.Api/ ./Final/Warehouse.Api 23 | 24 | # Build project with Release configuration 25 | # and no restore, as we did it already 26 | RUN dotnet build -c Release --no-restore ./Final/Warehouse.Api/Warehouse.Api.csproj 27 | 28 | ## Test project with Release configuration 29 | ## and no build, as we did it already 30 | #RUN dotnet test -c Release --no-build ./Final/Warehouse.Api/Warehouse.Api.Tests.csproj 31 | 32 | 33 | # Publish project to output folder 34 | # and no build, as we did it already 35 | WORKDIR /app/Final/Warehouse.Api 36 | RUN ls 37 | RUN dotnet publish -c Release --no-build -o out 38 | 39 | ######################################## 40 | # Second stage of multistage build 41 | ######################################## 42 | # Use other build image as the final one 43 | # that won't have source codes 44 | ######################################## 45 | FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine 46 | 47 | # Setup working directory for project 48 | WORKDIR /app 49 | 50 | # Copy published in previous stage binaries 51 | # from the `builder` image 52 | COPY --from=builder /app/Final/Warehouse.Api/out . 53 | 54 | # Set URL that App will be exposed 55 | ENV ASPNETCORE_URLS="http://*:5000" 56 | 57 | # sets entry point command to automatically 58 | # run application on `docker run` 59 | ENTRYPOINT ["dotnet", "Warehouse.Api.dll"] 60 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Core/Validation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Warehouse.Api.Core; 6 | 7 | public static class Validation 8 | { 9 | public static Guid AssertNotEmpty( 10 | [NotNull] this Guid? value, 11 | [CallerArgumentExpression("value")] string? argumentName = null 12 | ) => 13 | (value != null && value.Value != Guid.Empty) 14 | ? value.Value 15 | : throw new ArgumentOutOfRangeException(argumentName); 16 | 17 | 18 | public static string AssertNotEmpty( 19 | [NotNull] this string? value, 20 | [CallerArgumentExpression("value")] string? argumentName = null 21 | ) => 22 | !string.IsNullOrWhiteSpace(value) 23 | ? value 24 | : throw new ArgumentOutOfRangeException(argumentName); 25 | 26 | 27 | public static string? AssertNullOrNotEmpty( 28 | this string? value, 29 | [CallerArgumentExpression("value")] string? argumentName = null 30 | ) => 31 | value?.AssertNotEmpty(argumentName); 32 | 33 | public static string AssertMatchesRegex( 34 | [NotNull] this string? value, 35 | [StringSyntax(StringSyntaxAttribute.Regex)] 36 | string pattern, 37 | [CallerArgumentExpression("value")] string? argumentName = null 38 | ) => 39 | Regex.IsMatch(value.AssertNotEmpty(), pattern) 40 | ? value 41 | : throw new ArgumentOutOfRangeException(argumentName); 42 | 43 | public static int AssertPositive( 44 | [NotNull] this int? value, 45 | [CallerArgumentExpression("value")] string? argumentName = null 46 | ) => 47 | value?.AssertPositive() ?? throw new ArgumentOutOfRangeException(argumentName); 48 | 49 | public static int AssertPositive( 50 | this int value, 51 | [CallerArgumentExpression("value")] string? argumentName = null 52 | ) => 53 | value > 0 54 | ? value 55 | : throw new ArgumentOutOfRangeException(argumentName); 56 | } 57 | -------------------------------------------------------------------------------- /Final/Warehouse/Core/Validation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Warehouse.Core; 6 | 7 | public static class Validation 8 | { 9 | public static Guid AssertNotEmpty( 10 | [NotNull] this Guid? value, 11 | [CallerArgumentExpression("value")] string? argumentName = null 12 | ) => 13 | (value != null && value.Value != Guid.Empty) 14 | ? value.Value 15 | : throw new ArgumentOutOfRangeException(argumentName); 16 | 17 | 18 | public static string AssertNotEmpty( 19 | [NotNull] this string? value, 20 | [CallerArgumentExpression("value")] string? argumentName = null 21 | ) => 22 | !string.IsNullOrWhiteSpace(value) 23 | ? value 24 | : throw new ArgumentOutOfRangeException(argumentName); 25 | 26 | 27 | public static string? AssertNullOrNotEmpty( 28 | this string? value, 29 | [CallerArgumentExpression("value")] string? argumentName = null 30 | ) => 31 | value?.AssertNotEmpty(argumentName); 32 | 33 | public static string AssertMatchesRegex( 34 | [NotNull] this string? value, 35 | [StringSyntax(StringSyntaxAttribute.Regex)] 36 | string pattern, 37 | [CallerArgumentExpression("value")] string? argumentName = null 38 | ) 39 | { 40 | return Regex.IsMatch(value.AssertNotEmpty(), pattern) 41 | ? value 42 | : throw new ArgumentOutOfRangeException(argumentName); 43 | } 44 | 45 | public static int AssertPositive( 46 | [NotNull] this int? value, 47 | [CallerArgumentExpression("value")] string? argumentName = null 48 | ) => 49 | value?.AssertPositive() ?? throw new ArgumentOutOfRangeException(argumentName); 50 | 51 | public static int AssertPositive( 52 | this int value, 53 | [CallerArgumentExpression("value")] string? argumentName = null 54 | ) => 55 | value > 0 56 | ? value 57 | : throw new ArgumentOutOfRangeException(argumentName); 58 | } 59 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | using static Ogooreck.API.ApiSpecification; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProductDetails; 8 | 9 | public class GetProductDetailsTests: IClassFixture 10 | { 11 | private readonly GetProductDetailsFixture API; 12 | 13 | public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; 14 | 15 | [Fact] 16 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 17 | API.Given(URI($"/api/products/{API.ExistingProduct.Id}")) 18 | .When(GET) 19 | .Then(OK, RESPONSE_BODY(API.ExistingProduct)); 20 | 21 | [Theory] 22 | [InlineData(12)] 23 | [InlineData("not-a-guid")] 24 | public Task InvalidGuidId_ShouldReturn_404(object invalidId) => 25 | API.Given(URI($"/api/products/{invalidId}")) 26 | .When(GET) 27 | .Then(NOT_FOUND); 28 | 29 | [Fact] 30 | public Task NotExistingId_ShouldReturn_404() => 31 | API.Given(URI($"/api/products/{Guid.NewGuid()}")) 32 | .When(GET) 33 | .Then(NOT_FOUND); 34 | } 35 | 36 | public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime 37 | { 38 | public ProductDetails ExistingProduct = default!; 39 | 40 | public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 41 | 42 | public async Task InitializeAsync() 43 | { 44 | var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); 45 | var registerResponse = await Send( 46 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 47 | ); 48 | 49 | await CREATED(registerResponse); 50 | 51 | var (sku, name, description) = registerProduct; 52 | ExistingProduct = new ProductDetails(registerResponse.GetCreatedId(), sku!, name!, description); 53 | } 54 | 55 | public Task DisposeAsync() => Task.CompletedTask; 56 | } 57 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Core/Validation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Warehouse.Core; 6 | 7 | public static class Validation 8 | { 9 | public static Guid AssertNotEmpty( 10 | [NotNull] this Guid? value, 11 | [CallerArgumentExpression("value")] string? argumentName = null 12 | ) => 13 | (value != null && value.Value != Guid.Empty) 14 | ? value.Value 15 | : throw new ArgumentOutOfRangeException(argumentName); 16 | 17 | 18 | public static string AssertNotEmpty( 19 | [NotNull] this string? value, 20 | [CallerArgumentExpression("value")] string? argumentName = null 21 | ) => 22 | !string.IsNullOrWhiteSpace(value) 23 | ? value 24 | : throw new ArgumentOutOfRangeException(argumentName); 25 | 26 | 27 | public static string? AssertNullOrNotEmpty( 28 | this string? value, 29 | [CallerArgumentExpression("value")] string? argumentName = null 30 | ) => 31 | value?.AssertNotEmpty(argumentName); 32 | 33 | public static string AssertMatchesRegex( 34 | [NotNull] this string? value, 35 | [StringSyntax(StringSyntaxAttribute.Regex)] 36 | string pattern, 37 | [CallerArgumentExpression("value")] string? argumentName = null 38 | ) 39 | { 40 | return Regex.IsMatch(value.AssertNotEmpty(), pattern) 41 | ? value 42 | : throw new ArgumentOutOfRangeException(argumentName); 43 | } 44 | 45 | public static int AssertPositive( 46 | [NotNull] this int? value, 47 | [CallerArgumentExpression("value")] string? argumentName = null 48 | ) => 49 | value?.AssertPositive() ?? throw new ArgumentOutOfRangeException(argumentName); 50 | 51 | public static int AssertPositive( 52 | this int value, 53 | [CallerArgumentExpression("value")] string? argumentName = null 54 | ) => 55 | value > 0 56 | ? value 57 | : throw new ArgumentOutOfRangeException(argumentName); 58 | } 59 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | using static Ogooreck.API.ApiSpecification; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProductDetails; 8 | 9 | public class GetProductDetailsTests: IClassFixture 10 | { 11 | private readonly GetProductDetailsFixture API; 12 | 13 | public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; 14 | 15 | [Fact] 16 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 17 | API.Given(URI($"/api/products/{API.ExistingProduct.Id}")) 18 | .When(GET) 19 | .Then(OK, RESPONSE_BODY(API.ExistingProduct)); 20 | 21 | [Theory] 22 | [InlineData(12)] 23 | [InlineData("not-a-guid")] 24 | public Task InvalidGuidId_ShouldReturn_404(object invalidId) => 25 | API.Given(URI($"/api/products/{invalidId}")) 26 | .When(GET) 27 | .Then(NOT_FOUND); 28 | 29 | [Fact] 30 | public Task NotExistingId_ShouldReturn_404() => 31 | API.Given(URI($"/api/products/{Guid.NewGuid()}")) 32 | .When(GET) 33 | .Then(NOT_FOUND); 34 | } 35 | 36 | public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime 37 | { 38 | public ProductDetails ExistingProduct = default!; 39 | 40 | public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 41 | 42 | public async Task InitializeAsync() 43 | { 44 | var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); 45 | var registerResponse = await Send( 46 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 47 | ); 48 | 49 | await CREATED(registerResponse); 50 | 51 | var (sku, name, description) = registerProduct; 52 | ExistingProduct = new ProductDetails(registerResponse.GetCreatedId(), sku!, name!, description); 53 | } 54 | 55 | public Task DisposeAsync() => Task.CompletedTask; 56 | } 57 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Core/Validation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace Warehouse.Core; 6 | 7 | public static class Validation 8 | { 9 | public static Guid AssertNotEmpty( 10 | [NotNull] this Guid? value, 11 | [CallerArgumentExpression("value")] string? argumentName = null 12 | ) => 13 | (value != null && value.Value != Guid.Empty) 14 | ? value.Value 15 | : throw new ArgumentOutOfRangeException(argumentName); 16 | 17 | 18 | public static string AssertNotEmpty( 19 | [NotNull] this string? value, 20 | [CallerArgumentExpression("value")] string? argumentName = null 21 | ) => 22 | !string.IsNullOrWhiteSpace(value) 23 | ? value 24 | : throw new ArgumentOutOfRangeException(argumentName); 25 | 26 | 27 | public static string? AssertNullOrNotEmpty( 28 | this string? value, 29 | [CallerArgumentExpression("value")] string? argumentName = null 30 | ) => 31 | value?.AssertNotEmpty(argumentName); 32 | 33 | public static string AssertMatchesRegex( 34 | [NotNull] this string? value, 35 | [StringSyntax(StringSyntaxAttribute.Regex)] 36 | string pattern, 37 | [CallerArgumentExpression("value")] string? argumentName = null 38 | ) 39 | { 40 | return Regex.IsMatch(value.AssertNotEmpty(), pattern) 41 | ? value 42 | : throw new ArgumentOutOfRangeException(argumentName); 43 | } 44 | 45 | public static int AssertPositive( 46 | [NotNull] this int? value, 47 | [CallerArgumentExpression("value")] string? argumentName = null 48 | ) => 49 | value?.AssertPositive() ?? throw new ArgumentOutOfRangeException(argumentName); 50 | 51 | public static int AssertPositive( 52 | this int value, 53 | [CallerArgumentExpression("value")] string? argumentName = null 54 | ) => 55 | value > 0 56 | ? value 57 | : throw new ArgumentOutOfRangeException(argumentName); 58 | } 59 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/Products/GettingProductDetails/GetProductDetailsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Warehouse.Products.GettingProductDetails; 4 | using Warehouse.Products.RegisteringProduct; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProductDetails; 8 | 9 | public class GetProductDetailsTests: IClassFixture 10 | { 11 | private readonly GetProductDetailsFixture API; 12 | 13 | public GetProductDetailsTests(GetProductDetailsFixture api) => API = api; 14 | 15 | [Fact] 16 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 17 | API.Given(URI($"/api/products/{API.ExistingProduct.Id}")) 18 | .When(GET) 19 | .Then(OK, RESPONSE_BODY(API.ExistingProduct)); 20 | 21 | [Theory] 22 | [InlineData(12)] 23 | [InlineData("not-a-guid")] 24 | public Task InvalidGuidId_ShouldReturn_404(object invalidId) => 25 | API.Given(URI($"/api/products/{invalidId}")) 26 | .When(GET) 27 | .Then(NOT_FOUND); 28 | 29 | [Fact] 30 | public Task NotExistingId_ShouldReturn_404() => 31 | API.Given(URI($"/api/products/{Guid.NewGuid()}")) 32 | .When(GET) 33 | .Then(NOT_FOUND); 34 | } 35 | 36 | 37 | public class GetProductDetailsFixture: ApiSpecification, IAsyncLifetime 38 | { 39 | public ProductDetails ExistingProduct = default!; 40 | 41 | public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 42 | 43 | public async Task InitializeAsync() 44 | { 45 | var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription"); 46 | var registerResponse = await Send( 47 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 48 | ); 49 | 50 | await CREATED(registerResponse); 51 | 52 | var (sku, name, description) = registerProduct; 53 | ExistingProduct = new ProductDetails(registerResponse.GetCreatedId(), sku!, name!, description); 54 | } 55 | 56 | public Task DisposeAsync() => Task.CompletedTask; 57 | } 58 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | true 38 | PreserveNewest 39 | PreserveNewest 40 | 41 | 42 | true 43 | PreserveNewest 44 | PreserveNewest 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | PreserveNewest 40 | PreserveNewest 41 | 42 | 43 | true 44 | PreserveNewest 45 | PreserveNewest 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | PreserveNewest 40 | PreserveNewest 41 | 42 | 43 | true 44 | PreserveNewest 45 | PreserveNewest 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Twitter Follow](https://img.shields.io/twitter/follow/oskar_at_net?style=social)](https://twitter.com/oskar_at_net) ![Github Actions](https://github.com/oskardudycz/cqrs-is-simpler-with-net-and-csharp/actions/workflows/build.dotnet.yml/badge.svg?branch=main) [![blog](https://img.shields.io/badge/blog-event--driven.io-brightgreen)](https://event-driven.io/?utm_source=event_sourcing_net) [![blog](https://img.shields.io/badge/%F0%9F%9A%80-Architecture%20Weekly-important)](https://www.architecture-weekly.com/?utm_source=event_sourcing_net) 2 | 3 | # CQRS is simpler with .NET 7 and C# 11 4 | 5 | Watch the talk [below](https://www.youtube.com/watch?v=iY7LO289qnQ): 6 | 7 | CQRS is Simpler than you think with C#11 & NET7 8 | 9 | Repository with backing code for my talk. For more samples like that see [EventSourcing in .NET repository](https://github.com/oskardudycz/EventSourcing.NetCore). 10 | 11 | ## Read also 12 | - 📝 [CQRS facts and myths explained](https://event-driven.io/en/cqrs_facts_and_myths_explained/?utm_source=event_sourcing_net) 13 | - 📝 [What onion has to do with Clean Code?](https://event-driven.io/pl/onion_clean_code/?utm_source=event_sourcing_net) 14 | - 📝 [How to slice the codebase effectively?](https://event-driven.io/en/how_to_slice_the_codebase_effectively/?utm_source=event_sourcing_net) 15 | - 📝 [Generic does not mean Simple?](https://event-driven.io/en/generic_does_not_mean_simple/?utm_source=event_sourcing_net) 16 | - 📝 [Can command return a value?](https://event-driven.io/en/can_command_return_a_value/?utm_source=event_sourcing_net) 17 | - 📝 [CQRS is simpler than you think with .NET 6 and C# 10](https://event-driven.io/en/cqrs_is_simpler_than_you_think_with_net6/?utm_source=event_sourcing_net) 18 | - 📝 [How to register all CQRS handlers by convention](https://event-driven.io/en/how_to_register_all_mediatr_handlers_by_convention/?utm_source=event_sourcing_net) 19 | - 📝 [Notes about C# records and Nullable Reference Types](https://event-driven.io/en/notes_about_csharp_records_and_nullable_reference_types/?utm_source=event_sourcing_net) 20 | 21 | See also [Java version of those samples](https://github.com/oskardudycz/cqrs-is-simpler-with-java). 22 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/Warehouse.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | all 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | PreserveNewest 40 | PreserveNewest 41 | 42 | 43 | true 44 | PreserveNewest 45 | PreserveNewest 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | 8 | #nullable disable 9 | 10 | namespace Warehouse.Migrations 11 | { 12 | [DbContext(typeof(WarehouseDBContext))] 13 | partial class WarehouseDBContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder 19 | .HasAnnotation("ProductVersion", "7.0.2") 20 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 21 | 22 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 23 | 24 | modelBuilder.Entity("Warehouse.Products.Product", b => 25 | { 26 | b.Property("Id") 27 | .HasColumnType("uuid"); 28 | 29 | b.Property("Description") 30 | .HasColumnType("text"); 31 | 32 | b.Property("Name") 33 | .IsRequired() 34 | .HasColumnType("text"); 35 | 36 | b.HasKey("Id"); 37 | 38 | b.ToTable("Product"); 39 | }); 40 | 41 | modelBuilder.Entity("Warehouse.Products.Product", b => 42 | { 43 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 44 | { 45 | b1.Property("ProductId") 46 | .HasColumnType("uuid"); 47 | 48 | b1.Property("Value") 49 | .IsRequired() 50 | .HasColumnType("text"); 51 | 52 | b1.HasKey("ProductId"); 53 | 54 | b1.ToTable("Product"); 55 | 56 | b1.WithOwner() 57 | .HasForeignKey("ProductId"); 58 | }); 59 | 60 | b.Navigation("Sku") 61 | .IsRequired(); 62 | }); 63 | #pragma warning restore 612, 618 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Final/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using Warehouse.Storage; 8 | 9 | #nullable disable 10 | 11 | namespace Warehouse.Migrations 12 | { 13 | [DbContext(typeof(WarehouseDBContext))] 14 | partial class WarehouseDBContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.2") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Warehouse.Products.Product", b => 26 | { 27 | b.Property("Id") 28 | .HasColumnType("uuid"); 29 | 30 | b.Property("Description") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Name") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.HasKey("Id"); 38 | 39 | b.ToTable("Product"); 40 | }); 41 | 42 | modelBuilder.Entity("Warehouse.Products.Product", b => 43 | { 44 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 45 | { 46 | b1.Property("ProductId") 47 | .HasColumnType("uuid"); 48 | 49 | b1.Property("Value") 50 | .IsRequired() 51 | .HasColumnType("text"); 52 | 53 | b1.HasKey("ProductId"); 54 | 55 | b1.ToTable("Product"); 56 | 57 | b1.WithOwner() 58 | .HasForeignKey("ProductId"); 59 | }); 60 | 61 | b.Navigation("Sku") 62 | .IsRequired(); 63 | }); 64 | #pragma warning restore 612, 618 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using Warehouse.Storage; 8 | 9 | #nullable disable 10 | 11 | namespace Warehouse.Migrations 12 | { 13 | [DbContext(typeof(WarehouseDBContext))] 14 | partial class WarehouseDBContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.2") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Warehouse.Products.Product", b => 26 | { 27 | b.Property("Id") 28 | .HasColumnType("uuid"); 29 | 30 | b.Property("Description") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Name") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.HasKey("Id"); 38 | 39 | b.ToTable("Product"); 40 | }); 41 | 42 | modelBuilder.Entity("Warehouse.Products.Product", b => 43 | { 44 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 45 | { 46 | b1.Property("ProductId") 47 | .HasColumnType("uuid"); 48 | 49 | b1.Property("Value") 50 | .IsRequired() 51 | .HasColumnType("text"); 52 | 53 | b1.HasKey("ProductId"); 54 | 55 | b1.ToTable("Product"); 56 | 57 | b1.WithOwner() 58 | .HasForeignKey("ProductId"); 59 | }); 60 | 61 | b.Navigation("Sku") 62 | .IsRequired(); 63 | }); 64 | #pragma warning restore 612, 618 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Migrations/WarehouseDBContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 7 | using Warehouse.Storage; 8 | 9 | #nullable disable 10 | 11 | namespace Warehouse.Migrations 12 | { 13 | [DbContext(typeof(WarehouseDBContext))] 14 | partial class WarehouseDBContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "7.0.2") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Warehouse.Products.Product", b => 26 | { 27 | b.Property("Id") 28 | .HasColumnType("uuid"); 29 | 30 | b.Property("Description") 31 | .HasColumnType("text"); 32 | 33 | b.Property("Name") 34 | .IsRequired() 35 | .HasColumnType("text"); 36 | 37 | b.HasKey("Id"); 38 | 39 | b.ToTable("Product"); 40 | }); 41 | 42 | modelBuilder.Entity("Warehouse.Products.Product", b => 43 | { 44 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 45 | { 46 | b1.Property("ProductId") 47 | .HasColumnType("uuid"); 48 | 49 | b1.Property("Value") 50 | .IsRequired() 51 | .HasColumnType("text"); 52 | 53 | b1.HasKey("ProductId"); 54 | 55 | b1.ToTable("Product"); 56 | 57 | b1.WithOwner() 58 | .HasForeignKey("ProductId"); 59 | }); 60 | 61 | b.Navigation("Sku") 62 | .IsRequired(); 63 | }); 64 | #pragma warning restore 612, 618 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class ExceptionHandlingMiddleware 7 | { 8 | private readonly RequestDelegate next; 9 | 10 | private readonly ILogger logger; 11 | 12 | public ExceptionHandlingMiddleware( 13 | RequestDelegate next, 14 | ILoggerFactory loggerFactory 15 | ) 16 | { 17 | this.next = next; 18 | logger = loggerFactory.CreateLogger(); 19 | } 20 | 21 | public async Task Invoke(HttpContext context /* other scoped dependencies */) 22 | { 23 | try 24 | { 25 | await next(context).ConfigureAwait(false); 26 | } 27 | catch (Exception ex) 28 | { 29 | await HandleExceptionAsync(context, ex).ConfigureAwait(false); 30 | } 31 | } 32 | 33 | private Task HandleExceptionAsync(HttpContext context, Exception exception) 34 | { 35 | logger.LogError(exception, exception.Message); 36 | Console.WriteLine("ERROR:" + exception.Message + exception.StackTrace); 37 | 38 | if (exception.InnerException != null) 39 | Console.WriteLine("INNER DETAILS:" + exception.InnerException.Message + 40 | exception.InnerException.StackTrace); 41 | 42 | var codeInfo = ExceptionToHttpStatusMapper.Map(exception); 43 | 44 | var result = JsonSerializer.Serialize(new HttpExceptionWrapper((int)codeInfo.Code, codeInfo.Message)); 45 | context.Response.ContentType = "application/json"; 46 | context.Response.StatusCode = (int)codeInfo.Code; 47 | return context.Response.WriteAsync(result); 48 | } 49 | } 50 | 51 | public class HttpExceptionWrapper 52 | { 53 | public int StatusCode { get; } 54 | 55 | public string Error { get; } 56 | 57 | public HttpExceptionWrapper(int statusCode, string error) 58 | { 59 | StatusCode = statusCode; 60 | Error = error; 61 | } 62 | } 63 | 64 | public static class ExceptionHandlingMiddlewareExtensions 65 | { 66 | public static IApplicationBuilder UseExceptionHandlingMiddleware( 67 | this IApplicationBuilder app, 68 | Func? customMap = null 69 | ) 70 | { 71 | ExceptionToHttpStatusMapper.CustomMap = customMap; 72 | return app.UseMiddleware(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Final/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class ExceptionHandlingMiddleware 7 | { 8 | private readonly RequestDelegate next; 9 | 10 | private readonly ILogger logger; 11 | 12 | public ExceptionHandlingMiddleware( 13 | RequestDelegate next, 14 | ILoggerFactory loggerFactory 15 | ) 16 | { 17 | this.next = next; 18 | logger = loggerFactory.CreateLogger(); 19 | } 20 | 21 | public async Task Invoke(HttpContext context /* other scoped dependencies */) 22 | { 23 | try 24 | { 25 | await next(context).ConfigureAwait(false); 26 | } 27 | catch (Exception ex) 28 | { 29 | await HandleExceptionAsync(context, ex).ConfigureAwait(false); 30 | } 31 | } 32 | 33 | private Task HandleExceptionAsync(HttpContext context, Exception exception) 34 | { 35 | logger.LogError(exception, exception.Message); 36 | Console.WriteLine("ERROR:" + exception.Message + exception.StackTrace); 37 | 38 | if (exception.InnerException != null) 39 | Console.WriteLine("INNER DETAILS:" + exception.InnerException.Message + 40 | exception.InnerException.StackTrace); 41 | 42 | var codeInfo = ExceptionToHttpStatusMapper.Map(exception); 43 | 44 | var result = JsonSerializer.Serialize(new HttpExceptionWrapper((int)codeInfo.Code, codeInfo.Message)); 45 | context.Response.ContentType = "application/json"; 46 | context.Response.StatusCode = (int)codeInfo.Code; 47 | return context.Response.WriteAsync(result); 48 | } 49 | } 50 | 51 | public class HttpExceptionWrapper 52 | { 53 | public int StatusCode { get; } 54 | 55 | public string Error { get; } 56 | 57 | public HttpExceptionWrapper(int statusCode, string error) 58 | { 59 | StatusCode = statusCode; 60 | Error = error; 61 | } 62 | } 63 | 64 | public static class ExceptionHandlingMiddlewareExtensions 65 | { 66 | public static IApplicationBuilder UseExceptionHandlingMiddleware( 67 | this IApplicationBuilder app, 68 | Func? customMap = null 69 | ) 70 | { 71 | ExceptionToHttpStatusMapper.CustomMap = customMap; 72 | return app.UseMiddleware(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class ExceptionHandlingMiddleware 7 | { 8 | private readonly RequestDelegate next; 9 | 10 | private readonly ILogger logger; 11 | 12 | public ExceptionHandlingMiddleware( 13 | RequestDelegate next, 14 | ILoggerFactory loggerFactory 15 | ) 16 | { 17 | this.next = next; 18 | logger = loggerFactory.CreateLogger(); 19 | } 20 | 21 | public async Task Invoke(HttpContext context /* other scoped dependencies */) 22 | { 23 | try 24 | { 25 | await next(context).ConfigureAwait(false); 26 | } 27 | catch (Exception ex) 28 | { 29 | await HandleExceptionAsync(context, ex).ConfigureAwait(false); 30 | } 31 | } 32 | 33 | private Task HandleExceptionAsync(HttpContext context, Exception exception) 34 | { 35 | logger.LogError(exception, exception.Message); 36 | Console.WriteLine("ERROR:" + exception.Message + exception.StackTrace); 37 | 38 | if (exception.InnerException != null) 39 | Console.WriteLine("INNER DETAILS:" + exception.InnerException.Message + 40 | exception.InnerException.StackTrace); 41 | 42 | var codeInfo = ExceptionToHttpStatusMapper.Map(exception); 43 | 44 | var result = JsonSerializer.Serialize(new HttpExceptionWrapper((int)codeInfo.Code, codeInfo.Message)); 45 | context.Response.ContentType = "application/json"; 46 | context.Response.StatusCode = (int)codeInfo.Code; 47 | return context.Response.WriteAsync(result); 48 | } 49 | } 50 | 51 | public class HttpExceptionWrapper 52 | { 53 | public int StatusCode { get; } 54 | 55 | public string Error { get; } 56 | 57 | public HttpExceptionWrapper(int statusCode, string error) 58 | { 59 | StatusCode = statusCode; 60 | Error = error; 61 | } 62 | } 63 | 64 | public static class ExceptionHandlingMiddlewareExtensions 65 | { 66 | public static IApplicationBuilder UseExceptionHandlingMiddleware( 67 | this IApplicationBuilder app, 68 | Func? customMap = null 69 | ) 70 | { 71 | ExceptionToHttpStatusMapper.CustomMap = customMap; 72 | return app.UseMiddleware(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api/Middlewares/ExceptionHandling/ExceptionHandlingMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | 4 | namespace Warehouse.Api.Middlewares.ExceptionHandling; 5 | 6 | public class ExceptionHandlingMiddleware 7 | { 8 | private readonly RequestDelegate next; 9 | 10 | private readonly ILogger logger; 11 | 12 | public ExceptionHandlingMiddleware( 13 | RequestDelegate next, 14 | ILoggerFactory loggerFactory 15 | ) 16 | { 17 | this.next = next; 18 | logger = loggerFactory.CreateLogger(); 19 | } 20 | 21 | public async Task Invoke(HttpContext context /* other scoped dependencies */) 22 | { 23 | try 24 | { 25 | await next(context).ConfigureAwait(false); 26 | } 27 | catch (Exception ex) 28 | { 29 | await HandleExceptionAsync(context, ex).ConfigureAwait(false); 30 | } 31 | } 32 | 33 | private Task HandleExceptionAsync(HttpContext context, Exception exception) 34 | { 35 | logger.LogError(exception, exception.Message); 36 | Console.WriteLine("ERROR:" + exception.Message + exception.StackTrace); 37 | 38 | if (exception.InnerException != null) 39 | Console.WriteLine("INNER DETAILS:" + exception.InnerException.Message + 40 | exception.InnerException.StackTrace); 41 | 42 | var codeInfo = ExceptionToHttpStatusMapper.Map(exception); 43 | 44 | var result = JsonSerializer.Serialize(new HttpExceptionWrapper((int)codeInfo.Code, codeInfo.Message)); 45 | context.Response.ContentType = "application/json"; 46 | context.Response.StatusCode = (int)codeInfo.Code; 47 | return context.Response.WriteAsync(result); 48 | } 49 | } 50 | 51 | public class HttpExceptionWrapper 52 | { 53 | public int StatusCode { get; } 54 | 55 | public string Error { get; } 56 | 57 | public HttpExceptionWrapper(int statusCode, string error) 58 | { 59 | StatusCode = statusCode; 60 | Error = error; 61 | } 62 | } 63 | 64 | public static class ExceptionHandlingMiddlewareExtensions 65 | { 66 | public static IApplicationBuilder UseExceptionHandlingMiddleware( 67 | this IApplicationBuilder app, 68 | Func? customMap = null 69 | ) 70 | { 71 | ExceptionToHttpStatusMapper.CustomMap = customMap; 72 | return app.UseMiddleware(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Migrations/20230205120130_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace Warehouse.Migrations 12 | { 13 | [DbContext(typeof(WarehouseDBContext))] 14 | [Migration("20230205120130_Initial")] 15 | partial class Initial 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder 22 | .HasAnnotation("ProductVersion", "7.0.2") 23 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 24 | 25 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 26 | 27 | modelBuilder.Entity("Warehouse.Products.Product", b => 28 | { 29 | b.Property("Id") 30 | .HasColumnType("uuid"); 31 | 32 | b.Property("Description") 33 | .HasColumnType("text"); 34 | 35 | b.Property("Name") 36 | .IsRequired() 37 | .HasColumnType("text"); 38 | 39 | b.HasKey("Id"); 40 | 41 | b.ToTable("Product"); 42 | }); 43 | 44 | modelBuilder.Entity("Warehouse.Products.Product", b => 45 | { 46 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 47 | { 48 | b1.Property("ProductId") 49 | .HasColumnType("uuid"); 50 | 51 | b1.Property("Value") 52 | .IsRequired() 53 | .HasColumnType("text"); 54 | 55 | b1.HasKey("ProductId"); 56 | 57 | b1.ToTable("Product"); 58 | 59 | b1.WithOwner() 60 | .HasForeignKey("ProductId"); 61 | }); 62 | 63 | b.Navigation("Sku") 64 | .IsRequired(); 65 | }); 66 | #pragma warning restore 612, 618 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Final/Warehouse/Migrations/20230205120130_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Warehouse.Storage; 9 | 10 | #nullable disable 11 | 12 | namespace Warehouse.Migrations 13 | { 14 | [DbContext(typeof(WarehouseDBContext))] 15 | [Migration("20230205120130_Initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.2") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Warehouse.Products.Product", b => 29 | { 30 | b.Property("Id") 31 | .HasColumnType("uuid"); 32 | 33 | b.Property("Description") 34 | .HasColumnType("text"); 35 | 36 | b.Property("Name") 37 | .IsRequired() 38 | .HasColumnType("text"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.ToTable("Product"); 43 | }); 44 | 45 | modelBuilder.Entity("Warehouse.Products.Product", b => 46 | { 47 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 48 | { 49 | b1.Property("ProductId") 50 | .HasColumnType("uuid"); 51 | 52 | b1.Property("Value") 53 | .IsRequired() 54 | .HasColumnType("text"); 55 | 56 | b1.HasKey("ProductId"); 57 | 58 | b1.ToTable("Product"); 59 | 60 | b1.WithOwner() 61 | .HasForeignKey("ProductId"); 62 | }); 63 | 64 | b.Navigation("Sku") 65 | .IsRequired(); 66 | }); 67 | #pragma warning restore 612, 618 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Migrations/20230205120130_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Warehouse.Storage; 9 | 10 | #nullable disable 11 | 12 | namespace Warehouse.Migrations 13 | { 14 | [DbContext(typeof(WarehouseDBContext))] 15 | [Migration("20230205120130_Initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.2") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Warehouse.Products.Product", b => 29 | { 30 | b.Property("Id") 31 | .HasColumnType("uuid"); 32 | 33 | b.Property("Description") 34 | .HasColumnType("text"); 35 | 36 | b.Property("Name") 37 | .IsRequired() 38 | .HasColumnType("text"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.ToTable("Product"); 43 | }); 44 | 45 | modelBuilder.Entity("Warehouse.Products.Product", b => 46 | { 47 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 48 | { 49 | b1.Property("ProductId") 50 | .HasColumnType("uuid"); 51 | 52 | b1.Property("Value") 53 | .IsRequired() 54 | .HasColumnType("text"); 55 | 56 | b1.HasKey("ProductId"); 57 | 58 | b1.ToTable("Product"); 59 | 60 | b1.WithOwner() 61 | .HasForeignKey("ProductId"); 62 | }); 63 | 64 | b.Navigation("Sku") 65 | .IsRequired(); 66 | }); 67 | #pragma warning restore 612, 618 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse/Migrations/20230205120130_Initial.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | using Warehouse.Storage; 9 | 10 | #nullable disable 11 | 12 | namespace Warehouse.Migrations 13 | { 14 | [DbContext(typeof(WarehouseDBContext))] 15 | [Migration("20230205120130_Initial")] 16 | partial class Initial 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "7.0.2") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Warehouse.Products.Product", b => 29 | { 30 | b.Property("Id") 31 | .HasColumnType("uuid"); 32 | 33 | b.Property("Description") 34 | .HasColumnType("text"); 35 | 36 | b.Property("Name") 37 | .IsRequired() 38 | .HasColumnType("text"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.ToTable("Product"); 43 | }); 44 | 45 | modelBuilder.Entity("Warehouse.Products.Product", b => 46 | { 47 | b.OwnsOne("Warehouse.Products.SKU", "Sku", b1 => 48 | { 49 | b1.Property("ProductId") 50 | .HasColumnType("uuid"); 51 | 52 | b1.Property("Value") 53 | .IsRequired() 54 | .HasColumnType("text"); 55 | 56 | b1.HasKey("ProductId"); 57 | 58 | b1.ToTable("Product"); 59 | 60 | b1.WithOwner() 61 | .HasForeignKey("ProductId"); 62 | }); 63 | 64 | b.Navigation("Sku") 65 | .IsRequired(); 66 | }); 67 | #pragma warning restore 612, 618 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Xunit; 4 | 5 | namespace Warehouse.Api.Tests.Products.RegisteringProduct; 6 | 7 | public class RegisterProductTests: IClassFixture 8 | { 9 | private readonly ApiSpecification API; 10 | 11 | public RegisterProductTests(WarehouseTestWebApplicationFactory webApplicationFactory) => 12 | API = ApiSpecification.Setup(webApplicationFactory); 13 | 14 | [Theory] 15 | [MemberData(nameof(ValidRequests))] 16 | public Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) => 17 | API.Given( 18 | URI("/api/products/"), 19 | BODY(validRequest) 20 | ) 21 | .When(POST) 22 | .Then(CREATED); 23 | 24 | [Theory] 25 | [MemberData(nameof(InvalidRequests))] 26 | public Task InvalidRequest_ShouldReturn_400(RegisterProductRequest invalidRequest) => 27 | API.Given( 28 | URI("/api/products"), 29 | BODY(invalidRequest) 30 | ) 31 | .When(POST) 32 | .Then(BAD_REQUEST); 33 | 34 | [Fact] 35 | public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() 36 | { 37 | // Given 38 | var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); 39 | 40 | // first one should succeed 41 | await API.Given( 42 | URI("/api/products/"), 43 | BODY(request) 44 | ) 45 | .When(POST) 46 | .Then(CREATED); 47 | 48 | // second one will fail with conflict 49 | await API.Given( 50 | URI("/api/products/"), 51 | BODY(request) 52 | ) 53 | .When(POST) 54 | .Then(CONFLICT); 55 | } 56 | 57 | private const string ValidName = "VALID_NAME"; 58 | private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; 59 | private const string ValidDescription = "VALID_DESCRIPTION"; 60 | 61 | public static TheoryData ValidRequests = new() 62 | { 63 | new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), 64 | new RegisterProductRequest(ValidSKU, ValidName, null) 65 | }; 66 | 67 | public static TheoryData InvalidRequests = new() 68 | { 69 | new RegisterProductRequest(null!, ValidName, ValidDescription), 70 | new RegisterProductRequest("INVALID_SKU", ValidName, ValidDescription), 71 | new RegisterProductRequest(ValidSKU, null!, ValidDescription), 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using static Ogooreck.API.ApiSpecification; 4 | using Xunit; 5 | 6 | namespace Warehouse.Api.Tests.Products.RegisteringProduct; 7 | 8 | public class RegisterProductTests: IClassFixture 9 | { 10 | private readonly ApiSpecification API; 11 | 12 | public RegisterProductTests(WarehouseTestWebApplicationFactory webApplicationFactory) => 13 | API = ApiSpecification.Setup(webApplicationFactory); 14 | 15 | [Theory] 16 | [MemberData(nameof(ValidRequests))] 17 | public Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) => 18 | API.Given( 19 | URI("/api/products/"), 20 | BODY(validRequest) 21 | ) 22 | .When(POST) 23 | .Then(CREATED); 24 | 25 | [Theory] 26 | [MemberData(nameof(InvalidRequests))] 27 | public Task InvalidRequest_ShouldReturn_400(RegisterProductRequest invalidRequest) => 28 | API.Given( 29 | URI("/api/products"), 30 | BODY(invalidRequest) 31 | ) 32 | .When(POST) 33 | .Then(BAD_REQUEST); 34 | 35 | [Fact] 36 | public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() 37 | { 38 | // Given 39 | var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); 40 | 41 | // first one should succeed 42 | await API.Given( 43 | URI("/api/products/"), 44 | BODY(request) 45 | ) 46 | .When(POST) 47 | .Then(CREATED); 48 | 49 | // second one will fail with conflict 50 | await API.Given( 51 | URI("/api/products/"), 52 | BODY(request) 53 | ) 54 | .When(POST) 55 | .Then(CONFLICT); 56 | } 57 | 58 | private const string ValidName = "VALID_NAME"; 59 | private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; 60 | private const string ValidDescription = "VALID_DESCRIPTION"; 61 | 62 | public static TheoryData ValidRequests = new() 63 | { 64 | new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), 65 | new RegisterProductRequest(ValidSKU, ValidName, null) 66 | }; 67 | 68 | public static TheoryData InvalidRequests = new() 69 | { 70 | new RegisterProductRequest(null!, ValidName, ValidDescription), 71 | new RegisterProductRequest("INVALID_SKU", ValidName, ValidDescription), 72 | new RegisterProductRequest(ValidSKU, null!, ValidDescription), 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using static Ogooreck.API.ApiSpecification; 4 | using Xunit; 5 | 6 | namespace Warehouse.Api.Tests.Products.RegisteringProduct; 7 | 8 | public class RegisterProductTests: IClassFixture 9 | { 10 | private readonly ApiSpecification API; 11 | 12 | public RegisterProductTests(WarehouseTestWebApplicationFactory webApplicationFactory) => 13 | API = ApiSpecification.Setup(webApplicationFactory); 14 | 15 | [Theory] 16 | [MemberData(nameof(ValidRequests))] 17 | public Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) => 18 | API.Given( 19 | URI("/api/products/"), 20 | BODY(validRequest) 21 | ) 22 | .When(POST) 23 | .Then(CREATED); 24 | 25 | [Theory] 26 | [MemberData(nameof(InvalidRequests))] 27 | public Task InvalidRequest_ShouldReturn_400(RegisterProductRequest invalidRequest) => 28 | API.Given( 29 | URI("/api/products"), 30 | BODY(invalidRequest) 31 | ) 32 | .When(POST) 33 | .Then(BAD_REQUEST); 34 | 35 | [Fact] 36 | public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() 37 | { 38 | // Given 39 | var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); 40 | 41 | // first one should succeed 42 | await API.Given( 43 | URI("/api/products/"), 44 | BODY(request) 45 | ) 46 | .When(POST) 47 | .Then(CREATED); 48 | 49 | // second one will fail with conflict 50 | await API.Given( 51 | URI("/api/products/"), 52 | BODY(request) 53 | ) 54 | .When(POST) 55 | .Then(CONFLICT); 56 | } 57 | 58 | private const string ValidName = "VALID_NAME"; 59 | private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; 60 | private const string ValidDescription = "VALID_DESCRIPTION"; 61 | 62 | public static TheoryData ValidRequests = new() 63 | { 64 | new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), 65 | new RegisterProductRequest(ValidSKU, ValidName, null) 66 | }; 67 | 68 | public static TheoryData InvalidRequests = new() 69 | { 70 | new RegisterProductRequest(null!, ValidName, ValidDescription), 71 | new RegisterProductRequest("INVALID_SKU", ValidName, ValidDescription), 72 | new RegisterProductRequest(ValidSKU, null!, ValidDescription), 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/Products/RegisteringProduct/RegisterProductTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Warehouse.Products.RegisteringProduct; 4 | using Xunit; 5 | 6 | namespace Warehouse.Api.Tests.Products.RegisteringProduct; 7 | 8 | public class RegisterProductTests: IClassFixture 9 | { 10 | private readonly ApiSpecification API; 11 | 12 | public RegisterProductTests(WarehouseTestWebApplicationFactory webApplicationFactory) => 13 | API = ApiSpecification.Setup(webApplicationFactory); 14 | 15 | [Theory] 16 | [MemberData(nameof(ValidRequests))] 17 | public Task ValidRequest_ShouldReturn_201(RegisterProductRequest validRequest) => 18 | API.Given( 19 | URI("/api/products/"), 20 | BODY(validRequest) 21 | ) 22 | .When(POST) 23 | .Then(CREATED); 24 | 25 | [Theory] 26 | [MemberData(nameof(InvalidRequests))] 27 | public Task InvalidRequest_ShouldReturn_400(RegisterProductRequest invalidRequest) => 28 | API.Given( 29 | URI("/api/products"), 30 | BODY(invalidRequest) 31 | ) 32 | .When(POST) 33 | .Then(BAD_REQUEST); 34 | 35 | [Fact] 36 | public async Task RequestForExistingSKUShouldFail_ShouldReturn_409() 37 | { 38 | // Given 39 | var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription); 40 | 41 | // first one should succeed 42 | await API.Given( 43 | URI("/api/products/"), 44 | BODY(request) 45 | ) 46 | .When(POST) 47 | .Then(CREATED); 48 | 49 | // second one will fail with conflict 50 | await API.Given( 51 | URI("/api/products/"), 52 | BODY(request) 53 | ) 54 | .When(POST) 55 | .Then(CONFLICT); 56 | } 57 | 58 | private const string ValidName = "VALID_NAME"; 59 | private static string ValidSKU => $"CC{DateTime.Now.Ticks}"; 60 | private const string ValidDescription = "VALID_DESCRIPTION"; 61 | 62 | public static TheoryData ValidRequests = new() 63 | { 64 | new RegisterProductRequest(ValidSKU, ValidName, ValidDescription), 65 | new RegisterProductRequest(ValidSKU, ValidName, null) 66 | }; 67 | 68 | public static TheoryData InvalidRequests = new() 69 | { 70 | new RegisterProductRequest(null!, ValidName, ValidDescription), 71 | new RegisterProductRequest("INVALID_SKU", ValidName, ValidDescription), 72 | new RegisterProductRequest(ValidSKU, null!, ValidDescription), 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Xunit; 4 | 5 | namespace Warehouse.Api.Tests.Products.GettingProducts; 6 | 7 | public class GetProductsTests: IClassFixture 8 | { 9 | private readonly GetProductsFixture API; 10 | 11 | public GetProductsTests(GetProductsFixture api) => 12 | API = api; 13 | 14 | [Fact] 15 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 16 | API.Given(URI("/api/products/")) 17 | .When(GET) 18 | .Then(OK, RESPONSE_BODY(API.RegisteredProducts)); 19 | 20 | [Fact] 21 | public Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() 22 | { 23 | var registeredProduct = API.RegisteredProducts.First(); 24 | var filter = registeredProduct.Sku[1..]; 25 | 26 | return API.Given(URI($"/api/products/?filter={filter}")) 27 | .When(GET) 28 | .Then(OK, RESPONSE_BODY(new List { registeredProduct })); 29 | } 30 | 31 | [Fact] 32 | public Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() 33 | { 34 | // Given 35 | const int page = 2; 36 | const int pageSize = 1; 37 | var pagedRecords = API.RegisteredProducts 38 | .Skip(page - 1) 39 | .Take(pageSize) 40 | .ToList(); 41 | 42 | return API.Given(URI($"/api/products/?page={page}&pageSize={pageSize}")) 43 | .When(GET) 44 | .Then(OK, RESPONSE_BODY(pagedRecords)); 45 | } 46 | 47 | [Fact] 48 | public Task NegativePage_ShouldReturn_400() => 49 | API.Given(URI($"/api/products/?page={-20}")) 50 | .When(GET) 51 | .Then(BAD_REQUEST); 52 | 53 | [Theory] 54 | [InlineData(0)] 55 | [InlineData(-20)] 56 | public Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) => 57 | API.Given(URI($"/api/products/?pageSize={pageSize}")) 58 | .When(GET) 59 | .Then(BAD_REQUEST); 60 | } 61 | 62 | public class GetProductsFixture: ApiSpecification, IAsyncLifetime 63 | { 64 | public List RegisteredProducts { get; } = new(); 65 | 66 | public GetProductsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 67 | 68 | public async Task InitializeAsync() 69 | { 70 | var productsToRegister = new[] 71 | { 72 | new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), 73 | new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), 74 | new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") 75 | }; 76 | 77 | foreach (var registerProduct in productsToRegister) 78 | { 79 | var registerResponse = await Send( 80 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 81 | ); 82 | 83 | await CREATED(registerResponse); 84 | 85 | var createdId = registerResponse.GetCreatedId(); 86 | 87 | var (sku, name, _) = registerProduct; 88 | RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); 89 | } 90 | } 91 | 92 | public Task DisposeAsync() => Task.CompletedTask; 93 | } 94 | -------------------------------------------------------------------------------- /Mediator/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | using static Ogooreck.API.ApiSpecification; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProducts; 8 | 9 | public class GetProductsTests: IClassFixture 10 | { 11 | private readonly GetProductsFixture API; 12 | 13 | public GetProductsTests(GetProductsFixture api) => 14 | API = api; 15 | 16 | [Fact] 17 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 18 | API.Given(URI("/api/products/")) 19 | .When(GET) 20 | .Then(OK, RESPONSE_BODY(API.RegisteredProducts)); 21 | 22 | [Fact] 23 | public Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() 24 | { 25 | var registeredProduct = API.RegisteredProducts.First(); 26 | var filter = registeredProduct.Sku[1..]; 27 | 28 | return API.Given(URI($"/api/products/?filter={filter}")) 29 | .When(GET) 30 | .Then(OK, RESPONSE_BODY(new List { registeredProduct })); 31 | } 32 | 33 | [Fact] 34 | public Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() 35 | { 36 | // Given 37 | const int page = 2; 38 | const int pageSize = 1; 39 | var pagedRecords = API.RegisteredProducts 40 | .Skip(page - 1) 41 | .Take(pageSize) 42 | .ToList(); 43 | 44 | return API.Given(URI($"/api/products/?page={page}&pageSize={pageSize}")) 45 | .When(GET) 46 | .Then(OK, RESPONSE_BODY(pagedRecords)); 47 | } 48 | 49 | [Fact] 50 | public Task NegativePage_ShouldReturn_400() => 51 | API.Given(URI($"/api/products/?page={-20}")) 52 | .When(GET) 53 | .Then(BAD_REQUEST); 54 | 55 | [Theory] 56 | [InlineData(0)] 57 | [InlineData(-20)] 58 | public Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) => 59 | API.Given(URI($"/api/products/?pageSize={pageSize}")) 60 | .When(GET) 61 | .Then(BAD_REQUEST); 62 | } 63 | 64 | public class GetProductsFixture: ApiSpecification, IAsyncLifetime 65 | { 66 | public List RegisteredProducts { get; } = new(); 67 | 68 | public GetProductsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 69 | 70 | public async Task InitializeAsync() 71 | { 72 | var productsToRegister = new[] 73 | { 74 | new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), 75 | new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), 76 | new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") 77 | }; 78 | 79 | foreach (var registerProduct in productsToRegister) 80 | { 81 | var registerResponse = await Send( 82 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 83 | ); 84 | 85 | await CREATED(registerResponse); 86 | 87 | var createdId = registerResponse.GetCreatedId(); 88 | 89 | var (sku, name, _) = registerProduct; 90 | RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); 91 | } 92 | } 93 | 94 | public Task DisposeAsync() => Task.CompletedTask; 95 | } 96 | -------------------------------------------------------------------------------- /ApplicationService/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using Warehouse.Api.Requests; 3 | using Warehouse.Products; 4 | using static Ogooreck.API.ApiSpecification; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProducts; 8 | 9 | public class GetProductsTests: IClassFixture 10 | { 11 | private readonly GetProductsFixture API; 12 | 13 | public GetProductsTests(GetProductsFixture api) => 14 | API = api; 15 | 16 | [Fact] 17 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 18 | API.Given(URI("/api/products/")) 19 | .When(GET) 20 | .Then(OK, RESPONSE_BODY(API.RegisteredProducts)); 21 | 22 | [Fact] 23 | public Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() 24 | { 25 | var registeredProduct = API.RegisteredProducts.First(); 26 | var filter = registeredProduct.Sku[1..]; 27 | 28 | return API.Given(URI($"/api/products/?filter={filter}")) 29 | .When(GET) 30 | .Then(OK, RESPONSE_BODY(new List { registeredProduct })); 31 | } 32 | 33 | [Fact] 34 | public Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() 35 | { 36 | // Given 37 | const int page = 2; 38 | const int pageSize = 1; 39 | var pagedRecords = API.RegisteredProducts 40 | .Skip(page - 1) 41 | .Take(pageSize) 42 | .ToList(); 43 | 44 | return API.Given(URI($"/api/products/?page={page}&pageSize={pageSize}")) 45 | .When(GET) 46 | .Then(OK, RESPONSE_BODY(pagedRecords)); 47 | } 48 | 49 | [Fact] 50 | public Task NegativePage_ShouldReturn_400() => 51 | API.Given(URI($"/api/products/?page={-20}")) 52 | .When(GET) 53 | .Then(BAD_REQUEST); 54 | 55 | [Theory] 56 | [InlineData(0)] 57 | [InlineData(-20)] 58 | public Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) => 59 | API.Given(URI($"/api/products/?pageSize={pageSize}")) 60 | .When(GET) 61 | .Then(BAD_REQUEST); 62 | } 63 | 64 | public class GetProductsFixture: ApiSpecification, IAsyncLifetime 65 | { 66 | public List RegisteredProducts { get; } = new(); 67 | 68 | public GetProductsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 69 | 70 | public async Task InitializeAsync() 71 | { 72 | var productsToRegister = new[] 73 | { 74 | new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), 75 | new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), 76 | new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") 77 | }; 78 | 79 | foreach (var registerProduct in productsToRegister) 80 | { 81 | var registerResponse = await Send( 82 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 83 | ); 84 | 85 | await CREATED(registerResponse); 86 | 87 | var createdId = registerResponse.GetCreatedId(); 88 | 89 | var (sku, name, _) = registerProduct; 90 | RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); 91 | } 92 | } 93 | 94 | public Task DisposeAsync() => Task.CompletedTask; 95 | } 96 | -------------------------------------------------------------------------------- /Final/Warehouse.Api.Tests/Products/GettingProducts/GetProductsTests.cs: -------------------------------------------------------------------------------- 1 | using Ogooreck.API; 2 | using static Ogooreck.API.ApiSpecification; 3 | using Warehouse.Products.GettingProducts; 4 | using Warehouse.Products.RegisteringProduct; 5 | using Xunit; 6 | 7 | namespace Warehouse.Api.Tests.Products.GettingProducts; 8 | 9 | public class GetProductsTests: IClassFixture 10 | { 11 | private readonly GetProductsFixture API; 12 | 13 | public GetProductsTests(GetProductsFixture api) => 14 | API = api; 15 | 16 | [Fact] 17 | public Task ValidRequest_With_NoParams_ShouldReturn_200() => 18 | API.Given(URI("/api/products/")) 19 | .When(GET) 20 | .Then(OK, RESPONSE_BODY(API.RegisteredProducts)); 21 | 22 | [Fact] 23 | public Task ValidRequest_With_Filter_ShouldReturn_SubsetOfRecords() 24 | { 25 | var registeredProduct = API.RegisteredProducts.First(); 26 | var filter = registeredProduct.Sku[1..]; 27 | 28 | return API.Given(URI($"/api/products/?filter={filter}")) 29 | .When(GET) 30 | .Then(OK, RESPONSE_BODY(new List { registeredProduct })); 31 | } 32 | 33 | [Fact] 34 | public Task ValidRequest_With_Paging_ShouldReturn_PageOfRecords() 35 | { 36 | // Given 37 | const int page = 2; 38 | const int pageSize = 1; 39 | var pagedRecords = API.RegisteredProducts 40 | .Skip(page - 1) 41 | .Take(pageSize) 42 | .ToList(); 43 | 44 | return API.Given(URI($"/api/products/?page={page}&pageSize={pageSize}")) 45 | .When(GET) 46 | .Then(OK, RESPONSE_BODY(pagedRecords)); 47 | } 48 | 49 | [Fact] 50 | public Task NegativePage_ShouldReturn_400() => 51 | API.Given(URI($"/api/products/?page={-20}")) 52 | .When(GET) 53 | .Then(BAD_REQUEST); 54 | 55 | [Theory] 56 | [InlineData(0)] 57 | [InlineData(-20)] 58 | public Task NegativeOrZeroPageSize_ShouldReturn_400(int pageSize) => 59 | API.Given(URI($"/api/products/?pageSize={pageSize}")) 60 | .When(GET) 61 | .Then(BAD_REQUEST); 62 | } 63 | 64 | 65 | public class GetProductsFixture: ApiSpecification, IAsyncLifetime 66 | { 67 | public List RegisteredProducts { get; } = new(); 68 | 69 | public GetProductsFixture(): base(new WarehouseTestWebApplicationFactory()) { } 70 | 71 | public async Task InitializeAsync() 72 | { 73 | var productsToRegister = new[] 74 | { 75 | new RegisterProductRequest("ZX1234", "ValidName", "ValidDescription"), 76 | new RegisterProductRequest("AD5678", "OtherValidName", "OtherValidDescription"), 77 | new RegisterProductRequest("BH90210", "AnotherValid", "AnotherValidDescription") 78 | }; 79 | 80 | foreach (var registerProduct in productsToRegister) 81 | { 82 | var registerResponse = await Send( 83 | new ApiRequest(POST, URI("/api/products"), BODY(registerProduct)) 84 | ); 85 | 86 | await CREATED(registerResponse); 87 | 88 | var createdId = registerResponse.GetCreatedId(); 89 | 90 | var (sku, name, _) = registerProduct; 91 | RegisteredProducts.Add(new ProductListItem(createdId, sku!, name!)); 92 | } 93 | } 94 | 95 | public Task DisposeAsync() => Task.CompletedTask; 96 | } 97 | -------------------------------------------------------------------------------- /Mediator/Warehouse/Products/Repositories/ProductsRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Warehouse.Storage; 3 | 4 | namespace Warehouse.Products; 5 | 6 | internal interface IProductRepository 7 | { 8 | ValueTask Find(ProductId productId, CancellationToken ct); 9 | 10 | 11 | Task Add(Product product, CancellationToken ct); 12 | 13 | 14 | Task Update(Product product, CancellationToken ct); 15 | 16 | 17 | Task Delete(Product product, CancellationToken ct); 18 | 19 | ValueTask ProductWithSKUExists(SKU productSKU, CancellationToken ct); 20 | 21 | ValueTask GetProductDetails(ProductId productId, CancellationToken ct); 22 | 23 | ValueTask> GetProducts( 24 | string? filter, 25 | int page, 26 | int pageSize, 27 | CancellationToken ct 28 | ); 29 | } 30 | 31 | internal class ProductsRepository: IProductRepository 32 | { 33 | private readonly WarehouseDBContext dbContext; 34 | 35 | public ProductsRepository(WarehouseDBContext dbContext) => 36 | this.dbContext = dbContext; 37 | 38 | public ValueTask ProductWithSKUExists( 39 | SKU productSKU, 40 | CancellationToken ct 41 | ) => 42 | new( 43 | dbContext.Set().AnyAsync( 44 | product => product.Sku.Value == productSKU.Value, ct 45 | )); 46 | 47 | public ValueTask Find(ProductId productId, CancellationToken ct) => 48 | dbContext.FindAsync(new[] { productId }, ct); 49 | 50 | public async Task Add(Product product, CancellationToken ct) 51 | { 52 | await dbContext.AddAsync(product, ct); 53 | await dbContext.SaveChangesAsync(ct); 54 | } 55 | 56 | public async Task Update(Product product, CancellationToken ct) 57 | { 58 | dbContext.Update(product); 59 | await dbContext.SaveChangesAsync(ct); 60 | } 61 | 62 | public async Task Delete(Product product, CancellationToken ct) 63 | { 64 | dbContext.Update(product); 65 | await dbContext.SaveChangesAsync(ct); 66 | } 67 | 68 | public async ValueTask GetProductDetails(ProductId productId, CancellationToken ct) 69 | { 70 | var product = await dbContext.Set().AsNoTracking() 71 | .SingleOrDefaultAsync(p => p.Id == productId, ct); 72 | 73 | if (product == null) 74 | return null; 75 | 76 | return new ProductDetails( 77 | product.Id.Value, 78 | product.Sku.Value, 79 | product.Name, 80 | product.Description 81 | ); 82 | } 83 | 84 | public async ValueTask> GetProducts( 85 | string? filter, 86 | int page, 87 | int pageSize, 88 | CancellationToken ct 89 | ) 90 | { 91 | var products = dbContext.Set().AsNoTracking(); 92 | 93 | var filteredProducts = string.IsNullOrEmpty(filter) 94 | ? products 95 | : products 96 | .Where(p => 97 | p.Sku.Value.Contains(filter!) || 98 | p.Name.Contains(filter!) || 99 | p.Description!.Contains(filter!) 100 | ); 101 | 102 | var result = await filteredProducts 103 | .Skip(pageSize * (page - 1)) 104 | .Take(pageSize) 105 | .ToListAsync(ct); 106 | 107 | var sth = result 108 | .Select(p => new ProductListItem(p.Id.Value, p.Sku.Value, p.Name)) 109 | .ToList(); 110 | 111 | return sth; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /.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 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 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 | *.VC.db 84 | *.VC.VC.opendb 85 | 86 | # Visual Studio profiler 87 | *.psess 88 | *.vsp 89 | *.vspx 90 | *.sap 91 | 92 | # TFS 2012 Local Workspace 93 | $tf/ 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | *.DotSettings.user 102 | 103 | # JustCode is a .NET coding add-in 104 | .JustCode 105 | 106 | # TeamCity is a build add-in 107 | _TeamCity* 108 | 109 | # DotCover is a Code Coverage Tool 110 | *.dotCover 111 | 112 | # NCrunch 113 | _NCrunch_* 114 | .*crunch*.local.xml 115 | nCrunchTemp_* 116 | 117 | # MightyMoose 118 | *.mm.* 119 | AutoTest.Net/ 120 | 121 | # Web workbench (sass) 122 | .sass-cache/ 123 | 124 | # Installshield output folder 125 | [Ee]xpress/ 126 | 127 | # DocProject is a documentation generator add-in 128 | DocProject/buildhelp/ 129 | DocProject/Help/*.HxT 130 | DocProject/Help/*.HxC 131 | DocProject/Help/*.hhc 132 | DocProject/Help/*.hhk 133 | DocProject/Help/*.hhp 134 | DocProject/Help/Html2 135 | DocProject/Help/html 136 | 137 | # Click-Once directory 138 | publish/ 139 | 140 | # Publish Web Output 141 | *.[Pp]ublish.xml 142 | *.azurePubxml 143 | # TODO: Comment the next line if you want to checkin your web deploy settings 144 | # but database connection strings (with potential passwords) will be unencrypted 145 | *.pubxml 146 | *.publishproj 147 | 148 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 149 | # checkin your Azure Web App publish settings, but sensitive information contained 150 | # in these scripts will be unencrypted 151 | PublishScripts/ 152 | 153 | # NuGet Packages 154 | *.nupkg 155 | # The packages folder can be ignored because of Package Restore 156 | # **/packages/* 157 | # except build/, which is used as an MSBuild target. 158 | !**/packages/build/ 159 | # Uncomment if necessary however generally it will be regenerated when needed 160 | #!**/packages/repositories.config 161 | # NuGet v3's project.json files produces more ignoreable files 162 | *.nuget.props 163 | *.nuget.targets 164 | 165 | # Microsoft Azure Build Output 166 | csx/ 167 | *.build.csdef 168 | 169 | # Microsoft Azure Emulator 170 | ecf/ 171 | rcf/ 172 | 173 | # Windows Store app package directories and files 174 | AppPackages/ 175 | BundleArtifacts/ 176 | Package.StoreAssociation.xml 177 | _pkginfo.txt 178 | 179 | # Visual Studio cache files 180 | # files ending in .cache can be ignored 181 | *.[Cc]ache 182 | # but keep track of directories ending in .cache 183 | !*.[Cc]ache/ 184 | 185 | # Others 186 | ClientBin/ 187 | ~$* 188 | *~ 189 | *.dbmdl 190 | *.dbproj.schemaview 191 | *.pfx 192 | *.publishsettings 193 | node_modules/ 194 | orleans.codegen.cs 195 | 196 | # Since there are multiple workflows, uncomment next line to ignore bower_components 197 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 198 | #bower_components/ 199 | 200 | # RIA/Silverlight projects 201 | Generated_Code/ 202 | 203 | # Backup & report files from converting an old project file 204 | # to a newer Visual Studio version. Backup files are not needed, 205 | # because we have git ;-) 206 | _UpgradeReport_Files/ 207 | Backup*/ 208 | UpgradeLog*.XML 209 | UpgradeLog*.htm 210 | 211 | # SQL Server files 212 | *.mdf 213 | *.ldf 214 | 215 | # Business Intelligence projects 216 | *.rdl.data 217 | *.bim.layout 218 | *.bim_*.settings 219 | 220 | # Microsoft Fakes 221 | FakesAssemblies/ 222 | 223 | # GhostDoc plugin setting file 224 | *.GhostDoc.xml 225 | 226 | # Node.js Tools for Visual Studio 227 | .ntvs_analysis.dat 228 | 229 | # Visual Studio 6 build log 230 | *.plg 231 | 232 | # Visual Studio 6 workspace options file 233 | *.opt 234 | 235 | # Visual Studio LightSwitch build output 236 | **/*.HTMLClient/GeneratedArtifacts 237 | **/*.DesktopClient/GeneratedArtifacts 238 | **/*.DesktopClient/ModelManifest.xml 239 | **/*.Server/GeneratedArtifacts 240 | **/*.Server/ModelManifest.xml 241 | _Pvt_Extensions 242 | 243 | # Paket dependency manager 244 | .paket/paket.exe 245 | paket-files/ 246 | 247 | # FAKE - F# Make 248 | .fake/ 249 | 250 | # JetBrains Rider 251 | .idea/ 252 | *.sln.iml 253 | 254 | **/out 255 | 256 | # MACOS 257 | .DS_STORE 258 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | 15 | [*.cs] 16 | csharp_indent_case_contents = true:warning 17 | csharp_indent_switch_labels = true:warning 18 | csharp_space_after_cast = false:warning 19 | csharp_space_after_keywords_in_control_flow_statements = true:warning 20 | csharp_space_between_method_declaration_parameter_list_parentheses = false:warning 21 | csharp_space_between_method_call_parameter_list_parentheses = false:warning 22 | csharp_space_before_colon_in_inheritance_clause = false:warning 23 | csharp_space_after_colon_in_inheritance_clause = true:warning 24 | csharp_space_around_binary_operators = before_and_after:warning 25 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false:warning 26 | csharp_space_between_method_call_name_and_opening_parenthesis = false:warning 27 | csharp_space_between_method_call_empty_parameter_list_parentheses = false:warning 28 | csharp_preserve_single_line_statements = false:warning 29 | csharp_preserve_single_line_blocks = true:warning 30 | 31 | ############################### 32 | # .NET Coding Conventions # 33 | ############################### 34 | 35 | [*.cs] 36 | # Organize usings 37 | dotnet_sort_system_directives_first = true:warning 38 | dotnet_separate_import_directive_groups = false:warning 39 | 40 | # this. preferences 41 | dotnet_style_qualification_for_field = false:warning 42 | dotnet_style_qualification_for_property = false:warning 43 | dotnet_style_qualification_for_method = false:warning 44 | dotnet_style_qualification_for_event = false:warning 45 | 46 | # Language keywords vs BCL types preferences 47 | dotnet_style_predefined_type_for_locals_parameters_members = true:warning 48 | dotnet_style_predefined_type_for_member_access = true:warning 49 | 50 | # Parentheses preferences 51 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 52 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 53 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 54 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 55 | 56 | # Modifier preferences 57 | dotnet_style_require_accessibility_modifiers = always:warning 58 | dotnet_style_readonly_field = true:warning 59 | 60 | # Expression-level preferences 61 | dotnet_style_object_initializer = true:warning 62 | dotnet_style_collection_initializer = true:warning 63 | dotnet_style_explicit_tuple_names = true:suggestion 64 | dotnet_style_null_propagation = true 65 | dotnet_style_coalesce_expression = true:warning 66 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning 67 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 68 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 69 | dotnet_style_prefer_auto_properties = true:warning 70 | dotnet_style_prefer_conditional_expression_over_assignment = true:warning 71 | dotnet_style_prefer_conditional_expression_over_return = true:silent 72 | 73 | ############################### 74 | # Naming Conventions # 75 | ############################### 76 | 77 | # Style Definitions 78 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 79 | 80 | # Use PascalCase for constant fields 81 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion 82 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields 83 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style 84 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 85 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = * 86 | dotnet_naming_symbols.constant_fields.required_modifiers = const 87 | 88 | ############################### 89 | # C# Code Style Rules # 90 | ############################### 91 | 92 | [*.cs] 93 | # var preferences 94 | csharp_style_var_for_built_in_types = true:warning 95 | csharp_style_var_when_type_is_apparent = true:warning 96 | csharp_style_var_elsewhere = true:warning 97 | 98 | # Expression-bodied members 99 | csharp_style_expression_bodied_methods = false:silent 100 | csharp_style_expression_bodied_constructors = false:silent 101 | csharp_style_expression_bodied_operators = false:silent 102 | csharp_style_expression_bodied_properties = true:silent 103 | csharp_style_expression_bodied_indexers = true:silent 104 | csharp_style_expression_bodied_accessors = true:silent 105 | 106 | # Pattern-matching preferences 107 | csharp_style_pattern_matching_over_is_with_cast_check = true:warning 108 | csharp_style_pattern_matching_over_as_with_null_check = true:warning 109 | 110 | # Null-checking preferences 111 | csharp_style_throw_expression = true:warning 112 | csharp_style_conditional_delegate_call = true:suggestion 113 | 114 | # Modifier preferences 115 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 116 | 117 | # Expression-level preferences 118 | csharp_prefer_braces = true:information 119 | csharp_style_deconstructed_variable_declaration = true:suggestion 120 | csharp_prefer_simple_default_expression = true:warning 121 | csharp_style_pattern_local_over_anonymous_function = true:warning 122 | csharp_style_inlined_variable_declaration = true:warning 123 | 124 | ############################### 125 | # C# Formatting Rules # 126 | ############################### 127 | 128 | # New line preferences 129 | csharp_new_line_before_open_brace = all 130 | csharp_new_line_before_else = true 131 | csharp_new_line_before_catch = true 132 | csharp_new_line_before_finally = true 133 | csharp_new_line_before_members_in_object_initializers = true 134 | csharp_new_line_before_members_in_anonymous_types = true 135 | csharp_new_line_between_query_expression_clauses = true 136 | 137 | # Indentation preferences 138 | csharp_indent_case_contents = true 139 | csharp_indent_switch_labels = true 140 | csharp_indent_labels = flush_left 141 | 142 | # Space preferences 143 | csharp_space_after_cast = false 144 | csharp_space_after_keywords_in_control_flow_statements = true 145 | csharp_space_between_method_declaration_parameter_list_parentheses = false 146 | csharp_space_between_method_call_parameter_list_parentheses = false 147 | csharp_space_between_parentheses = false 148 | csharp_space_before_colon_in_inheritance_clause = false 149 | csharp_space_after_colon_in_inheritance_clause = true 150 | 151 | csharp_space_around_binary_operators = before_and_after 152 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 153 | csharp_space_between_method_call_name_and_opening_parenthesis = false 154 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 155 | 156 | # Wrapping preferences 157 | csharp_preserve_single_line_statements = false 158 | csharp_preserve_single_line_blocks = true 159 | 160 | 161 | ############################### 162 | # Reliability Inspections # 163 | ############################### 164 | 165 | # CA2012: Use ValueTasks correctly 166 | dotnet_diagnostic.CA2012.severity = error 167 | 168 | # VSTHRD002 Avoid problematic synchronous waits 169 | dotnet_diagnostic.VSTHRD002.severity = warning 170 | 171 | # VSTHRD011 Use AsyncLazy 172 | dotnet_diagnostic.VSTHRD011.severity = warning 173 | 174 | # VSTHRD100 Avoid async void methods 175 | dotnet_diagnostic.VSTHRD100.severity = error 176 | 177 | # VSTHRD101 Avoid unsupported async delegates 178 | dotnet_diagnostic.VSTHRD101.severity = error 179 | 180 | # VSTHRD102 Implement internal logic asynchronously 181 | dotnet_diagnostic.VSTHRD102.severity = error 182 | 183 | # VSTHRD103 Call async methods when in an async method 184 | dotnet_diagnostic.VSTHRD103.severity = error 185 | 186 | # VSTHRD110 Observe result of async calls 187 | dotnet_diagnostic.VSTHRD110.severity = warning 188 | 189 | # VSTHRD111 Use .ConfigureAwait(bool) 190 | dotnet_diagnostic.VSTHRD111.severity = error 191 | 192 | # VSTHRD112 Implement System.IAsyncDisposable 193 | dotnet_diagnostic.VSTHRD112.severity = error 194 | 195 | # VSTHRD200 Use Async suffix for async methods 196 | dotnet_diagnostic.VSTHRD200.severity = none 197 | -------------------------------------------------------------------------------- /Warehouse.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{DB50DBC4-CB42-48A9-BC9A-37E9546B481F}" 4 | ProjectSection(SolutionItems) = preProject 5 | Directory.Build.props = Directory.Build.props 6 | Core.Build.props = Core.Build.props 7 | docker-compose.yml = docker-compose.yml 8 | Dockerfile = Dockerfile 9 | EndProjectSection 10 | EndProject 11 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{69AC90E1-2A1B-44AF-8E67-E30C0416D54B}" 12 | ProjectSection(SolutionItems) = preProject 13 | .github\workflows\build.dotnet.yml = .github\workflows\build.dotnet.yml 14 | .github\workflows\build.docker.yml = .github\workflows\build.docker.yml 15 | EndProjectSection 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Final", "Final", "{5E927C43-8A4B-4D5E-A377-7638D00A1FC5}" 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse", "Final\Warehouse\Warehouse.csproj", "{ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4}" 20 | EndProject 21 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Final\Warehouse.Api\Warehouse.Api.csproj", "{1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C}" 22 | EndProject 23 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Final\Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC}" 24 | EndProject 25 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Empty", "Empty", "{6EBC6A17-D1D8-4B36-A55D-E0F1E0C43880}" 26 | EndProject 27 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ApplicationService", "ApplicationService", "{44AFE231-CB31-40F6-AEE3-50DF3881B1FA}" 28 | EndProject 29 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse", "ApplicationService\Warehouse\Warehouse.csproj", "{AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05}" 30 | EndProject 31 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "ApplicationService\Warehouse.Api\Warehouse.Api.csproj", "{621EE4A5-4FE4-42AA-9E8C-CA649809A794}" 32 | EndProject 33 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "ApplicationService\Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{325D2D84-9642-4B32-9B0D-617F084DCB1B}" 34 | EndProject 35 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Empty\Warehouse.Api\Warehouse.Api.csproj", "{7D93DADA-1105-4DCC-868D-23AB99D3567C}" 36 | EndProject 37 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Empty\Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{4F9AF79B-950F-4598-843F-A7B725229F43}" 38 | EndProject 39 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mediator", "Mediator", "{5E939E77-ABDD-4C99-B3BD-C4C73B3B108B}" 40 | EndProject 41 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse", "Mediator\Warehouse\Warehouse.csproj", "{283547B2-B3D5-4246-A20A-EF05C63AC883}" 42 | EndProject 43 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Mediator\Warehouse.Api\Warehouse.Api.csproj", "{C40B9900-8F05-4F23-ADE3-103333308BB9}" 44 | EndProject 45 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api.Tests", "Mediator\Warehouse.Api.Tests\Warehouse.Api.Tests.csproj", "{F9074808-3CF0-4CA6-BBF8-32055BE86362}" 46 | EndProject 47 | Global 48 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 49 | Debug|Any CPU = Debug|Any CPU 50 | Release|Any CPU = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {69AC90E1-2A1B-44AF-8E67-E30C0416D54B} = {DB50DBC4-CB42-48A9-BC9A-37E9546B481F} 54 | {ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4} = {5E927C43-8A4B-4D5E-A377-7638D00A1FC5} 55 | {1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C} = {5E927C43-8A4B-4D5E-A377-7638D00A1FC5} 56 | {1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC} = {5E927C43-8A4B-4D5E-A377-7638D00A1FC5} 57 | {AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05} = {44AFE231-CB31-40F6-AEE3-50DF3881B1FA} 58 | {621EE4A5-4FE4-42AA-9E8C-CA649809A794} = {44AFE231-CB31-40F6-AEE3-50DF3881B1FA} 59 | {325D2D84-9642-4B32-9B0D-617F084DCB1B} = {44AFE231-CB31-40F6-AEE3-50DF3881B1FA} 60 | {7D93DADA-1105-4DCC-868D-23AB99D3567C} = {6EBC6A17-D1D8-4B36-A55D-E0F1E0C43880} 61 | {4F9AF79B-950F-4598-843F-A7B725229F43} = {6EBC6A17-D1D8-4B36-A55D-E0F1E0C43880} 62 | {283547B2-B3D5-4246-A20A-EF05C63AC883} = {5E939E77-ABDD-4C99-B3BD-C4C73B3B108B} 63 | {C40B9900-8F05-4F23-ADE3-103333308BB9} = {5E939E77-ABDD-4C99-B3BD-C4C73B3B108B} 64 | {F9074808-3CF0-4CA6-BBF8-32055BE86362} = {5E939E77-ABDD-4C99-B3BD-C4C73B3B108B} 65 | EndGlobalSection 66 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 67 | {ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 68 | {ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4}.Debug|Any CPU.Build.0 = Debug|Any CPU 69 | {ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4}.Release|Any CPU.ActiveCfg = Release|Any CPU 70 | {ED70BFCA-58F2-4114-A2FA-B4D85AC80DA4}.Release|Any CPU.Build.0 = Release|Any CPU 71 | {1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 72 | {1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C}.Debug|Any CPU.Build.0 = Debug|Any CPU 73 | {1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C}.Release|Any CPU.ActiveCfg = Release|Any CPU 74 | {1EE9783C-8F60-4E96-B03C-C1FE28BB9D2C}.Release|Any CPU.Build.0 = Release|Any CPU 75 | {1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 76 | {1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC}.Debug|Any CPU.Build.0 = Debug|Any CPU 77 | {1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC}.Release|Any CPU.ActiveCfg = Release|Any CPU 78 | {1C3F40F8-FBD6-4D82-9942-4DD98F35F8AC}.Release|Any CPU.Build.0 = Release|Any CPU 79 | {AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 80 | {AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05}.Debug|Any CPU.Build.0 = Debug|Any CPU 81 | {AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05}.Release|Any CPU.ActiveCfg = Release|Any CPU 82 | {AE6EA47C-5FFA-47F9-9812-1EB4A7B36C05}.Release|Any CPU.Build.0 = Release|Any CPU 83 | {621EE4A5-4FE4-42AA-9E8C-CA649809A794}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 84 | {621EE4A5-4FE4-42AA-9E8C-CA649809A794}.Debug|Any CPU.Build.0 = Debug|Any CPU 85 | {621EE4A5-4FE4-42AA-9E8C-CA649809A794}.Release|Any CPU.ActiveCfg = Release|Any CPU 86 | {621EE4A5-4FE4-42AA-9E8C-CA649809A794}.Release|Any CPU.Build.0 = Release|Any CPU 87 | {325D2D84-9642-4B32-9B0D-617F084DCB1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 88 | {325D2D84-9642-4B32-9B0D-617F084DCB1B}.Debug|Any CPU.Build.0 = Debug|Any CPU 89 | {325D2D84-9642-4B32-9B0D-617F084DCB1B}.Release|Any CPU.ActiveCfg = Release|Any CPU 90 | {325D2D84-9642-4B32-9B0D-617F084DCB1B}.Release|Any CPU.Build.0 = Release|Any CPU 91 | {7D93DADA-1105-4DCC-868D-23AB99D3567C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 92 | {7D93DADA-1105-4DCC-868D-23AB99D3567C}.Debug|Any CPU.Build.0 = Debug|Any CPU 93 | {7D93DADA-1105-4DCC-868D-23AB99D3567C}.Release|Any CPU.ActiveCfg = Release|Any CPU 94 | {7D93DADA-1105-4DCC-868D-23AB99D3567C}.Release|Any CPU.Build.0 = Release|Any CPU 95 | {4F9AF79B-950F-4598-843F-A7B725229F43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 96 | {4F9AF79B-950F-4598-843F-A7B725229F43}.Debug|Any CPU.Build.0 = Debug|Any CPU 97 | {4F9AF79B-950F-4598-843F-A7B725229F43}.Release|Any CPU.ActiveCfg = Release|Any CPU 98 | {4F9AF79B-950F-4598-843F-A7B725229F43}.Release|Any CPU.Build.0 = Release|Any CPU 99 | {283547B2-B3D5-4246-A20A-EF05C63AC883}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 100 | {283547B2-B3D5-4246-A20A-EF05C63AC883}.Debug|Any CPU.Build.0 = Debug|Any CPU 101 | {283547B2-B3D5-4246-A20A-EF05C63AC883}.Release|Any CPU.ActiveCfg = Release|Any CPU 102 | {283547B2-B3D5-4246-A20A-EF05C63AC883}.Release|Any CPU.Build.0 = Release|Any CPU 103 | {C40B9900-8F05-4F23-ADE3-103333308BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 104 | {C40B9900-8F05-4F23-ADE3-103333308BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU 105 | {C40B9900-8F05-4F23-ADE3-103333308BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU 106 | {C40B9900-8F05-4F23-ADE3-103333308BB9}.Release|Any CPU.Build.0 = Release|Any CPU 107 | {F9074808-3CF0-4CA6-BBF8-32055BE86362}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 108 | {F9074808-3CF0-4CA6-BBF8-32055BE86362}.Debug|Any CPU.Build.0 = Debug|Any CPU 109 | {F9074808-3CF0-4CA6-BBF8-32055BE86362}.Release|Any CPU.ActiveCfg = Release|Any CPU 110 | {F9074808-3CF0-4CA6-BBF8-32055BE86362}.Release|Any CPU.Build.0 = Release|Any CPU 111 | EndGlobalSection 112 | EndGlobal 113 | -------------------------------------------------------------------------------- /Empty/Warehouse.Api/Program.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable ArrangeTypeModifiers 2 | // ReSharper disable ClassNeverInstantiated.Global 3 | 4 | using System.Diagnostics.CodeAnalysis; 5 | using Microsoft.AspNetCore.Http.HttpResults; 6 | using Microsoft.EntityFrameworkCore; 7 | using Warehouse.Api.Core; 8 | using Warehouse.Api.Middlewares.ExceptionHandling; 9 | 10 | Console.WriteLine("🇸🇪 👋 Hey Swetugg! 🇸🇪 "); 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | builder.Services 15 | .AddEndpointsApiExplorer() 16 | .AddSwaggerGen() 17 | .AddDbContext( 18 | options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB") 19 | ); 20 | 21 | var app = builder.Build(); 22 | 23 | if (app.Environment.IsDevelopment()) 24 | { 25 | app.UseSwagger() 26 | .UseSwaggerUI(); 27 | 28 | app.UseExceptionHandlingMiddleware(); 29 | 30 | // Kids, do not try this at home! 31 | using var scope = app.Services.CreateScope(); 32 | await using var db = scope.ServiceProvider.GetRequiredService(); 33 | await db.Database.MigrateAsync(); 34 | } 35 | 36 | // Register new product 37 | app.MapPost( 38 | "api/products/", 39 | async Task>( 40 | WarehouseDBContext db, 41 | RegisterProductRequest request, 42 | CancellationToken ct 43 | ) => 44 | { 45 | var productId = Guid.NewGuid(); 46 | var (sku, name, description) = request; 47 | 48 | var product = new Product( 49 | ProductId.From(productId), 50 | SKU.From(sku), 51 | name.AssertNotEmpty(), 52 | description.AssertNullOrNotEmpty() 53 | ); 54 | 55 | if (await db.Set().AnyAsync(p => p.Sku.Value == sku, ct)) 56 | return TypedResults.Conflict(); 57 | 58 | db.Add(product); 59 | await db.SaveChangesAsync(ct); 60 | 61 | return TypedResults.Created($"/api/products/{productId}"); 62 | } 63 | ); 64 | 65 | // Get Product Details by Id 66 | app.MapGet( 67 | "/api/products/{id:guid}", 68 | async Task, NotFound>>( 69 | WarehouseDBContext db, 70 | Guid id, 71 | CancellationToken ct 72 | ) => 73 | await db.FindAsync(ProductId.From(id)) 74 | is { } product 75 | ? TypedResults.Ok( 76 | new ProductDetails( 77 | product.Id.Value, 78 | product.Sku.Value, 79 | product.Name, 80 | product.Description 81 | ) 82 | ) 83 | : TypedResults.NotFound() 84 | ); 85 | 86 | // Get Products 87 | app.MapGet( 88 | "/api/products", 89 | async Task>, BadRequest, NotFound>>( 90 | WarehouseDBContext db, 91 | string? filter, 92 | int? page, 93 | int? pageSize, 94 | CancellationToken ct 95 | ) => 96 | { 97 | page ??= 1; 98 | pageSize ??= 10; 99 | 100 | page.AssertPositive(); 101 | pageSize.AssertPositive(); 102 | 103 | var products = db.Set().AsNoTracking(); 104 | 105 | var filteredProducts = string.IsNullOrEmpty(filter) 106 | ? products 107 | : products 108 | .Where(p => 109 | p.Sku.Value.Contains(filter) || 110 | p.Name.Contains(filter) || 111 | p.Description!.Contains(filter) 112 | ); 113 | 114 | return TypedResults.Ok( 115 | await filteredProducts 116 | .Skip(pageSize.Value * (page.Value - 1)) 117 | .Take(pageSize.Value) 118 | .Select(p => new ProductListItem(p.Id.Value, p.Sku.Value, p.Name)) 119 | .ToListAsync(ct) 120 | ); 121 | } 122 | ); 123 | 124 | app.Run(); 125 | 126 | public record RegisterProductRequest( 127 | string SKU, 128 | string Name, 129 | string? Description 130 | ); 131 | 132 | // CQRS 133 | 134 | // Command 135 | readonly record struct ProductId(Guid Value) 136 | { 137 | public static ProductId From(Guid? productId) => 138 | new(productId.AssertNotEmpty()); 139 | } 140 | 141 | record SKU(string Value) 142 | { 143 | public static SKU From(string? sku) => 144 | new(sku.AssertMatchesRegex("[A-Z]{2,4}[0-9]{4,18}")); 145 | } 146 | 147 | record RegisterProduct( 148 | ProductId ProductId, 149 | SKU SKU, 150 | string Name, 151 | string? Description 152 | ) 153 | { 154 | public static RegisterProduct From(Guid? id, string? sku, string? name, string? description) => 155 | new( 156 | ProductId.From(id), 157 | SKU.From(sku), 158 | name.AssertNotEmpty(), 159 | description.AssertNullOrNotEmpty() 160 | ); 161 | } 162 | 163 | record DeactivateProduct( 164 | ProductId ProductId 165 | ) 166 | { 167 | public static DeactivateProduct From(Guid? id) => 168 | new(ProductId.From(id)); 169 | } 170 | 171 | // Query 172 | record GetProducts(string? Filter, int Page, int PageSize) 173 | { 174 | private const int DefaultPage = 1; 175 | private const int DefaultPageSize = 10; 176 | 177 | public static GetProducts From(string? filter, int? page, int? pageSize) => 178 | new( 179 | filter, 180 | page.GetValueOrDefault(DefaultPage).AssertPositive(), 181 | pageSize.GetValueOrDefault(DefaultPageSize).AssertPositive() 182 | ); 183 | } 184 | 185 | public record ProductListItem( 186 | Guid Id, 187 | string Sku, 188 | string Name 189 | ); 190 | 191 | record GetProductDetails(ProductId ProductId) 192 | { 193 | static GetProductDetails From(Guid productId) => 194 | new(ProductId.From(productId)); 195 | } 196 | 197 | public record ProductDetails( 198 | Guid Id, 199 | string Sku, 200 | string Name, 201 | string? Description 202 | ); 203 | 204 | // Responsibility Segregation 205 | 206 | interface ICommandHandler 207 | { 208 | ValueTask Handle(TCommand command, CancellationToken ct); 209 | } 210 | 211 | class RegisterProductHandler: ICommandHandler 212 | { 213 | public ValueTask Handle(RegisterProduct command, CancellationToken ct) => 214 | throw new NotImplementedException("We'll get to that later"); 215 | } 216 | 217 | class DeactivateProductHandler: ICommandHandler 218 | { 219 | public ValueTask Handle(DeactivateProduct command, CancellationToken ct) => 220 | throw new NotImplementedException("We'll get to that later"); 221 | } 222 | 223 | interface IQueryHandler 224 | { 225 | ValueTask Handle(TQuery query, CancellationToken ct); 226 | } 227 | 228 | class GetProductDetailsHandler: IQueryHandler 229 | { 230 | public ValueTask Handle(GetProductDetails query, CancellationToken ct) => 231 | throw new NotImplementedException("We'll get to that later"); 232 | } 233 | 234 | class GetProductsHandler: IQueryHandler> 235 | { 236 | public ValueTask> Handle(GetProducts query, CancellationToken ct) => 237 | throw new NotImplementedException("We'll get to that later"); 238 | } 239 | 240 | class ProductsApplicationService 241 | { 242 | public Task Handle(RegisterProduct command, CancellationToken ct) => 243 | throw new NotImplementedException("We'll get to that later"); 244 | 245 | public Task Handle(DeactivateProduct command, CancellationToken ct) => 246 | throw new NotImplementedException("We'll get to that later"); 247 | } 248 | 249 | class ProductsQueryService 250 | { 251 | ValueTask Handle(GetProductDetails query, CancellationToken ct) => 252 | throw new NotImplementedException("We'll get to that later"); 253 | 254 | ValueTask> Handle(GetProducts query, CancellationToken ct) => 255 | throw new NotImplementedException("We'll get to that later"); 256 | } 257 | 258 | record Product 259 | { 260 | public required ProductId Id { get; init; } 261 | 262 | public required SKU Sku { get; init; } 263 | 264 | public required string Name { get; init; } 265 | 266 | public string? Description { get; init; } 267 | 268 | // Note: this is needed because we're using SKU DTO. 269 | // It would work if we had just primitives or strongly-typed keys 270 | private Product() { } 271 | 272 | [SetsRequiredMembers] 273 | public Product(ProductId id, SKU sku, string name, string? description) 274 | { 275 | Id = id; 276 | Sku = sku; 277 | Name = name; 278 | Description = description; 279 | } 280 | } 281 | 282 | class WarehouseDBContext: DbContext 283 | { 284 | public WarehouseDBContext(DbContextOptions options) 285 | : base(options) 286 | { 287 | } 288 | 289 | protected override void OnModelCreating(ModelBuilder modelBuilder) 290 | { 291 | var product = modelBuilder.Entity(); 292 | 293 | product 294 | .Property(e => e.Id) 295 | .HasConversion( 296 | typed => typed.Value, 297 | plain => new ProductId(plain) 298 | ); 299 | 300 | product 301 | .OwnsOne(e => e.Sku); 302 | } 303 | } 304 | 305 | public partial class Program 306 | { 307 | } 308 | --------------------------------------------------------------------------------