diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index dccf32d74..05f73d9e9 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -323,6 +323,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "13-Projections.SingleStream EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "14-Projections.SingleStream.EventualConsistency", "Workshops\IntroductionToEventSourcing\Solved\14-Projections.SingleStream.EventualConsistency\14-Projections.SingleStream.EventualConsistency.csproj", "{A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Warehouse.MinimalAPI", "Warehouse.MinimalAPI", "{005FC7CE-97BA-47F7-982E-63C46327F78C}" + ProjectSection(SolutionItems) = preProject + Sample\Warehouse.MinimalAPI\docker-compose.yml = Sample\Warehouse.MinimalAPI\docker-compose.yml + Sample\Warehouse.MinimalAPI\README.md = Sample\Warehouse.MinimalAPI\README.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Sample\Warehouse.MinimalAPI\Warehouse.Api\Warehouse.Api.csproj", "{723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -737,6 +745,10 @@ Global {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Debug|Any CPU.Build.0 = Debug|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.ActiveCfg = Release|Any CPU {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48}.Release|Any CPU.Build.0 = Release|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -867,6 +879,8 @@ Global {2CA4B0B3-AF8B-4BD8-A18D-18EE1F93DE04} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {7A990FD1-4935-4F85-912C-D4B870232856} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} {A5A2A4CB-08F6-49AE-B044-DD9C5F8B0F48} = {65F6E2BE-B2D4-4E56-B0CB-3062C4882B9E} + {005FC7CE-97BA-47F7-982E-63C46327F78C} = {A7186B6B-D56D-4AEF-B6B7-FAA827764C34} + {723AD8CE-E3EF-40F6-9576-CCF2C3DB504E} = {005FC7CE-97BA-47F7-982E-63C46327F78C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/README.md b/README.md index f8ac1ee35..fba7492ad 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,12 @@ Tutorial, practical samples and other resources about Event Sourcing in .NET. Se - [6.2 Simple EventSourcing with EventStoreDB](#62-simple-eventsourcing-with-eventstoredb) - [6.3 ECommerce with EventStoreDB](#63-ecommerce-with-eventstoredb) - [6.5 Warehouse](#65-warehouse) - - [6.6 Event Versioning](#66-event-versioning) - - [6.7 Event Pipelines](#67-event-pipelines) - - [6.8 Meetings Management with Marten](#68-meetings-management-with-marten) - - [6.9 Cinema Tickets Reservations with Marten](#69-cinema-tickets-reservations-with-marten) - - [6.10 SmartHome IoT with Marten](#610-smarthome-iot-with-marten) + - [6.6 Warehouse Minimal API](#66-warehouse-minimal-api) + - [6.7 Event Versioning](#67-event-versioning) + - [6.8 Event Pipelines](#68-event-pipelines) + - [6.9 Meetings Management with Marten](#69-meetings-management-with-marten) + - [6.10 Cinema Tickets Reservations with Marten](#610-cinema-tickets-reservations-with-marten) + - [6.11 SmartHome IoT with Marten](#611-smarthome-iot-with-marten) - [7. Self-paced training Kits](#7-self-paced-training-kits) - [7.1 Introduction to Event Sourcing](#71-introduction-to-event-sourcing) - [7.2 Build your own Event Store](#72-build-your-own-event-store) @@ -652,7 +653,13 @@ Samples are using CQRS architecture. They're sliced based on the business module - No Event Sourcing! Using Entity Framework to show that CQRS is not bounded to Event Sourcing or any type of storage, - No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. -### 6.6 [Event Versioning](./Sample/EventsVersioning) +### 6.6 [Warehouse Minimal API](./Sample/Warehouse.MinimalAPI/) +Variation of the previous example, but: +- using Minimal API, +- example how to inject handlers in MediatR like style to decouple API from handlers. +- 📝 Read more [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) + +### 6.7 [Event Versioning](./Sample/EventsVersioning) Shows how to handle basic event schema versioning scenarios using event and stream transformations (e.g. upcasting): - [Simple mapping](./Sample/EventsVersioning/#simple-mapping) - [New not required property](./Sample/EventsVersioning/#new-not-required-property) @@ -665,8 +672,9 @@ Shows how to handle basic event schema versioning scenarios using event and stre - [Events Transformations](./Sample/EventsVersioning/#events-transformations) - [Stream Transformation](./Sample/EventsVersioning/#stream-transformation) - [Summary](./Sample/EventsVersioning/#summary) +- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) -### 6.7 [Event Pipelines](./Sample/EventPipelines) +### 6.8 [Event Pipelines](./Sample/EventPipelines) Shows how to compose event handlers in the processing pipelines to: - filter events, - transform them, @@ -676,8 +684,9 @@ Shows how to compose event handlers in the processing pipelines to: - allows using interfaces and classes if you want to, - can be used with Dependency Injection, but also without through builder, - integrates with MediatR if you want to. +- 📝 Read more [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) -### 6.8 [Meetings Management with Marten](./Sample/MeetingsManagement/) +### 6.9 [Meetings Management with Marten](./Sample/MeetingsManagement/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - microservices example, @@ -685,12 +694,12 @@ Shows how to compose event handlers in the processing pipelines to: - Kafka as a messaging platform to integrate microservices, - read models handled in separate microservice and stored to other database (ElasticSearch) -### 6.9 [Cinema Tickets Reservations with Marten](./Sample/Tickets/) +### 6.10 [Cinema Tickets Reservations with Marten](./Sample/Tickets/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - stores events to Marten. -### 6.10 [SmartHome IoT with Marten](./Sample/AsyncProjections/) +### 6.11 [SmartHome IoT with Marten](./Sample/AsyncProjections/) - typical Event Sourcing and CQRS flow, - DDD using Aggregates, - stores events to Marten, @@ -762,6 +771,7 @@ Read also more on the **Event Sourcing** and **CQRS** topics in my [blog](https: - 📝 [How to slice the codebase effectively?](https://event-driven.io/en/how_to_slice_the_codebase_effectively/?utm_source=event_sourcing_net) - 📝 [Generic does not mean Simple?](https://event-driven.io/en/generic_does_not_mean_simple/?utm_source=event_sourcing_net) - 📝 [Can command return a value?](https://event-driven.io/en/can_command_return_a_value/?utm_source=event_sourcing_net) +- 📝 [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) - 📝 [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) - 📝 [How to use ETag header for optimistic concurrency](https://event-driven.io/en/how_to_use_etag_header_for_optimistic_concurrency/?utm_source=event_sourcing_net) - 📝 [Dealing with Eventual Consistency and Idempotency in MongoDB projections](https://event-driven.io/en/dealing_with_eventual_consistency_and_idempotency_in_mongodb_projections/?utm_source=event_sourcing_net) @@ -770,7 +780,8 @@ Read also more on the **Event Sourcing** and **CQRS** topics in my [blog](https: - 📝 [How to do snapshots in Marten?](https://event-driven.io/en/how_to_do_snapshots_in_Marten/?utm_source=event_sourcing_net) - 📝 [Integrating Marten with other systems](https://event-driven.io/en/integrating_Marten/?utm_source=event_sourcing_net) - 📝 [How to (not) do the events versioning?](https://event-driven.io/en/how_to_do_event_versioning/?utm_source=event_sourcing_net) -- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) +- 📝 [Simple patterns for events schema versioning](https://event-driven.io/en/simple_events_versioning_patterns/?utm_source=event_sourcing_net) +- 📝 [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) - 📝 [How to create projections of events for nested object structures?](https://event-driven.io/en/how_to_create_projections_of_events_for_nested_object_structures/?utm_source=event_sourcing_net) - 📝 [How to scale projections in the event-driven systems?](https://event-driven.io/en/how_to_scale_projections_in_the_event_driven_systems/?utm_source=event_sourcing_net) - 📝 [Immutable Value Objects are simpler and more useful than you think!](https://event-driven.io/en/immutable_value_objects/?utm_source=event_sourcing_net) diff --git a/Sample/EventPipelines/README.md b/Sample/EventPipelines/README.md index c0117a569..28862ba6b 100644 --- a/Sample/EventPipelines/README.md +++ b/Sample/EventPipelines/README.md @@ -9,6 +9,7 @@ Shows how to compose event handlers in the processing pipelines to: - allows using interfaces and classes if you want to, - can be used with Dependency Injection, but also without through builder, - integrates with MediatR if you want to. +- 📝 Read more [How to build a simple event pipeline](https://event-driven.io/en/how_to_build_simple_event_pipeline/?utm_source=event_sourcing_net) ## Overview diff --git a/Sample/Warehouse.MinimalAPI/README.md b/Sample/Warehouse.MinimalAPI/README.md new file mode 100644 index 000000000..17ac996d4 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/README.md @@ -0,0 +1,7 @@ +# Warehouse Minimal API +- CQRS flow using Minimal API, +- example how to inject handlers in MediatR like style to decouple API from handling, +- example of how and where to use C# Records, Nullable Reference Types, etc, +- No Event Sourcing! Using Entity Framework to show that CQRS is not bounded to Event Sourcing or any type of storage, +- No Aggregates! CQRS do not need DDD. Business logic can be handled in handlers. +- 📝 Read more [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) diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs new file mode 100644 index 000000000..7577f39bd --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Commands/ICommandHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Api.Core.Commands; + +public interface ICommandHandler +{ + ValueTask Handle(T command, CancellationToken token); +} + +public delegate ValueTask CommandHandler(T query, CancellationToken ct); + +public static class CommandHandlerConfiguration +{ + public static IServiceCollection AddCommandHandler( + this IServiceCollection services, + Func? configure = null + ) where TCommandHandler : class, ICommandHandler + { + if (configure == null) + { + services.AddTransient(); + services.AddTransient, TCommandHandler>(); + } + else + { + services.AddTransient(configure); + services.AddTransient, TCommandHandler>(configure); + } + + services + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ) + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ); + + return services; + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs new file mode 100644 index 000000000..e09d838ff --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Entities/EntitiesExtensions.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Api.Core.Entities; + +public static class EntitiesExtensions +{ + public static async ValueTask AddAndSave(this DbContext dbContext, T entity, CancellationToken ct) + where T : notnull + { + await dbContext.AddAsync(entity, ct); + await dbContext.SaveChangesAsync(ct); + } + + public static ValueTask Find(this DbContext dbContext, TId id, CancellationToken ct) + where T : class where TId : notnull + => dbContext.FindAsync(new object[] {id}, ct); + + public static IServiceCollection AddQueryable(this IServiceCollection services) + where TDbContext : DbContext + where T : class => + services.AddTransient(sp => sp.GetRequiredService().Set().AsNoTracking()); +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs new file mode 100644 index 000000000..274e5ba5f --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Primitives/MappingExtensions.cs @@ -0,0 +1,33 @@ +using System; + +namespace Warehouse.Api.Core.Primitives; + +internal static class MappingExtensions +{ + public static T AssertNotNull(this T? value, string? paramName = null) + where T : struct + { + if (value == null) + throw new ArgumentNullException(paramName); + + return (T)value; + } + + public static string AssertNotEmpty(this string? value, string? paramName = null) + => !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentOutOfRangeException(paramName); + + public static T AssertNotEmpty(this T value, string? paramName = null) + where T : struct + => AssertNotEmpty((T?)value, paramName); + + public static T AssertNotEmpty(this T? value, string? paramName = null) + where T : struct + { + var notNullValue = value.AssertNotNull(paramName); + + if(Equals(notNullValue, default(T))) + throw new ArgumentOutOfRangeException(paramName); + + return notNullValue; + } +} \ No newline at end of file diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs new file mode 100644 index 000000000..58bd71034 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Core/Queries/IQueryHandler.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Warehouse.Api.Core.Queries; + +public interface IQueryHandler +{ + ValueTask Handle(T query, CancellationToken ct); +} + +public delegate ValueTask QueryHandler(T query, CancellationToken ct); + +public static class QueryHandlerConfiguration +{ + public static IServiceCollection AddQueryHandler( + this IServiceCollection services, + Func? configure = null + ) where TQueryHandler : class, IQueryHandler + { + if (configure == null) + { + services + .AddTransient() + .AddTransient, TQueryHandler>(); + } + else + { + services + .AddTransient(configure) + .AddTransient, TQueryHandler>(configure); + } + + services + .AddTransient>>( + sp => sp.GetRequiredService>().Handle + ) + .AddTransient>( + sp => sp.GetRequiredService>().Handle + ); + + return services; + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs new file mode 100644 index 000000000..129848246 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.Designer.cs @@ -0,0 +1,70 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Warehouse.Api.Storage; + +#nullable disable + +namespace Warehouse.Api.Migrations +{ + [DbContext(typeof(WarehouseDBContext))] + [Migration("20211214161109_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Warehouse.Api.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Warehouse.Api.Products.Product", b => + { + b.OwnsOne("Warehouse.Api.Products.Primitives.SKU", "Sku", b1 => + { + b1.Property("ProductId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Sku") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs new file mode 100644 index 000000000..3412c314d --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/20211214161109_Initial.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Warehouse.Api.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Product", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Sku_Value = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Product", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Product"); + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs new file mode 100644 index 000000000..206b91584 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Migrations/WarehouseDBContextModelSnapshot.cs @@ -0,0 +1,68 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Warehouse.Api.Storage; + +#nullable disable + +namespace Warehouse.Api.Migrations +{ + [DbContext(typeof(WarehouseDBContext))] + partial class WarehouseDBContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Warehouse.Api.Products.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Product"); + }); + + modelBuilder.Entity("Warehouse.Api.Products.Product", b => + { + b.OwnsOne("Warehouse.Api.Products.Primitives.SKU", "Sku", b1 => + { + b1.Property("ProductId") + .HasColumnType("uuid"); + + b1.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ProductId"); + + b1.ToTable("Product"); + + b1.WithOwner() + .HasForeignKey("ProductId"); + }); + + b.Navigation("Sku") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs new file mode 100644 index 000000000..2b91d1eb5 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Configuration.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Warehouse.Api.Core.Commands; +using Warehouse.Api.Core.Entities; +using Warehouse.Api.Core.Queries; +using WarehouseDBContext = Warehouse.Api.Storage.WarehouseDBContext; + +namespace Warehouse.Api.Products; + +internal static class Configuration +{ + public static IServiceCollection AddProductServices(this IServiceCollection services) + => services + .AddQueryable() + .AddCommandHandler(s => + { + var dbContext = s.GetRequiredService(); + return new HandleRegisterProduct(dbContext.AddAndSave, dbContext.ProductWithSKUExists); + }) + .AddQueryHandler, HandleGetProducts>() + .AddQueryHandler(); + + public static void SetupProductsModel(this ModelBuilder modelBuilder) + => modelBuilder.Entity() + .OwnsOne(p => p.Sku); + + public static ValueTask ProductWithSKUExists(this WarehouseDBContext dbContext, SKU productSKU, CancellationToken ct) + => new (dbContext.Set().AnyAsync(product => product.Sku.Value == productSKU.Value, ct)); +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs new file mode 100644 index 000000000..bcaa90023 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProductDetails.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Warehouse.Api.Core.Primitives; +using Warehouse.Api.Core.Queries; + +namespace Warehouse.Api.Products; + +internal class HandleGetProductDetails: IQueryHandler +{ + private readonly IQueryable products; + + public HandleGetProductDetails(IQueryable products) + { + this.products = products; + } + + public async ValueTask Handle(GetProductDetails query, CancellationToken ct) + { + var product = await products + .SingleOrDefaultAsync(p => p.Id == query.ProductId, ct); + + if (product == null) + return null; + + return new ProductDetails( + product.Id, + product.Sku.Value, + product.Name, + product.Description + ); + } +} + +public record GetProductDetails( + Guid ProductId +) +{ + public static GetProductDetails With(Guid? productId) + => new(productId.AssertNotEmpty(nameof(productId))); +} + +public record ProductDetails( + Guid Id, + string Sku, + string Name, + string? Description +); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs new file mode 100644 index 000000000..9309f5464 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/GetProducts.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Warehouse.Api.Core.Queries; + +namespace Warehouse.Api.Products; + +internal class HandleGetProducts : IQueryHandler> +{ + private readonly IQueryable products; + + public HandleGetProducts(IQueryable products) + { + this.products = products; + } + + public async ValueTask> Handle(GetProducts query, CancellationToken ct) + { + var (filter, page, pageSize) = query; + + var filteredProducts = string.IsNullOrEmpty(filter) + ? products + : products + .Where(p => + p.Sku.Value.Contains(query.Filter!) || + p.Name.Contains(query.Filter!) || + p.Description!.Contains(query.Filter!) + ); + + return await filteredProducts + .Skip(pageSize * (page - 1)) + .Take(pageSize) + .Select(p => new ProductListItem(p.Id, p.Sku.Value, p.Name)) + .ToListAsync(ct); + } +} + +public record GetProducts( + string? Filter, + int Page, + int PageSize +) +{ + private const int DefaultPage = 1; + private const int DefaultPageSize = 10; + + public static GetProducts With(string? filter, int? page, int? pageSize) + { + page ??= DefaultPage; + pageSize ??= DefaultPageSize; + + if (page <= 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + if (pageSize <= 0) + throw new ArgumentOutOfRangeException(nameof(pageSize)); + + return new (filter, page.Value, pageSize.Value); + } +} + +public record ProductListItem( + Guid Id, + string Sku, + string Name +); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs new file mode 100644 index 000000000..145434fc0 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/Product.cs @@ -0,0 +1,57 @@ +using System; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Warehouse.Api.Products; + +internal class Product +{ + public Guid Id { get; set; } + + /// + /// The Stock Keeping Unit (SKU), i.e. a merchant-specific identifier for a product or service, or the product to which the offer refers. + /// + /// + public SKU Sku { get; set; } = default!; + + /// + /// Product Name + /// + public string Name { get; set; } = default!; + + /// + /// Optional Product description + /// + public string? Description { get; set; } + + private Product(){} + + public Product(Guid id, SKU sku, string name, string? description) + { + Id = id; + Sku = sku; + Name = name; + Description = description; + } +} + +public record SKU +{ + public string Value { get; init; } + + [JsonConstructor] + public SKU(string value) + { + Value = value; + } + + public static SKU Create(string? value) + { + if (value == null) + throw new ArgumentNullException(nameof(SKU)); + if (string.IsNullOrWhiteSpace(value) || !Regex.IsMatch(value, "[A-Z]{2,4}[0-9]{4,18}")) + throw new ArgumentOutOfRangeException(nameof(SKU)); + + return new SKU(value); + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs new file mode 100644 index 000000000..6dab2c79a --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Products/RegisterProduct.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Warehouse.Api.Core.Commands; + +namespace Warehouse.Api.Products; + +internal class HandleRegisterProduct : ICommandHandler +{ + private readonly Func addProduct; + private readonly Func> productWithSKUExists; + + public HandleRegisterProduct( + Func addProduct, + Func> productWithSKUExists + ) + { + this.addProduct = addProduct; + this.productWithSKUExists = productWithSKUExists; + } + + public async ValueTask Handle(RegisterProduct command, CancellationToken ct) + { + var product = new Product( + command.ProductId, + command.SKU, + command.Name, + command.Description + ); + + if (await productWithSKUExists(command.SKU, ct)) + throw new InvalidOperationException( + $"Product with SKU `{command.SKU} already exists."); + + await addProduct(product, ct); + } +} + +public record RegisterProduct( + Guid ProductId, + SKU SKU, + string Name, + string? Description +) +{ + public static RegisterProduct With(Guid? id, string? sku, string? name, string? description) + { + if (!id.HasValue || id == Guid.Empty) throw new ArgumentOutOfRangeException(nameof(id)); + if (string.IsNullOrEmpty(sku)) throw new ArgumentOutOfRangeException(nameof(sku)); + if (string.IsNullOrEmpty(name)) throw new ArgumentOutOfRangeException(nameof(name)); + if (description is "") throw new ArgumentOutOfRangeException(nameof(name)); + + return new RegisterProduct(id.Value, SKU.Create(sku), name, description); + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs new file mode 100644 index 000000000..897646a7f --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Program.cs @@ -0,0 +1,93 @@ +using System.Net; +using Core.WebApi.Middlewares.ExceptionHandling; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Warehouse.Api.Core.Commands; +using Warehouse.Api.Core.Queries; +using Warehouse.Api.Products; +using Warehouse.Api.Storage; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddDbContext( + options => options.UseNpgsql("name=ConnectionStrings:WarehouseDB")) + .AddProductServices(); + +var app = builder.Build(); + +app.UseExceptionHandlingMiddleware(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger() + .UseSwaggerUI(); + + using var scope = app.Services.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} + +// Get Products +app.MapGet( + "/api/products", + ( + [FromServices] QueryHandler> getProducts, + string? filter, + int? page, + int? pageSize, + CancellationToken ct + ) => + getProducts(GetProducts.With(filter, page, pageSize), ct) + ) + .Produces((int)HttpStatusCode.BadRequest); + + +// Get Product Details by Id +app.MapGet( + "/api/products/{id:guid}", + async ( + [FromServices] QueryHandler getProductById, + Guid id, + CancellationToken ct + ) => + await getProductById(GetProductDetails.With(id), ct) + is { } product + ? Results.Ok(product) + : Results.NotFound() + ) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + +// Register new product +app.MapPost( + "api/products/", + async ( + [FromServices] CommandHandler registerProduct, + RegisterProductRequest request, + CancellationToken ct + ) => + { + var productId = Guid.NewGuid(); + var (sku, name, description) = request; + + await registerProduct( + RegisterProduct.With(productId, sku, name, description), + ct); + + return Results.Created($"/api/products/{productId}", productId); + } + ) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + +app.Run(); + +public record RegisterProductRequest( + string? SKU, + string? Name, + string? Description +); diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json new file mode 100644 index 000000000..96d95946b --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Products.Api": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs new file mode 100644 index 000000000..b463eac08 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Storage/WarehouseDBContext.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Warehouse.Api.Products; + +namespace Warehouse.Api.Storage; + +public class WarehouseDBContext: DbContext +{ + public WarehouseDBContext(DbContextOptions options) + : base(options) + { + + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.SetupProductsModel(); + } +} + +public class WarehouseDBContextFactory: IDesignTimeDbContextFactory +{ + public WarehouseDBContext CreateDbContext(params string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + if (optionsBuilder.IsConfigured) + return new WarehouseDBContext(optionsBuilder.Options); + + //Called by parameterless ctor Usually Migrations + var environmentName = Environment.GetEnvironmentVariable("EnvironmentName") ?? "Development"; + + var connectionString = + new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{environmentName}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build() + .GetConnectionString("WarehouseDB"); + + optionsBuilder.UseNpgsql(connectionString); + + return new WarehouseDBContext(optionsBuilder.Options); + } + + public static WarehouseDBContext Create() + => new WarehouseDBContextFactory().CreateDbContext(); +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj new file mode 100644 index 000000000..5767e7cf9 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/Warehouse.Api.csproj @@ -0,0 +1,25 @@ + + + + net6.0 + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json new file mode 100644 index 000000000..8983e0fc1 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json new file mode 100644 index 000000000..521450942 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.Api/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "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'" + }, + "AllowedHosts": "*" +} diff --git a/Sample/Warehouse.MinimalAPI/Warehouse.sln b/Sample/Warehouse.MinimalAPI/Warehouse.sln new file mode 100644 index 000000000..8c4b3b3d9 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/Warehouse.sln @@ -0,0 +1,44 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{F6A27B3D-4018-4E66-A008-3E1280C8C685}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Warehouse.Api", "Warehouse.Api\Warehouse.Api.csproj", "{46D1830E-55B1-4F36-959F-2ACF936BFC7C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.WebApi", "..\..\Core.WebApi\Core.WebApi.csproj", "{A7A09EBA-0B66-4402-A063-69B47D43A66D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Api.Testing", "..\..\Core.Api.Testing\Core.Api.Testing.csproj", "{49B8A573-5F71-4087-B30D-D72459ABA0E2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{A50C04D5-DDAD-4415-8C42-BAA28EEC360C}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CI", "CI", "{01E4F2B8-04B8-4C44-9089-33155E297779}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A7A09EBA-0B66-4402-A063-69B47D43A66D} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + {49B8A573-5F71-4087-B30D-D72459ABA0E2} = {F6A27B3D-4018-4E66-A008-3E1280C8C685} + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46D1830E-55B1-4F36-959F-2ACF936BFC7C}.Release|Any CPU.Build.0 = Release|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7A09EBA-0B66-4402-A063-69B47D43A66D}.Release|Any CPU.Build.0 = Release|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {49B8A573-5F71-4087-B30D-D72459ABA0E2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Sample/Warehouse.MinimalAPI/docker-compose.yml b/Sample/Warehouse.MinimalAPI/docker-compose.yml new file mode 100644 index 000000000..bd7611411 --- /dev/null +++ b/Sample/Warehouse.MinimalAPI/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3" +services: + ####################################################### + # Postgres + ####################################################### + postgres: + image: clkao/postgres-plv8 + container_name: postgres + environment: + POSTGRES_PASSWORD: Password12! + ports: + - "5432:5432" + networks: + - pg_network + + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + ports: + - "${PGADMIN_PORT:-5050}:80" + networks: + - pg_network + +networks: + pg_network: + driver: bridge + +volumes: + postgres: + pgadmin: