From 9714028ed7bd87143d5319aac72089b89dc51f6f Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 28 May 2026 15:17:50 -0500 Subject: [PATCH 1/2] feat: add domain service pattern slice --- README.md | 6 +- .../Application/DomainServiceBenchmarks.cs | 38 +++ .../shipping-domain-service-pattern.md | 30 +++ docs/examples/toc.yml | 3 + docs/generators/domain-service.md | 43 ++++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 18 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- docs/patterns/application/domain-service.md | 45 ++++ docs/patterns/toc.yml | 2 + .../DomainServices/DomainService.cs | 81 +++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../ShippingDomainServiceDemo.cs | 77 ++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 + .../DomainServices/DomainServiceAttributes.cs | 19 ++ .../AnalyzerReleases.Unshipped.md | 4 + .../DomainServiceRegistryGenerator.cs | 226 ++++++++++++++++++ .../ShippingDomainServiceDemoTests.cs | 67 ++++++ .../PatternKitBenchmarkCoverageTests.cs | 2 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 22 ++ .../DomainServiceRegistryGeneratorTests.cs | 109 +++++++++ .../DomainServices/DomainServiceTests.cs | 61 +++++ 26 files changed, 884 insertions(+), 13 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/DomainServiceBenchmarks.cs create mode 100644 docs/examples/shipping-domain-service-pattern.md create mode 100644 docs/generators/domain-service.md create mode 100644 docs/patterns/application/domain-service.md create mode 100644 src/PatternKit.Core/Application/DomainServices/DomainService.cs create mode 100644 src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/DomainServices/DomainServiceAttributes.cs create mode 100644 src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/DomainServiceRegistryGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/DomainServices/DomainServiceTests.cs diff --git a/README.md b/README.md index 3c76b0c1..ba56cb43 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 103 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 104 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 18 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 19 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -561,6 +561,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | | Domain Event | Execution | 367.2 ns | 1.77 KB | 346.4 ns | 1.55 KB | Generated reduced execution time and allocation for the order-placed dispatch workflow. | +| Domain Service | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Domain Service | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Event-Carried State Transfer | Construction | 7.552 ns | 48 B | 6.751 ns | 48 B | Same allocation; generated was slightly faster in this microbenchmark. | | Event-Carried State Transfer | Execution | 58.508 ns | 448 B | 59.071 ns | 448 B | Effectively equivalent for the inventory projection workflow. | | Event Notification | Construction | 30.920 ns | 232 B | 31.926 ns | 232 B | Effectively equivalent for this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/DomainServiceBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/DomainServiceBenchmarks.cs new file mode 100644 index 00000000..07b14c7a --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/DomainServiceBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.DomainServices; +using PatternKit.Examples.DomainServiceDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "DomainService")] +public class DomainServiceBenchmarks +{ + private static readonly ShippingDomainServiceDemo.ShippingRequest Request = + ShippingDomainServiceDemo.CreateHighValueRequest(); + + private readonly DomainServiceRegistry _fluent = + ShippingDomainServiceDemo.CreateFluentRegistry(); + + private readonly DomainServiceRegistry _generated = + ShippingDomainServiceDemo.CreateGeneratedRegistry(); + + [Benchmark(Baseline = true, Description = "Fluent: create domain service registry")] + [BenchmarkCategory("Fluent", "Construction")] + public DomainServiceRegistry Fluent_CreateRegistry() + => ShippingDomainServiceDemo.CreateFluentRegistry(); + + [Benchmark(Description = "Generated: create domain service registry")] + [BenchmarkCategory("Generated", "Construction")] + public DomainServiceRegistry Generated_CreateRegistry() + => ShippingDomainServiceDemo.CreateGeneratedRegistry(); + + [Benchmark(Description = "Fluent: select shipping decision")] + [BenchmarkCategory("Fluent", "Execution")] + public ShippingDomainServiceDemo.ShippingDecision Fluent_SelectBest() + => ShippingDomainServiceDemo.SelectBest(Request, _fluent); + + [Benchmark(Description = "Generated: select shipping decision")] + [BenchmarkCategory("Generated", "Execution")] + public ShippingDomainServiceDemo.ShippingDecision Generated_SelectBest() + => ShippingDomainServiceDemo.SelectBest(Request, _generated); +} diff --git a/docs/examples/shipping-domain-service-pattern.md b/docs/examples/shipping-domain-service-pattern.md new file mode 100644 index 00000000..ed436b79 --- /dev/null +++ b/docs/examples/shipping-domain-service-pattern.md @@ -0,0 +1,30 @@ +# Shipping Domain Service Pattern + +This example demonstrates a production-style Domain Service for shipping decisions. It includes a fluent operation registry, a source-generated operation registry, TinyBDD tests, and an `IServiceCollection` extension. + +## Import + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DomainServiceDemo; + +var services = new ServiceCollection(); +services.AddShippingDomainServiceDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var service = provider.GetRequiredService(); +``` + +## Use + +```csharp +var decision = service.SelectBest(ShippingDomainServiceDemo.CreateHighValueRequest()); +``` + +The service uses a generated `DomainServiceRegistry`. The fluent and generated routes share the same carrier and insurance rules so teams can compare runtime and generated composition without changing domain behavior. + +## Production Notes + +- Keep domain services stateless. +- Keep operation names stable because callers and tests use them as domain vocabulary. +- Register the example with `AddShippingDomainServiceDemo()` or import all examples with `AddPatternKitExamples()`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 9e6fb115..8cc138bc 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -169,6 +169,9 @@ - name: Order Aggregate Root Pattern href: order-aggregate-root-pattern.md +- name: Shipping Domain Service Pattern + href: shipping-domain-service-pattern.md + - name: Order Value Object Pattern href: order-value-object-pattern.md diff --git a/docs/generators/domain-service.md b/docs/generators/domain-service.md new file mode 100644 index 00000000..bedb609d --- /dev/null +++ b/docs/generators/domain-service.md @@ -0,0 +1,43 @@ +# Domain Service Generator + +The Domain Service generator turns annotated static methods into a named `DomainServiceRegistry` factory. + +## Usage + +```csharp +using PatternKit.Generators.DomainServices; + +[GenerateDomainServiceRegistry(typeof(ShippingRequest), typeof(ShippingDecision), FactoryMethodName = "Build")] +public static partial class ShippingServices +{ + [DomainServiceOperation("insured-air")] + private static ShippingDecision InsuredAir(ShippingRequest request) + => new(request.OrderId, "air", request.Weight * 3m); +} +``` + +Generated output: + +```csharp +var registry = ShippingServices.Build(); +var decision = registry.Execute("insured-air", request); +``` + +## Operation Shape + +Domain service operations must be static methods with this shape: + +```csharp +static TResponse Operation(TRequest request) +``` + +Operation names must be unique within the generated registry. + +## Diagnostics + +| ID | Meaning | +|---|---| +| `PKDOM001` | Host type must be `partial`. | +| `PKDOM002` | Host type has no `[DomainServiceOperation]` methods. | +| `PKDOM003` | Operation method signature is invalid. | +| `PKDOM004` | Operation name is duplicated. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index cd87570d..2a347e17 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -61,6 +61,9 @@ - name: Domain Event href: domain-event.md +- name: Domain Service + href: domain-service.md + - name: Event Sourcing href: event-sourcing.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 04a974f7..88cb65d5 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -81,6 +81,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | | Domain Event | Execution | 367.2 ns | 1.77 KB | 346.4 ns | 1.55 KB | Generated reduced execution time and allocation for the order-placed dispatch workflow. | +| Domain Service | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Domain Service | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Event-Carried State Transfer | Construction | 7.552 ns | 48 B | 6.751 ns | 48 B | Same allocation; generated was slightly faster in this microbenchmark. | | Event-Carried State Transfer | Execution | 58.508 ns | 448 B | 59.071 ns | 448 B | Effectively equivalent for the inventory projection workflow. | | Event Notification | Construction | 30.920 ns | 232 B | 31.926 ns | 232 B | Effectively equivalent for this microbenchmark. | @@ -224,11 +226,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 103 catalog patterns and 412 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 104 catalog patterns and 416 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 18 | 72 | +| Application Architecture | 19 | 76 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -236,7 +238,7 @@ The coverage matrix currently publishes 103 catalog patterns and 412 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 98 generator source route results. +The generator matrix currently publishes 99 generator source route results. ## Hosting Integration Matrix Results @@ -262,8 +264,9 @@ The generator matrix currently publishes 98 generator source route results. | Application Architecture | Audit Log | Covered | Covered | Covered | Covered | | Application Architecture | CQRS | Covered | Covered | Covered | Covered | | Application Architecture | Data Mapper | Covered | Covered | Covered | Covered | -| Application Architecture | Domain Event | Covered | Covered | Covered | Covered | -| Application Architecture | Event Sourcing | Covered | Covered | Covered | Covered | +| Application Architecture | Domain Event | Covered | Covered | Covered | Covered | +| Application Architecture | Domain Service | Covered | Covered | Covered | Covered | +| Application Architecture | Event Sourcing | Covered | Covered | Covered | Covered | | Application Architecture | Feature Toggle | Covered | Covered | Covered | Covered | | Application Architecture | Identity Map | Covered | Covered | Covered | Covered | | Application Architecture | Materialized View | Covered | Covered | Covered | Covered | @@ -383,8 +386,9 @@ The generator matrix currently publishes 98 generator source route results. | CompositeGenerator | `src/PatternKit.Generators/Composite/CompositeGenerator.cs` | Covered | | DataMapperGenerator | `src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs` | Covered | | DecoratorGenerator | `src/PatternKit.Generators/DecoratorGenerator.cs` | Covered | -| DomainEventDispatcherGenerator | `src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs` | Covered | -| EventCarriedStateTransferGenerator | `src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs` | Covered | +| DomainEventDispatcherGenerator | `src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs` | Covered | +| DomainServiceRegistryGenerator | `src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs` | Covered | +| EventCarriedStateTransferGenerator | `src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs` | Covered | | EventNotificationGenerator | `src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs` | Covered | | EventStoreGenerator | `src/PatternKit.Generators/EventSourcing/EventStoreGenerator.cs` | Covered | | FacadeGenerator | `src/PatternKit.Generators/FacadeGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index d21f3f12..c9ae95e5 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -96,6 +96,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | | Domain Event | Execution | 367.2 ns | 1.77 KB | 346.4 ns | 1.55 KB | Generated reduced execution time and allocation for the order-placed dispatch workflow. | +| Domain Service | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Domain Service | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Event-Carried State Transfer | Construction | 7.552 ns | 48 B | 6.751 ns | 48 B | Same allocation; generated was slightly faster in this microbenchmark. | | Event-Carried State Transfer | Execution | 58.508 ns | 448 B | 59.071 ns | 448 B | Effectively equivalent for the inventory projection workflow. | | Event Notification | Construction | 30.920 ns | 232 B | 31.926 ns | 232 B | Effectively equivalent for this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1b99f117..ac680839 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -115,6 +115,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Transaction Script | `TransactionScript` | Transaction Script generator | | Application Architecture | Service Layer | `IServiceOperation` and `ServiceLayerOperation` | Service Layer generator | | Application Architecture | Domain Event | `IDomainEvent` and `DomainEventDispatcher` | Domain Event generator | +| Application Architecture | Domain Service | `DomainServiceOperation` and named registries | Domain Service generator | | Application Architecture | Table Data Gateway | `ITableDataGateway` and `InMemoryTableDataGateway` | Table Data Gateway generator | | Application Architecture | Event Sourcing | `IEventStore` and `InMemoryEventStore` | Event Sourcing generator | | Application Architecture | Feature Toggle | `IFeatureToggleSet` and `FeatureToggleSet` | Feature Toggle generator | diff --git a/docs/index.md b/docs/index.md index 91742b68..7eb2f748 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 103 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 104 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 18 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 19 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Transaction Script, Unit of Work, Value Object | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/domain-service.md b/docs/patterns/application/domain-service.md new file mode 100644 index 00000000..b2f6e08f --- /dev/null +++ b/docs/patterns/application/domain-service.md @@ -0,0 +1,45 @@ +# Domain Service + +Domain Service models stateless domain behavior that does not naturally belong to a single entity or value object. + +Use it when a decision spans aggregates, value objects, policies, or external facts while still representing domain logic rather than application orchestration. + +## Fluent Path + +```csharp +using PatternKit.Application.DomainServices; + +var registry = DomainServiceRegistry.Create() + .Add("ground", request => new ShippingDecision(request.OrderId, "ground", request.Weight * 1.25m)) + .Add("insured-air", request => new ShippingDecision(request.OrderId, "air", request.Weight * 3m)) + .Build(); + +var decision = registry.Execute("insured-air", request); +``` + +`DomainServiceOperation` keeps the operation named and stateless. `DomainServiceRegistry` gives application services a stable injected surface. + +## Generated Path + +```csharp +using PatternKit.Generators.DomainServices; + +[GenerateDomainServiceRegistry(typeof(ShippingRequest), typeof(ShippingDecision))] +public static partial class ShippingServices +{ + [DomainServiceOperation("ground")] + private static ShippingDecision Ground(ShippingRequest request) + => new(request.OrderId, "ground", request.Weight * 1.25m); +} +``` + +The generator emits a registry factory so domain service operation names and signatures are compile-time checked. + +## IoC Usage + +```csharp +services.AddShippingDomainServiceDemo(); +services.AddSingleton(); +``` + +The example in `docs/examples/shipping-domain-service-pattern.md` shows fluent and generated domain services registered through `IServiceCollection`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index df24db3a..285ac1be 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -431,6 +431,8 @@ href: application/service-layer.md - name: Domain Event href: application/domain-event.md + - name: Domain Service + href: application/domain-service.md - name: Table Data Gateway href: application/table-data-gateway.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/DomainServices/DomainService.cs b/src/PatternKit.Core/Application/DomainServices/DomainService.cs new file mode 100644 index 00000000..10035087 --- /dev/null +++ b/src/PatternKit.Core/Application/DomainServices/DomainService.cs @@ -0,0 +1,81 @@ +namespace PatternKit.Application.DomainServices; + +/// +/// Stateless domain operation for behavior that does not naturally belong to a single entity or value object. +/// +public sealed class DomainServiceOperation +{ + private readonly Func _execute; + + private DomainServiceOperation(string name, Func execute) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Operation name is required.", nameof(name)) + : name; + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + } + + public string Name { get; } + + public static DomainServiceOperation Create(string name, Func execute) + => new(name, execute); + + public TResponse Execute(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + return _execute(request); + } +} + +/// +/// Named set of stateless domain service operations. +/// +public sealed class DomainServiceRegistry +{ + private readonly IReadOnlyDictionary> _operations; + + private DomainServiceRegistry(IReadOnlyDictionary> operations) + { + _operations = operations; + } + + public IReadOnlyList Names => _operations.Keys.OrderBy(static name => name, StringComparer.Ordinal).ToArray(); + + public static Builder Create() => new(); + + public DomainServiceOperation Get(string name) + { + if (!_operations.TryGetValue(name, out var operation)) + throw new KeyNotFoundException($"Domain service operation '{name}' was not registered."); + + return operation; + } + + public TResponse Execute(string name, TRequest request) + => Get(name).Execute(request); + + public sealed class Builder + { + private readonly Dictionary> _operations = new(StringComparer.Ordinal); + + public Builder Add(string name, Func execute) + => Add(DomainServiceOperation.Create(name, execute)); + + public Builder Add(DomainServiceOperation operation) + { + if (operation is null) + throw new ArgumentNullException(nameof(operation)); + + if (_operations.ContainsKey(operation.Name)) + throw new InvalidOperationException($"Domain service operation '{operation.Name}' is already registered."); + + _operations.Add(operation.Name, operation); + return this; + } + + public DomainServiceRegistry Build() + => new(new Dictionary>(_operations, StringComparer.Ordinal)); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 8cf2bd60..563974e1 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DomainEventDemo; +using PatternKit.Examples.DomainServiceDemo; using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.EventCarriedStateTransferDemo; using PatternKit.Examples.EventNotificationDemo; @@ -195,6 +196,7 @@ public sealed record ResilientCheckoutMailboxesExample(Func> RunAsync); public sealed record GeneratedInterpreterRulesExample(Interpreter Pricing, Interpreter Eligibility); public sealed record OrderAggregateRootPatternExample(OrderAggregateRootService Service); +public sealed record ShippingDomainServicePatternExample(ShippingDomainService Service); public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); public sealed record OrderValueObjectPatternExample(OrderValueObjectService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); @@ -306,6 +308,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddMessagingBackplaneFacadeExample() .AddGeneratedInterpreterRulesExample() .AddOrderAggregateRootPatternExample() + .AddShippingDomainServicePatternExample() .AddLoanApprovalSpecificationsExample() .AddOrderValueObjectPatternExample() .AddOrderRepositoryPatternExample() @@ -918,6 +921,13 @@ public static IServiceCollection AddOrderAggregateRootPatternExample(this IServi return services.RegisterExample("Order Aggregate Root Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddShippingDomainServicePatternExample(this IServiceCollection services) + { + services.AddShippingDomainServiceDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Shipping Domain Service Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServiceCollection services) { services.AddLoanApprovalSpecifications(); diff --git a/src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs b/src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs new file mode 100644 index 00000000..4f96f270 --- /dev/null +++ b/src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.DomainServices; +using PatternKit.Generators.DomainServices; + +namespace PatternKit.Examples.DomainServiceDemo; + +/// +/// Production-style domain services for shipping choices that span order value and fulfillment constraints. +/// +public static class ShippingDomainServiceDemo +{ + public sealed record ShippingRequest(string OrderId, decimal Weight, decimal DeclaredValue, bool RequiresSignature); + + public sealed record ShippingDecision(string OrderId, string Carrier, decimal Cost, bool Insured); + + public static DomainServiceRegistry CreateFluentRegistry() + => DomainServiceRegistry.Create() + .Add("ground", static request => new ShippingDecision( + request.OrderId, + "ground", + Math.Round(request.Weight * 1.25m + (request.RequiresSignature ? 4m : 0m), 2), + false)) + .Add("insured-air", static request => new ShippingDecision( + request.OrderId, + "air", + Math.Round(request.Weight * 3m + request.DeclaredValue * 0.01m + (request.RequiresSignature ? 4m : 0m), 2), + true)) + .Build(); + + public static DomainServiceRegistry CreateGeneratedRegistry() + => GeneratedShippingDomainServices.Create(); + + public static ShippingDecision SelectBest(ShippingRequest request, DomainServiceRegistry registry) + { + var operation = request.DeclaredValue >= 500m || request.RequiresSignature ? "insured-air" : "ground"; + return registry.Execute(operation, request); + } + + public static ShippingRequest CreateStandardRequest() + => new("ORD-100", 10m, 250m, false); + + public static ShippingRequest CreateHighValueRequest() + => new("ORD-200", 8m, 1_000m, true); + + public static IServiceCollection AddShippingDomainServiceDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => CreateGeneratedRegistry()); + services.AddSingleton(); + return services; + } +} + +public sealed class ShippingDomainService(DomainServiceRegistry registry) +{ + public ShippingDomainServiceDemo.ShippingDecision SelectBest(ShippingDomainServiceDemo.ShippingRequest request) + => ShippingDomainServiceDemo.SelectBest(request, registry); +} + +[GenerateDomainServiceRegistry(typeof(ShippingDomainServiceDemo.ShippingRequest), typeof(ShippingDomainServiceDemo.ShippingDecision))] +public static partial class GeneratedShippingDomainServices +{ + [DomainServiceOperation("ground")] + private static ShippingDomainServiceDemo.ShippingDecision Ground(ShippingDomainServiceDemo.ShippingRequest request) + => new( + request.OrderId, + "ground", + Math.Round(request.Weight * 1.25m + (request.RequiresSignature ? 4m : 0m), 2), + false); + + [DomainServiceOperation("insured-air")] + private static ShippingDomainServiceDemo.ShippingDecision InsuredAir(ShippingDomainServiceDemo.ShippingRequest request) + => new( + request.OrderId, + "air", + Math.Round(request.Weight * 3m + request.DeclaredValue * 0.01m + (request.RequiresSignature ? 4m : 0m), 2), + true); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index aa887731..f366a4d1 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -528,6 +528,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Aggregate Root"], ["invariant boundary", "source-generated command handler", "DI composition"]), + Descriptor( + "Shipping Domain Service Pattern", + "src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs", + "test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs", + "docs/examples/shipping-domain-service-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Domain Service"], + ["stateless domain operation", "source-generated operation registry", "DI composition"]), Descriptor( "Order Value Object Pattern", "src/PatternKit.Examples/ValueObjectDemo/OrderValueObjectDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index cb175f19..0bcefdc6 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1194,6 +1194,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/AggregateRootDemo/OrderAggregateRootDemoTests.cs", ["fluent command decision and event application", "generated aggregate command handler", "DI-importable order aggregate example"]), + Pattern("Domain Service", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/domain-service.md", + "src/PatternKit.Core/Application/DomainServices/DomainService.cs", + "test/PatternKit.Tests/Application/DomainServices/DomainServiceTests.cs", + "docs/generators/domain-service.md", + "src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs", + "test/PatternKit.Generators.Tests/DomainServiceRegistryGeneratorTests.cs", + null, + "docs/examples/shipping-domain-service-pattern.md", + "src/PatternKit.Examples/DomainServiceDemo/ShippingDomainServiceDemo.cs", + "test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs", + ["fluent stateless domain operation registry", "generated domain service registry", "DI-importable shipping decision example"]), + Pattern("Value Object", PatternFamily.ApplicationArchitecture, "docs/patterns/application/value-object.md", "src/PatternKit.Core/Application/ValueObjects/ValueObject.cs", diff --git a/src/PatternKit.Generators.Abstractions/DomainServices/DomainServiceAttributes.cs b/src/PatternKit.Generators.Abstractions/DomainServices/DomainServiceAttributes.cs new file mode 100644 index 00000000..86a1124c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/DomainServices/DomainServiceAttributes.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.DomainServices; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateDomainServiceRegistryAttribute(Type requestType, Type responseType) : Attribute +{ + public Type RequestType { get; } = requestType ?? throw new ArgumentNullException(nameof(requestType)); + + public Type ResponseType { get; } = responseType ?? throw new ArgumentNullException(nameof(responseType)); + + public string FactoryMethodName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class DomainServiceOperationAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Operation name is required.", nameof(name)) + : name; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 8ec3bf9e..56f8790e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -415,6 +415,10 @@ PKAGG002 | PatternKit.Generators.Aggregates | Error | Aggregate handler must dec PKAGG003 | PatternKit.Generators.Aggregates | Error | Aggregate handler must declare one event applier. PKAGG004 | PatternKit.Generators.Aggregates | Error | Aggregate decision signature is invalid. PKAGG005 | PatternKit.Generators.Aggregates | Error | Aggregate event applier signature is invalid. +PKDOM001 | PatternKit.Generators.DomainServices | Error | Domain service registry host must be partial. +PKDOM002 | PatternKit.Generators.DomainServices | Error | Domain service registry must declare at least one operation. +PKDOM003 | PatternKit.Generators.DomainServices | Error | Domain service operation signature is invalid. +PKDOM004 | PatternKit.Generators.DomainServices | Error | Domain service operation is duplicated. PKVO001 | PatternKit.Generators.ValueObjects | Error | Value Object host must be partial. PKVO002 | PatternKit.Generators.ValueObjects | Error | Value Object host must be a class. PKVO003 | PatternKit.Generators.ValueObjects | Error | Value Object must declare at least one component. diff --git a/src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs b/src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs new file mode 100644 index 00000000..be84c59a --- /dev/null +++ b/src/PatternKit.Generators/DomainServices/DomainServiceRegistryGenerator.cs @@ -0,0 +1,226 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.DomainServices; + +[Generator] +public sealed class DomainServiceRegistryGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.DomainServices.GenerateDomainServiceRegistryAttribute"; + private const string OperationAttributeName = "PatternKit.Generators.DomainServices.DomainServiceOperationAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKDOM001", + "Domain service registry host must be partial", + "Type '{0}' is marked with [GenerateDomainServiceRegistry] but is not declared as partial", + "PatternKit.Generators.DomainServices", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingOperations = new( + "PKDOM002", + "Domain service registry has no operations", + "Type '{0}' is marked with [GenerateDomainServiceRegistry] but does not declare any domain service operations", + "PatternKit.Generators.DomainServices", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidOperation = new( + "PKDOM003", + "Domain service operation signature is invalid", + "Operation method '{0}' must be static, return TResponse, and accept exactly one TRequest parameter", + "PatternKit.Generators.DomainServices", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateOperation = new( + "PKDOM004", + "Domain service operation is duplicated", + "Domain service operation '{0}' is registered more than once", + "PatternKit.Generators.DomainServices", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var requestType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var responseType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol; + if (requestType is null || responseType is null) + return; + + var operations = GetOperations(type, requestType, responseType, context, out var hasAnnotatedOperations); + if (!hasAnnotatedOperations) + { + context.ReportDiagnostic(Diagnostic.Create(MissingOperations, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (operations.Length == 0) + return; + + if (TryFindDuplicate(operations, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateOperation, duplicate.Location, duplicate.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + context.AddSource($"{type.Name}.DomainServiceRegistry.g.cs", SourceText.From( + GenerateSource(type, requestType, responseType, operations, factoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetOperations( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + SourceProductionContext context, + out bool hasAnnotatedOperations) + { + hasAnnotatedOperations = false; + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attr in method.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != OperationAttributeName) + continue; + + hasAnnotatedOperations = true; + if (!TryGetOperation(method, attr, requestType, responseType, out var operation)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidOperation, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(operation); + } + } + + return builder.ToImmutable(); + } + + private static bool TryGetOperation( + IMethodSymbol method, + AttributeData attribute, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + out Operation operation) + { + operation = default; + var name = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as string + : null; + if (string.IsNullOrWhiteSpace(name)) + return false; + + if (!method.IsStatic || method.IsGenericMethod || method.Parameters.Length != 1) + return false; + + if (!SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType) + || !SymbolEqualityComparer.Default.Equals(method.ReturnType, responseType)) + return false; + + operation = new Operation(name!, method.Name, method.Locations.FirstOrDefault()); + return true; + } + + private static bool TryFindDuplicate(IReadOnlyList operations, out Operation duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var operation in operations) + { + if (!seen.Add(operation.Name)) + { + duplicate = operation; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + IReadOnlyList operations, + string factoryMethodName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.DomainServices.DomainServiceRegistry<") + .Append(requestType.ToDisplayString(TypeFormat)).Append(", ") + .Append(responseType.ToDisplayString(TypeFormat)).Append("> ") + .Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.DomainServices.DomainServiceRegistry<") + .Append(requestType.ToDisplayString(TypeFormat)).Append(", ") + .Append(responseType.ToDisplayString(TypeFormat)).AppendLine(">.Create();"); + + foreach (var operation in operations.OrderBy(static operation => operation.Name, System.StringComparer.Ordinal)) + { + sb.Append(" builder.Add(\"") + .Append(Escape(operation.Name)) + .Append("\", static request => ") + .Append(operation.MethodName) + .AppendLine("(request));"); + } + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private readonly record struct Operation(string Name, string MethodName, Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs b/test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs new file mode 100644 index 00000000..dd530864 --- /dev/null +++ b/test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.DomainServiceDemo; +using TinyBDD; +using static PatternKit.Examples.DomainServiceDemo.ShippingDomainServiceDemo; + +namespace PatternKit.Examples.Tests.DomainServiceDemo; + +public sealed class ShippingDomainServiceDemoTests +{ + [Scenario("Fluent and generated domain services select the same shipping decisions")] + [Fact] + public void Fluent_And_Generated_Domain_Services_Select_The_Same_Shipping_Decisions() + { + var standard = CreateStandardRequest(); + var highValue = CreateHighValueRequest(); + var fluent = CreateFluentRegistry(); + var generated = CreateGeneratedRegistry(); + + ScenarioExpect.Equal(SelectBest(standard, fluent), SelectBest(standard, generated)); + ScenarioExpect.Equal(SelectBest(highValue, fluent), SelectBest(highValue, generated)); + ScenarioExpect.Equal(["ground", "insured-air"], generated.Names.ToArray()); + } + + [Scenario("Generated domain service explains carrier and insurance choice")] + [Fact] + public void Generated_Domain_Service_Explains_Carrier_And_Insurance_Choice() + { + var service = new ShippingDomainService(CreateGeneratedRegistry()); + + var decision = service.SelectBest(CreateHighValueRequest()); + + ScenarioExpect.Equal("air", decision.Carrier); + ScenarioExpect.True(decision.Insured); + ScenarioExpect.Equal(38m, decision.Cost); + } + + [Scenario("Shipping domain service integrates with IServiceCollection")] + [Fact] + public void Shipping_Domain_Service_Integrates_With_IServiceCollection() + { + var services = new ServiceCollection(); + services.AddShippingDomainServiceDemo(); + using var provider = services.BuildServiceProvider(validateScopes: true); + + var service = provider.GetRequiredService(); + var decision = service.SelectBest(CreateStandardRequest()); + + ScenarioExpect.Equal("ground", decision.Carrier); + ScenarioExpect.False(decision.Insured); + } + + [Scenario("Shipping domain service is importable through AddPatternKitExamples")] + [Fact] + public void Shipping_Domain_Service_Is_Importable_Through_AddPatternKitExamples() + { + using var provider = new ServiceCollection() + .AddPatternKitExamples() + .BuildServiceProvider(validateScopes: true); + + var example = provider.GetRequiredService(); + var decision = example.Service.SelectBest(CreateHighValueRequest()); + + ScenarioExpect.Equal("air", decision.Carrier); + ScenarioExpect.True(decision.Insured); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 7454f34f..6e9469ac 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -107,7 +107,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("412 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("416 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index a1f0e439..5f0fa319 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -101,6 +101,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Scheduler Agent Supervisor", "CQRS", "Aggregate Root", + "Domain Service", "Specification", "Value Object", "Repository", @@ -160,7 +161,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(18, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(19, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 1e4d3e7d..799c3c79 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -19,6 +19,7 @@ using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; using PatternKit.Generators.DomainEvents; +using PatternKit.Generators.DomainServices; using PatternKit.Generators.EventCarriedStateTransfer; using PatternKit.Generators.EventNotification; using PatternKit.Generators.EventSourcing; @@ -132,6 +133,8 @@ private enum TestTrigger { typeof(DataMapperToDomainAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDomainEventDispatcherAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DomainEventHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateDomainServiceRegistryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(DomainServiceOperationAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateEventStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateFacadeAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, true, false }, { typeof(FacadeExposeAttribute), AttributeTargets.Method, false, false }, @@ -916,6 +919,25 @@ public void Aggregate_Attributes_Expose_Defaults_And_Validation() ScenarioExpect.Throws(() => new GenerateAggregateCommandHandlerAttribute(typeof(string), typeof(int), null!)); } + [Scenario("Domain Service Attributes Expose Defaults And Validation")] + [Fact] + public void Domain_Service_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateDomainServiceRegistryAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "Build" + }; + var operation = new DomainServiceOperationAttribute("quote"); + + ScenarioExpect.Equal(typeof(string), generator.RequestType); + ScenarioExpect.Equal(typeof(int), generator.ResponseType); + ScenarioExpect.Equal("Build", generator.FactoryMethodName); + ScenarioExpect.Equal("quote", operation.Name); + ScenarioExpect.Throws(() => new GenerateDomainServiceRegistryAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateDomainServiceRegistryAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new DomainServiceOperationAttribute("")); + } + [Scenario("Retry Attributes Expose Defaults And Configuration")] [Fact] public void Retry_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/DomainServiceRegistryGeneratorTests.cs b/test/PatternKit.Generators.Tests/DomainServiceRegistryGeneratorTests.cs new file mode 100644 index 00000000..13c4a1c4 --- /dev/null +++ b/test/PatternKit.Generators.Tests/DomainServiceRegistryGeneratorTests.cs @@ -0,0 +1,109 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.DomainServices; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class DomainServiceRegistryGeneratorTests +{ + [Scenario("Generates domain service registry from operation methods")] + [Fact] + public void Generates_Domain_Service_Registry_From_Operation_Methods() + { + var source = """ + using PatternKit.Generators.DomainServices; + + namespace Demo; + + public sealed record QuoteRequest(decimal Weight); + public sealed record QuoteDecision(string Carrier, decimal Cost); + + [GenerateDomainServiceRegistry(typeof(QuoteRequest), typeof(QuoteDecision), FactoryMethodName = "Build")] + public static partial class ShippingServices + { + [DomainServiceOperation("ground")] + private static QuoteDecision Ground(QuoteRequest request) => new("ground", request.Weight); + + [DomainServiceOperation("air")] + private static QuoteDecision Air(QuoteRequest request) => new("air", request.Weight * 2m); + } + """; + + var comp = CreateCompilation(source, nameof(Generates_Domain_Service_Registry_From_Operation_Methods)); + var gen = new DomainServiceRegistryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(static result => result.GeneratedSources)); + ScenarioExpect.Equal("ShippingServices.DomainServiceRegistry.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("builder.Add(\"air\", static request => Air(request));", text); + ScenarioExpect.Contains("builder.Add(\"ground\", static request => Ground(request));", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Theory] + [InlineData(""" + using PatternKit.Generators.DomainServices; + [GenerateDomainServiceRegistry(typeof(object), typeof(string))] + public static class Services; + """, "PKDOM001")] + [InlineData(""" + using PatternKit.Generators.DomainServices; + [GenerateDomainServiceRegistry(typeof(object), typeof(string))] + public static partial class Services; + """, "PKDOM002")] + [InlineData(""" + using PatternKit.Generators.DomainServices; + [GenerateDomainServiceRegistry(typeof(object), typeof(string))] + public static partial class Services + { + [DomainServiceOperation("broken")] + private static int Broken(object request) => 1; + } + """, "PKDOM003")] + [InlineData(""" + using PatternKit.Generators.DomainServices; + [GenerateDomainServiceRegistry(typeof(object), typeof(string))] + public static partial class Services + { + [DomainServiceOperation("quote")] + private static string One(object request) => ""; + [DomainServiceOperation("quote")] + private static string Two(object request) => ""; + } + """, "PKDOM004")] + public void Reports_Domain_Service_Registry_Diagnostics(string source, string expected) + { + var diagnostic = RunAndGetSingleDiagnostic(source, expected); + + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Application.DomainServices.DomainServiceRegistry<,>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(DomainServiceRegistryGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new DomainServiceRegistryGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(static result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Application/DomainServices/DomainServiceTests.cs b/test/PatternKit.Tests/Application/DomainServices/DomainServiceTests.cs new file mode 100644 index 00000000..520d9ab5 --- /dev/null +++ b/test/PatternKit.Tests/Application/DomainServices/DomainServiceTests.cs @@ -0,0 +1,61 @@ +using PatternKit.Application.DomainServices; +using TinyBDD; + +namespace PatternKit.Tests.Application.DomainServices; + +public sealed class DomainServiceTests +{ + private sealed record ShipmentQuote(string OrderId, decimal Weight, decimal Value); + + private sealed record ShipmentDecision(string OrderId, string Carrier, decimal Cost); + + [Scenario("Domain service operation executes stateless domain behavior")] + [Fact] + public void Domain_Service_Operation_Executes_Stateless_Domain_Behavior() + { + var operation = DomainServiceOperation.Create( + "quote-ground", + static request => new ShipmentDecision(request.OrderId, "ground", request.Weight * 1.25m)); + + var decision = operation.Execute(new ShipmentQuote("ORD-100", 10m, 250m)); + + ScenarioExpect.Equal("quote-ground", operation.Name); + ScenarioExpect.Equal(new ShipmentDecision("ORD-100", "ground", 12.50m), decision); + } + + [Scenario("Domain service registry resolves named operations")] + [Fact] + public void Domain_Service_Registry_Resolves_Named_Operations() + { + var registry = DomainServiceRegistry.Create() + .Add("ground", static request => new ShipmentDecision(request.OrderId, "ground", request.Weight * 1.25m)) + .Add(DomainServiceOperation.Create( + "insured-air", + static request => new ShipmentDecision(request.OrderId, "air", request.Weight * 3m + request.Value * 0.01m))) + .Build(); + + var decision = registry.Execute("insured-air", new ShipmentQuote("ORD-100", 5m, 500m)); + + ScenarioExpect.Equal(["ground", "insured-air"], registry.Names.ToArray()); + ScenarioExpect.Equal(new ShipmentDecision("ORD-100", "air", 20m), decision); + ScenarioExpect.Equal("ground", registry.Get("ground").Name); + } + + [Scenario("Domain service rejects invalid usage")] + [Fact] + public void Domain_Service_Rejects_Invalid_Usage() + { + var operation = DomainServiceOperation.Create( + "ground", + static request => new ShipmentDecision(request.OrderId, "ground", request.Weight)); + var builder = DomainServiceRegistry.Create(); + + ScenarioExpect.Throws(() => DomainServiceOperation.Create("", static request => new ShipmentDecision(request.OrderId, "ground", request.Weight))); + ScenarioExpect.Throws(() => DomainServiceOperation.Create("ground", null!)); + ScenarioExpect.Throws(() => operation.Execute(null!)); + ScenarioExpect.Throws(() => builder.Add(null!)); + builder.Add(operation); + ScenarioExpect.Throws(() => builder.Add(operation)); + ScenarioExpect.Throws(() => builder.Build().Get("missing")); + } +} From 969dfb6beba7e9ab36b96485d881088bcbbaf0a0 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 28 May 2026 16:27:53 -0500 Subject: [PATCH 2/2] ci: stabilize solution coverage run --- .github/workflows/ci.yml | 50 +++++++++++----------------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06fb1d83..94092bbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,25 +54,14 @@ jobs: - name: Test with coverage timeout-minutes: 30 - shell: bash run: | - set -euo pipefail - for project in \ - test/PatternKit.Tests/PatternKit.Tests.csproj \ - test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj \ - test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj \ - test/PatternKit.Hosting.Extensions.Tests/PatternKit.Hosting.Extensions.Tests.csproj - do - dotnet test "$project" \ - --configuration Release \ - --no-build \ - -p:TestTfmsInParallel=false \ - --collect:"XPlat Code Coverage" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[PatternKit*]*" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*Tests]*" \ - -- RunConfiguration.TestSessionTimeout=1800000 - done + dotnet test PatternKit.slnx \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura - name: Install ReportGenerator run: dotnet tool update -g dotnet-reportgenerator-globaltool @@ -181,25 +170,14 @@ jobs: - name: Test with coverage (Release) timeout-minutes: 30 - shell: bash run: | - set -euo pipefail - for project in \ - test/PatternKit.Tests/PatternKit.Tests.csproj \ - test/PatternKit.Generators.Tests/PatternKit.Generators.Tests.csproj \ - test/PatternKit.Examples.Tests/PatternKit.Examples.Tests.csproj \ - test/PatternKit.Hosting.Extensions.Tests/PatternKit.Hosting.Extensions.Tests.csproj - do - dotnet test "$project" \ - --configuration Release \ - --no-build \ - -p:TestTfmsInParallel=false \ - --collect:"XPlat Code Coverage" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[PatternKit*]*" \ - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*Tests]*" \ - -- RunConfiguration.TestSessionTimeout=1800000 - done + dotnet test PatternKit.slnx \ + --configuration Release \ + --no-build \ + -p:TestTfmsInParallel=false \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura - name: Pack (all packable projects)