From 3c7e15b135e2b5827c7835ba95131efb009a1040 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Mon, 1 Jun 2026 06:39:28 -0500 Subject: [PATCH] feat: add ports and adapters pattern --- README.md | 4 +- .../Application/PortsAndAdaptersBenchmarks.cs | 34 +++ docs/examples/index.md | 2 + .../order-entry-ports-and-adapters.md | 3 + docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/ports-and-adapters.md | 20 ++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 18 +- docs/index.md | 4 +- .../application/ports-and-adapters.md | 14 ++ docs/patterns/toc.yml | 2 + .../PortsAndAdaptersPipeline.cs | 86 +++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../OrderEntryPortsAndAdaptersDemo.cs | 92 ++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../PortsAndAdaptersAttributes.cs | 33 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../PortsAndAdaptersGenerator.cs | 212 ++++++++++++++++++ .../OrderEntryPortsAndAdaptersDemoTests.cs | 45 ++++ .../PatternKitBenchmarkCoverageTests.cs | 5 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../PortsAndAdaptersGeneratorTests.cs | 168 ++++++++++++++ .../PortsAndAdaptersPipelineTests.cs | 66 ++++++ 25 files changed, 840 insertions(+), 13 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/PortsAndAdaptersBenchmarks.cs create mode 100644 docs/examples/order-entry-ports-and-adapters.md create mode 100644 docs/generators/ports-and-adapters.md create mode 100644 docs/patterns/application/ports-and-adapters.md create mode 100644 src/PatternKit.Core/Application/PortsAndAdapters/PortsAndAdaptersPipeline.cs create mode 100644 src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/PortsAndAdapters/PortsAndAdaptersAttributes.cs create mode 100644 src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/PortsAndAdaptersGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/PortsAndAdapters/PortsAndAdaptersPipelineTests.cs diff --git a/README.md b/README.md index 2c9904f9..2e178bf8 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 118 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 119 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 | 27 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 28 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | diff --git a/benchmarks/PatternKit.Benchmarks/Application/PortsAndAdaptersBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/PortsAndAdaptersBenchmarks.cs new file mode 100644 index 00000000..64726b7a --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/PortsAndAdaptersBenchmarks.cs @@ -0,0 +1,34 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.PortsAndAdapters; +using PatternKit.Examples.PortsAndAdaptersDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "PortsAndAdapters")] +public class PortsAndAdaptersBenchmarks +{ + [Benchmark(Baseline = true, Description = "Fluent: create ports and adapters pipeline")] + [BenchmarkCategory("Fluent", "Construction")] + public PortsAndAdaptersPipeline Fluent_CreatePipeline() + => OrderEntryPortsAndAdaptersPolicies.CreateFluent(new InMemoryOrderEntryApplicationPort()); + + [Benchmark(Description = "Generated: create ports and adapters pipeline")] + [BenchmarkCategory("Generated", "Construction")] + public PortsAndAdaptersPipeline Generated_CreatePipeline() + => GeneratedOrderEntryPortsAndAdapters.CreateGenerated(); + + [Benchmark(Description = "Fluent: execute ports and adapters pipeline")] + [BenchmarkCategory("Fluent", "Execution")] + public ValueTask Fluent_Execute() + => OrderEntryPortsAndAdaptersPolicies.CreateFluent(new InMemoryOrderEntryApplicationPort()) + .ExecuteAsync(new("order-100", "buyer@example.com", 42m)); + + [Benchmark(Description = "Generated: execute ports and adapters pipeline")] + [BenchmarkCategory("Generated", "Execution")] + public ValueTask Generated_Execute() + { + GeneratedOrderEntryPortsAndAdapters.ApplicationPort = new InMemoryOrderEntryApplicationPort(); + return GeneratedOrderEntryPortsAndAdapters.CreateGenerated() + .ExecuteAsync(new("order-100", "buyer@example.com", 42m)); + } +} diff --git a/docs/examples/index.md b/docs/examples/index.md index f1f37d23..f98ac63f 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -59,6 +59,8 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Customer Profile Lazy Load** A Generic Host importable deferred profile lookup with fluent and source-generated routes, `IServiceCollection` registration, TTL caching, and invalidation. See [Customer Profile Lazy Load](customer-profile-lazy-load.md). +* **Order Entry Ports and Adapters** + A Generic Host importable hexagonal order-entry flow with fluent and source-generated adapter routes. See [Order Entry Ports and Adapters](order-entry-ports-and-adapters.md). * **Product Catalog Change Data Capture** A Generic Host importable mutation capture workflow with fluent and source-generated routes, ordered pending records, and post-commit publication. See [Product Catalog Change Data Capture](product-catalog-change-data-capture.md). diff --git a/docs/examples/order-entry-ports-and-adapters.md b/docs/examples/order-entry-ports-and-adapters.md new file mode 100644 index 00000000..8788fa49 --- /dev/null +++ b/docs/examples/order-entry-ports-and-adapters.md @@ -0,0 +1,3 @@ +# Order Entry Ports and Adapters + +The order entry example maps HTTP-style request records into application commands, calls an application port, and maps the result back to HTTP-style response records. It includes fluent and source-generated factories plus an `AddOrderEntryPortsAndAdaptersDemo` registration method for `IServiceCollection`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 97d3b866..ca383462 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -307,6 +307,9 @@ - name: Product Catalog Cache Stampede Protection href: product-catalog-cache-stampede-protection.md +- name: Order Entry Ports and Adapters + href: order-entry-ports-and-adapters.md + - name: Product Catalog Read-Through and Write-Through Cache href: product-catalog-read-write-through-cache.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 32de7135..9ca307ee 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -74,6 +74,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | | [**Lazy Load**](lazy-load.md) | Deferred value factories with caching, TTL, and invalidation | `[GenerateLazyLoad]` | +| [**Ports and Adapters**](ports-and-adapters.md) | Hexagonal inbound adapters, application ports, and outbound adapters | `[GeneratePortsAndAdapters]` | | [**Materialized View**](materialized-view.md) | Event projection read-model factories from handlers | `[GenerateMaterializedView]` | | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | diff --git a/docs/generators/ports-and-adapters.md b/docs/generators/ports-and-adapters.md new file mode 100644 index 00000000..a6c2ec6e --- /dev/null +++ b/docs/generators/ports-and-adapters.md @@ -0,0 +1,20 @@ +# Ports and Adapters Generator + +Annotate a partial host with `[GeneratePortsAndAdapters]` and mark one inbound adapter, one application port, and one outbound adapter. + +```csharp +[GeneratePortsAndAdapters(typeof(HttpRequest), typeof(Command), typeof(Result), typeof(HttpResponse))] +public static partial class OrderEntry +{ + [InboundAdapter] + private static Command Inbound(HttpRequest request) => new(request.Id); + + [ApplicationPort] + private static ValueTask Handle(Command command, CancellationToken ct) => new(new(command.Id)); + + [OutboundAdapter] + private static HttpResponse Outbound(Result result) => new(202, result.Id); +} +``` + +The generated `Create()` factory returns a `PortsAndAdaptersPipeline`. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index f0a2f238..bdc63740 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -312,6 +312,9 @@ - name: Service Layer href: service-layer.md +- name: Ports and Adapters + href: ports-and-adapters.md + - name: Visitor Generator href: visitor-generator.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index ee5d3aa9..b56455ca 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -53,6 +53,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Backpressure | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Lazy Load | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Lazy Load | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Ports and Adapters | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Ports and Adapters | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache Stampede Protection | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache Stampede Protection | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Cache-Aside | Construction | 19.91 ns | 200 B | 19.85 ns | 200 B | Effectively equivalent for this microbenchmark. | @@ -258,11 +260,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 118 catalog patterns and 472 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 12 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 119 catalog patterns and 476 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 12 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 27 | 108 | +| Application Architecture | 28 | 112 | | Behavioral | 12 | 48 | | Cloud Architecture | 20 | 80 | | Creational | 6 | 24 | @@ -270,7 +272,7 @@ The coverage matrix currently publishes 118 catalog patterns and 472 pattern rou | Messaging Reliability | 4 | 16 | | Structural | 7 | 28 | -The generator matrix currently publishes 113 generator source route results. +The generator matrix currently publishes 114 generator source route results. ## Hosting Integration Matrix Results @@ -313,7 +315,8 @@ The generator matrix currently publishes 113 generator source route results. | Application Architecture | Identity Map | Covered | Covered | Covered | Covered | | Application Architecture | Lazy Load | Covered | Covered | Covered | Covered | | Application Architecture | Materialized View | Covered | Covered | Covered | Covered | -| Application Architecture | Repository | Covered | Covered | Covered | Covered | +| Application Architecture | Ports and Adapters | Covered | Covered | Covered | Covered | +| Application Architecture | Repository | Covered | Covered | Covered | Covered | | Application Architecture | Service Layer | Covered | Covered | Covered | Covered | | Application Architecture | Specification | Covered | Covered | Covered | Covered | | Application Architecture | Value Object | Covered | Covered | Covered | Covered | @@ -515,9 +518,10 @@ The generator matrix currently publishes 113 generator source route results. | RateLimitPolicyGenerator | `src/PatternKit.Generators/RateLimiting/RateLimitPolicyGenerator.cs` | Covered | | RepositoryGenerator | `src/PatternKit.Generators/Repository/RepositoryGenerator.cs` | Covered | | RetryPolicyGenerator | `src/PatternKit.Generators/Retry/RetryPolicyGenerator.cs` | Covered | -| SchedulerAgentSupervisorGenerator | `src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs` | Covered | -| ServiceLayerOperationGenerator | `src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs` | Covered | -| SidecarGenerator | `src/PatternKit.Generators/Sidecar/SidecarGenerator.cs` | Covered | +| SchedulerAgentSupervisorGenerator | `src/PatternKit.Generators/SchedulerAgentSupervisor/SchedulerAgentSupervisorGenerator.cs` | Covered | +| ServiceLayerOperationGenerator | `src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs` | Covered | +| PortsAndAdaptersGenerator | `src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs` | Covered | +| SidecarGenerator | `src/PatternKit.Generators/Sidecar/SidecarGenerator.cs` | Covered | | SingletonGenerator | `src/PatternKit.Generators/Singleton/SingletonGenerator.cs` | Covered | | SpecificationGenerator | `src/PatternKit.Generators/Specification/SpecificationGenerator.cs` | Covered | | ValueObjectGenerator | `src/PatternKit.Generators/ValueObjects/ValueObjectGenerator.cs` | Covered | diff --git a/docs/index.md b/docs/index.md index 2956d2b5..47da762f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 118 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 119 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 | 27 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | +| Application Architecture | 28 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Eventual Consistency Monitor, Feature Toggle, Identity Map, Lazy Load, Manual Task Gate, Materialized View, Ports and Adapters, Repository, Service Layer, Snapshot / Checkpoint Management, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 12 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Null Object, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 6 | Abstract Factory, Builder, Factory Method, Object Pool, Prototype, Singleton | diff --git a/docs/patterns/application/ports-and-adapters.md b/docs/patterns/application/ports-and-adapters.md new file mode 100644 index 00000000..aa491ab6 --- /dev/null +++ b/docs/patterns/application/ports-and-adapters.md @@ -0,0 +1,14 @@ +# Ports and Adapters + +Ports and Adapters keeps application use cases independent from delivery mechanisms and infrastructure adapters. + +```csharp +var pipeline = PortsAndAdaptersPipeline + .Create("order-entry") + .AdaptInboundWith(request => new PlaceOrderCommand(request.OrderId, request.CustomerEmail, request.Total)) + .HandleWith((command, ct) => applicationPort.PlaceOrderAsync(command, ct)) + .AdaptOutboundWith(result => new OrderEntryHttpResponse(result.Accepted ? 202 : 409, result.OrderId, result.Message)) + .Build(); +``` + +Use the fluent path when adapters are assembled at runtime from `IServiceCollection`. Use the generator path when the adapter methods are stable and you want a named factory with minimal ceremony. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 3a81ef06..3cfb88d2 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -439,6 +439,8 @@ href: application/identity-map.md - name: Lazy Load href: application/lazy-load.md + - name: Ports and Adapters + href: application/ports-and-adapters.md - name: Transaction Script href: application/transaction-script.md - name: Service Layer diff --git a/src/PatternKit.Core/Application/PortsAndAdapters/PortsAndAdaptersPipeline.cs b/src/PatternKit.Core/Application/PortsAndAdapters/PortsAndAdaptersPipeline.cs new file mode 100644 index 00000000..9f92ce56 --- /dev/null +++ b/src/PatternKit.Core/Application/PortsAndAdapters/PortsAndAdaptersPipeline.cs @@ -0,0 +1,86 @@ +namespace PatternKit.Application.PortsAndAdapters; + +/// Primary port exposed by a Ports and Adapters boundary. +public interface IPortsAndAdaptersPipeline +{ + string Name { get; } + + ValueTask ExecuteAsync(TInbound inbound, CancellationToken cancellationToken = default); +} + +/// Fluent Ports and Adapters pipeline that isolates delivery DTOs from application use cases. +public sealed class PortsAndAdaptersPipeline : IPortsAndAdaptersPipeline +{ + private readonly Func _inboundAdapter; + private readonly Func> _applicationPort; + private readonly Func _outboundAdapter; + + private PortsAndAdaptersPipeline( + string name, + Func inboundAdapter, + Func> applicationPort, + Func outboundAdapter) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Ports and Adapters pipeline name is required.", nameof(name)) + : name; + _inboundAdapter = inboundAdapter ?? throw new ArgumentNullException(nameof(inboundAdapter)); + _applicationPort = applicationPort ?? throw new ArgumentNullException(nameof(applicationPort)); + _outboundAdapter = outboundAdapter ?? throw new ArgumentNullException(nameof(outboundAdapter)); + } + + public string Name { get; } + + public static Builder Create(string name = "ports-and-adapters") => new(name); + + public async ValueTask ExecuteAsync(TInbound inbound, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (inbound is null) + throw new ArgumentNullException(nameof(inbound)); + + var command = _inboundAdapter(inbound); + var result = await _applicationPort(command, cancellationToken).ConfigureAwait(false); + return _outboundAdapter(result); + } + + public sealed class Builder + { + private readonly string _name; + private Func? _inboundAdapter; + private Func>? _applicationPort; + private Func? _outboundAdapter; + + internal Builder(string name) => _name = name; + + public Builder AdaptInboundWith(Func adapter) + { + _inboundAdapter = adapter ?? throw new ArgumentNullException(nameof(adapter)); + return this; + } + + public Builder HandleWith(Func> applicationPort) + { + _applicationPort = applicationPort ?? throw new ArgumentNullException(nameof(applicationPort)); + return this; + } + + public Builder AdaptOutboundWith(Func adapter) + { + _outboundAdapter = adapter ?? throw new ArgumentNullException(nameof(adapter)); + return this; + } + + public PortsAndAdaptersPipeline Build() + { + if (_inboundAdapter is null) + throw new InvalidOperationException("Ports and Adapters pipeline requires an inbound adapter."); + if (_applicationPort is null) + throw new InvalidOperationException("Ports and Adapters pipeline requires an application port."); + if (_outboundAdapter is null) + throw new InvalidOperationException("Ports and Adapters pipeline requires an outbound adapter."); + + return new(_name, _inboundAdapter, _applicationPort, _outboundAdapter); + } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 462d6039..c8c0c7d0 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -75,6 +75,7 @@ using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PatternShowcase; using PatternKit.Examples.PointOfSale; +using PatternKit.Examples.PortsAndAdaptersDemo; using PatternKit.Examples.Pricing; using PatternKit.Examples.PriorityQueueDemo; using PatternKit.Examples.ProductionReadiness; @@ -231,6 +232,7 @@ public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Run public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner); public sealed record CustomerProfileLazyLoadPatternExample(CustomerProfileLazyLoadService Service); public sealed record ProductCatalogChangeDataCaptureExample(ProductCatalogChangeDataCaptureService Service); +public sealed record OrderEntryPortsAndAdaptersPatternExample(OrderEntryPortsAndAdaptersWorkflow Workflow); public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); public sealed record OrderDomainEventPatternExample(OrderDomainEventDemoRunner Runner); @@ -357,6 +359,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderIdentityMapPatternExample() .AddCustomerProfileLazyLoadPatternExample() .AddProductCatalogChangeDataCaptureExample() + .AddOrderEntryPortsAndAdaptersPatternExample() .AddOrderTransactionScriptPatternExample() .AddCustomerServiceLayerPatternExample() .AddOrderDomainEventPatternExample() @@ -1058,6 +1061,13 @@ public static IServiceCollection AddProductCatalogChangeDataCaptureExample(this return services.RegisterExample("Product Catalog Change Data Capture", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderEntryPortsAndAdaptersPatternExample(this IServiceCollection services) + { + services.AddOrderEntryPortsAndAdaptersDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Order Entry Ports and Adapters", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderTransactionScriptPatternExample(this IServiceCollection services) { services.AddOrderTransactionScriptDemo(); diff --git a/src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs b/src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs new file mode 100644 index 00000000..1668f9b6 --- /dev/null +++ b/src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.PortsAndAdapters; +using PatternKit.Generators.PortsAndAdapters; + +namespace PatternKit.Examples.PortsAndAdaptersDemo; + +public sealed record OrderEntryHttpRequest(string OrderId, string CustomerEmail, decimal Total); +public sealed record PlaceOrderCommand(string OrderId, string CustomerEmail, decimal Total); +public sealed record PlaceOrderResult(string OrderId, bool Accepted, string Message); +public sealed record OrderEntryHttpResponse(int StatusCode, string OrderId, string Message); + +public interface IOrderEntryApplicationPort +{ + ValueTask PlaceOrderAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default); +} + +public sealed class InMemoryOrderEntryApplicationPort : IOrderEntryApplicationPort +{ + private readonly List _accepted = []; + + public IReadOnlyList Accepted => _accepted; + + public ValueTask PlaceOrderAsync(PlaceOrderCommand command, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _accepted.Add(command); + return new(new PlaceOrderResult(command.OrderId, true, "accepted")); + } +} + +public static class OrderEntryPortsAndAdaptersPolicies +{ + public static PortsAndAdaptersPipeline CreateFluent(IOrderEntryApplicationPort applicationPort) + { + if (applicationPort is null) + throw new ArgumentNullException(nameof(applicationPort)); + + return PortsAndAdaptersPipeline.Create("order-entry") + .AdaptInboundWith(MapInbound) + .HandleWith((command, cancellationToken) => applicationPort.PlaceOrderAsync(command, cancellationToken)) + .AdaptOutboundWith(MapOutbound) + .Build(); + } + + public static PlaceOrderCommand MapInbound(OrderEntryHttpRequest request) + => new(request.OrderId, request.CustomerEmail, request.Total); + + public static OrderEntryHttpResponse MapOutbound(PlaceOrderResult result) + => new(result.Accepted ? 202 : 409, result.OrderId, result.Message); +} + +public sealed class OrderEntryPortsAndAdaptersWorkflow(IPortsAndAdaptersPipeline pipeline) +{ + public ValueTask PlaceOrderAsync(OrderEntryHttpRequest request, CancellationToken cancellationToken = default) + => pipeline.ExecuteAsync(request, cancellationToken); +} + +public static class OrderEntryPortsAndAdaptersServiceCollectionExtensions +{ + public static IServiceCollection AddOrderEntryPortsAndAdaptersDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton>(sp => + OrderEntryPortsAndAdaptersPolicies.CreateFluent(sp.GetRequiredService())); + services.AddSingleton(); + return services; + } +} + +[GeneratePortsAndAdapters( + typeof(OrderEntryHttpRequest), + typeof(PlaceOrderCommand), + typeof(PlaceOrderResult), + typeof(OrderEntryHttpResponse), + FactoryName = nameof(CreateGenerated), + PipelineName = "order-entry")] +public static partial class GeneratedOrderEntryPortsAndAdapters +{ + public static IOrderEntryApplicationPort ApplicationPort { get; set; } = new InMemoryOrderEntryApplicationPort(); + + [InboundAdapter] + private static PlaceOrderCommand MapInbound(OrderEntryHttpRequest request) + => OrderEntryPortsAndAdaptersPolicies.MapInbound(request); + + [ApplicationPort] + private static ValueTask Handle(PlaceOrderCommand command, CancellationToken cancellationToken) + => ApplicationPort.PlaceOrderAsync(command, cancellationToken); + + [OutboundAdapter] + private static OrderEntryHttpResponse MapOutbound(PlaceOrderResult result) + => OrderEntryPortsAndAdaptersPolicies.MapOutbound(result); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index f1d1fdd3..3f200637 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -608,6 +608,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Change Data Capture"], ["ordered mutation capture", "source-generated CDC pipeline factory", "DI composition"]), + Descriptor( + "Order Entry Ports and Adapters", + "src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs", + "test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs", + "docs/examples/order-entry-ports-and-adapters.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Ports and Adapters"], + ["hexagonal delivery boundary", "source-generated adapter factory", "DI composition"]), Descriptor( "Order Transaction Script Pattern", "src/PatternKit.Examples/TransactionScriptDemo/OrderTransactionScriptDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 28a43e40..2f8ce8d2 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1402,6 +1402,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/LazyLoadDemo/CustomerProfileLazyLoadDemoTests.cs", ["fluent deferred value loader", "generated lazy load factory", "DI-importable customer profile example"]), + Pattern("Ports and Adapters", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/ports-and-adapters.md", + "src/PatternKit.Core/Application/PortsAndAdapters/PortsAndAdaptersPipeline.cs", + "test/PatternKit.Tests/Application/PortsAndAdapters/PortsAndAdaptersPipelineTests.cs", + "docs/generators/ports-and-adapters.md", + "src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs", + "test/PatternKit.Generators.Tests/PortsAndAdaptersGeneratorTests.cs", + null, + "docs/examples/order-entry-ports-and-adapters.md", + "src/PatternKit.Examples/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemo.cs", + "test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs", + ["fluent hexagonal pipeline", "generated adapter factory", "DI-importable order entry example"]), + Pattern("Transaction Script", PatternFamily.ApplicationArchitecture, "docs/patterns/application/transaction-script.md", "src/PatternKit.Core/Application/TransactionScript/TransactionScript.cs", diff --git a/src/PatternKit.Generators.Abstractions/PortsAndAdapters/PortsAndAdaptersAttributes.cs b/src/PatternKit.Generators.Abstractions/PortsAndAdapters/PortsAndAdaptersAttributes.cs new file mode 100644 index 00000000..a14c87d2 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/PortsAndAdapters/PortsAndAdaptersAttributes.cs @@ -0,0 +1,33 @@ +namespace PatternKit.Generators.PortsAndAdapters; + +/// Generates a Ports and Adapters pipeline factory from attributed adapter and application port methods. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GeneratePortsAndAdaptersAttribute : Attribute +{ + public GeneratePortsAndAdaptersAttribute(Type inboundType, Type commandType, Type resultType, Type outboundType) + { + InboundType = inboundType ?? throw new ArgumentNullException(nameof(inboundType)); + CommandType = commandType ?? throw new ArgumentNullException(nameof(commandType)); + ResultType = resultType ?? throw new ArgumentNullException(nameof(resultType)); + OutboundType = outboundType ?? throw new ArgumentNullException(nameof(outboundType)); + } + + public Type InboundType { get; } + public Type CommandType { get; } + public Type ResultType { get; } + public Type OutboundType { get; } + public string FactoryName { get; set; } = "Create"; + public string PipelineName { get; set; } = "ports-and-adapters"; +} + +/// Marks the inbound adapter method for a generated Ports and Adapters pipeline. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class InboundAdapterAttribute : Attribute; + +/// Marks the application port handler for a generated Ports and Adapters pipeline. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class ApplicationPortAttribute : Attribute; + +/// Marks the outbound adapter method for a generated Ports and Adapters pipeline. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class OutboundAdapterAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 22ffb15f..a1d91130 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -465,3 +465,7 @@ PKCDC004 | PatternKit.Generators.ChangeDataCapture | Error | Change Data Capture PKLL001 | PatternKit.Generators.LazyLoading | Error | Lazy load host must be partial. PKLL002 | PatternKit.Generators.LazyLoading | Error | Lazy load configuration is invalid. PKLL003 | PatternKit.Generators.LazyLoading | Error | Lazy load member name is invalid. +PKPA001 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters host must be partial. +PKPA002 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters method is missing. +PKPA003 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters method signature is invalid. +PKPA004 | PatternKit.Generators.PortsAndAdapters | Error | Ports and Adapters factory name is invalid. diff --git a/src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs b/src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs new file mode 100644 index 00000000..8c1888b1 --- /dev/null +++ b/src/PatternKit.Generators/PortsAndAdapters/PortsAndAdaptersGenerator.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.PortsAndAdapters; + +[Generator] +public sealed class PortsAndAdaptersGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.PortsAndAdapters.GeneratePortsAndAdaptersAttribute"; + private const string InboundAttributeName = "PatternKit.Generators.PortsAndAdapters.InboundAdapterAttribute"; + private const string ApplicationAttributeName = "PatternKit.Generators.PortsAndAdapters.ApplicationPortAttribute"; + private const string OutboundAttributeName = "PatternKit.Generators.PortsAndAdapters.OutboundAdapterAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new("PKPA001", "Ports and Adapters host must be partial", "Type '{0}' is marked with [GeneratePortsAndAdapters] but is not declared as partial", "PatternKit.Generators.PortsAndAdapters", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingMethod = new("PKPA002", "Ports and Adapters method is missing", "Ports and Adapters host '{0}' must declare exactly one inbound adapter, application port, and outbound adapter", "PatternKit.Generators.PortsAndAdapters", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidMethod = new("PKPA003", "Ports and Adapters method signature is invalid", "Ports and Adapters method '{0}' has an invalid signature", "PatternKit.Generators.PortsAndAdapters", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidFactoryName = new("PKPA004", "Ports and Adapters factory name is invalid", "Ports and Adapters factory name '{0}' is not a valid identifier", "PatternKit.Generators.PortsAndAdapters", 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 attribute = candidate.Attributes.FirstOrDefault(static attr => attr.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attribute is not null) + Generate(spc, candidate.Type, candidate.Node, attribute); + }); + } + + 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 inboundType = GetTypeArgument(attribute, 0); + var commandType = GetTypeArgument(attribute, 1); + var resultType = GetTypeArgument(attribute, 2); + var outboundType = GetTypeArgument(attribute, 3); + if (inboundType is null || commandType is null || resultType is null || outboundType is null) + return; + + var inbound = GetSingleMethod(type, InboundAttributeName); + var application = GetSingleMethod(type, ApplicationAttributeName); + var outbound = GetSingleMethod(type, OutboundAttributeName); + if (inbound is null || application is null || outbound is null) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMethod, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsUnary(inbound, inboundType, commandType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMethod, inbound.Locations.FirstOrDefault(), inbound.Name)); + return; + } + + if (!IsApplicationPort(application, commandType, resultType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMethod, application.Locations.FirstOrDefault(), application.Name)); + return; + } + + if (!IsUnary(outbound, resultType, outboundType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMethod, outbound.Locations.FirstOrDefault(), outbound.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + if (!IsIdentifier(factoryName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidFactoryName, node.Identifier.GetLocation(), factoryName)); + return; + } + + var pipelineName = GetNamedString(attribute, "PipelineName"); + if (string.IsNullOrWhiteSpace(pipelineName)) + pipelineName = "ports-and-adapters"; + + context.AddSource($"{type.Name}.PortsAndAdapters.g.cs", SourceText.From( + GenerateSource(type, inboundType, commandType, resultType, outboundType, inbound.Name, application.Name, outbound.Name, factoryName, pipelineName!), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol inboundType, + INamedTypeSymbol commandType, + INamedTypeSymbol resultType, + INamedTypeSymbol outboundType, + string inboundName, + string applicationName, + string outboundName, + string factoryName, + string pipelineName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var inbound = inboundType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var command = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var result = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var outbound = outboundType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + var containingTypes = GetContainingTypes(type); + var indentLevel = 0; + foreach (var containingType in containingTypes) + { + AppendTypeDeclaration(sb, containingType, indentLevel); + sb.AppendLine(); + sb.AppendLine(new string(' ', indentLevel * 4) + "{"); + indentLevel++; + } + + AppendTypeDeclaration(sb, type, indentLevel); + sb.AppendLine(); + var indent = new string(' ', indentLevel * 4); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.AppendLine(indent + "{"); + sb.Append(memberIndent).Append("public static global::PatternKit.Application.PortsAndAdapters.PortsAndAdaptersPipeline<") + .Append(inbound).Append(", ").Append(command).Append(", ").Append(result).Append(", ").Append(outbound).Append("> ") + .Append(factoryName).AppendLine("()"); + sb.AppendLine(memberIndent + "{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.PortsAndAdapters.PortsAndAdaptersPipeline<") + .Append(inbound).Append(", ").Append(command).Append(", ").Append(result).Append(", ").Append(outbound).Append(">.Create(\"") + .Append(Escape(pipelineName)).AppendLine("\")"); + sb.Append(bodyIndent).Append(" .AdaptInboundWith(").Append(inboundName).AppendLine(")"); + sb.Append(bodyIndent).Append(" .HandleWith(").Append(applicationName).AppendLine(")"); + sb.Append(bodyIndent).Append(" .AdaptOutboundWith(").Append(outboundName).AppendLine(")"); + sb.AppendLine(bodyIndent + " .Build();"); + sb.AppendLine(memberIndent + "}"); + sb.AppendLine(indent + "}"); + for (var i = containingTypes.Length - 1; i >= 0; i--) + sb.AppendLine(new string(' ', i * 4) + "}"); + + return sb.ToString(); + } + + private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type) + { + var containingTypes = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + containingTypes.Push(current); + + return containingTypes.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel) + { + sb.Append(new string(' ', indentLevel * 4)); + sb.Append(type.DeclaredAccessibility == Accessibility.Public ? "public " : "internal "); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name); + } + + private static INamedTypeSymbol? GetTypeArgument(AttributeData attribute, int index) + => attribute.ConstructorArguments.Length > index ? attribute.ConstructorArguments[index].Value as INamedTypeSymbol : null; + + private static IMethodSymbol? GetSingleMethod(INamedTypeSymbol type, string attributeName) + { + var methods = type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + return methods.Length == 1 ? methods[0] : null; + } + + private static bool IsUnary(IMethodSymbol method, ITypeSymbol inputType, ITypeSymbol outputType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, inputType) + && SymbolEqualityComparer.Default.Equals(method.ReturnType, outputType); + + private static bool IsApplicationPort(IMethodSymbol method, ITypeSymbol commandType, ITypeSymbol resultType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 2 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, commandType) + && method.Parameters[1].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken" + && method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.Tasks.ValueTask<" + resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">"; + + private static bool IsIdentifier(string value) + => SyntaxFacts.IsValidIdentifier(value) && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None; + + 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("\"", "\\\""); +} diff --git a/test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs b/test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs new file mode 100644 index 00000000..84c6db7b --- /dev/null +++ b/test/PatternKit.Examples.Tests/PortsAndAdaptersDemo/OrderEntryPortsAndAdaptersDemoTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.PortsAndAdaptersDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.PortsAndAdaptersDemo; + +public sealed class OrderEntryPortsAndAdaptersDemoTests +{ + [Scenario("Order entry ports and adapters supports fluent and generated paths")] + [Fact] + public async Task Order_Entry_Ports_And_Adapters_Supports_Fluent_And_Generated_Paths() + { + var fluentPort = new InMemoryOrderEntryApplicationPort(); + var fluent = OrderEntryPortsAndAdaptersPolicies.CreateFluent(fluentPort); + var generatedPort = new InMemoryOrderEntryApplicationPort(); + GeneratedOrderEntryPortsAndAdapters.ApplicationPort = generatedPort; + var generated = GeneratedOrderEntryPortsAndAdapters.CreateGenerated(); + + var fluentResponse = await fluent.ExecuteAsync(new("order-100", "buyer@example.com", 42m)); + var generatedResponse = await generated.ExecuteAsync(new("order-200", "buyer2@example.com", 84m)); + + ScenarioExpect.Equal(202, fluentResponse.StatusCode); + ScenarioExpect.Equal("order-100", fluentResponse.OrderId); + ScenarioExpect.Equal(1, fluentPort.Accepted.Count); + ScenarioExpect.Equal(202, generatedResponse.StatusCode); + ScenarioExpect.Equal("order-200", generatedResponse.OrderId); + ScenarioExpect.Equal(1, generatedPort.Accepted.Count); + } + + [Scenario("Order entry ports and adapters is importable through IServiceCollection")] + [Fact] + public async Task Order_Entry_Ports_And_Adapters_Is_Importable_Through_IServiceCollection() + { + using var provider = new ServiceCollection() + .AddOrderEntryPortsAndAdaptersDemo() + .BuildServiceProvider(validateScopes: true); + using var scope = provider.CreateScope(); + var workflow = scope.ServiceProvider.GetRequiredService(); + + var response = await workflow.PlaceOrderAsync(new("order-300", "buyer3@example.com", 126m)); + + ScenarioExpect.Equal(202, response.StatusCode); + ScenarioExpect.Equal("accepted", response.Message); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 05819705..2abef9df 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("472 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("476 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] @@ -239,6 +239,9 @@ private static string HumanizeScenarioBenchmarkName(string benchmarkClassName) if (patternName == "AntiCorruptionLayer") return "Anti-Corruption Layer"; + if (patternName == "PortsAndAdapters") + return "Ports and Adapters"; + if (patternName == "EventSourcing") return "Event Sourcing"; diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 8a8e67dd..4260eaef 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -116,6 +116,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Data Mapper", "Identity Map", "Lazy Load", + "Ports and Adapters", "Transaction Script", "Service Layer", "Domain Event", @@ -176,7 +177,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(42, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(4, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(20, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(27, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(28, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/PortsAndAdaptersGeneratorTests.cs b/test/PatternKit.Generators.Tests/PortsAndAdaptersGeneratorTests.cs new file mode 100644 index 00000000..df567335 --- /dev/null +++ b/test/PatternKit.Generators.Tests/PortsAndAdaptersGeneratorTests.cs @@ -0,0 +1,168 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Application.PortsAndAdapters; +using PatternKit.Generators.PortsAndAdapters; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Ports and Adapters generator")] +public sealed partial class PortsAndAdaptersGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits ports and adapters pipeline factory")] + [Fact] + public Task Generator_Emits_Ports_And_Adapters_Pipeline_Factory() + => Given("a configured ports and adapters declaration", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.PortsAndAdapters; + namespace Demo; + + public sealed record Inbound(string Id); + public sealed record Command(string Id); + public sealed record Result(string Id); + public sealed record Outbound(string Id); + + [GeneratePortsAndAdapters(typeof(Inbound), typeof(Command), typeof(Result), typeof(Outbound), FactoryName = "Build", PipelineName = "order-entry")] + public static partial class OrderEntry + { + [InboundAdapter] + private static Command AdaptInbound(Inbound inbound) => new(inbound.Id); + [ApplicationPort] + private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); + [OutboundAdapter] + private static Outbound AdaptOutbound(Result result) => new(result.Id); + } + """)) + .Then("generated source creates the configured pipeline", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source.HintName + source.Source); + ScenarioExpect.Contains("Create(\"order-entry\")", source.Source); + ScenarioExpect.Contains(".AdaptInboundWith(AdaptInbound)", source.Source); + ScenarioExpect.Contains(".HandleWith(Handle)", source.Source); + ScenarioExpect.Contains(".AdaptOutboundWith(AdaptOutbound)", source.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator emits nested ports and adapters host wrappers and safe defaults")] + [Fact] + public Task Generator_Emits_Nested_Ports_And_Adapters_Host_Wrappers_And_Safe_Defaults() + => Given("a nested ports and adapters declaration with a blank pipeline name", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.PortsAndAdapters; + namespace Demo; + + public sealed record Inbound(string Id); + public sealed record Command(string Id); + public sealed record Result(string Id); + public sealed record Outbound(string Id); + + public partial class OrderModule + { + [GeneratePortsAndAdapters(typeof(Inbound), typeof(Command), typeof(Result), typeof(Outbound), PipelineName = " ")] + internal static partial class OrderEntry + { + [InboundAdapter] + private static Command AdaptInbound(Inbound inbound) => new(inbound.Id); + [ApplicationPort] + private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); + [OutboundAdapter] + private static Outbound AdaptOutbound(Result result) => new(result.Id); + } + } + """)) + .Then("generated source wraps containing types and defaults the pipeline name", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public partial class OrderModule", source.Source); + ScenarioExpect.Contains("internal static partial class OrderEntry", source.Source); + ScenarioExpect.Contains("Create(\"ports-and-adapters\")", source.Source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid ports and adapters declarations")] + [Theory] + [InlineData("public static class OrderEntry { [InboundAdapter] private static Command AdaptInbound(Inbound inbound) => new(inbound.Id); [ApplicationPort] private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); [OutboundAdapter] private static Outbound AdaptOutbound(Result result) => new(result.Id); }", "PKPA001")] + [InlineData("public static partial class OrderEntry { [ApplicationPort] private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); [OutboundAdapter] private static Outbound AdaptOutbound(Result result) => new(result.Id); }", "PKPA002")] + [InlineData("public static partial class OrderEntry { [InboundAdapter] private static string AdaptInbound(Inbound inbound) => inbound.Id; [ApplicationPort] private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); [OutboundAdapter] private static Outbound AdaptOutbound(Result result) => new(result.Id); }", "PKPA003")] + [InlineData("public static partial class OrderEntry { [InboundAdapter] private static Command AdaptInbound(Inbound inbound) => new(inbound.Id); [ApplicationPort] private static Result Handle(Command command) => new(command.Id); [OutboundAdapter] private static Outbound AdaptOutbound(Result result) => new(result.Id); }", "PKPA003")] + [InlineData("public static partial class OrderEntry { [InboundAdapter] private static Command AdaptInbound(Inbound inbound) => new(inbound.Id); [ApplicationPort] private static ValueTask Handle(Command command, CancellationToken cancellationToken) => new(new Result(command.Id)); [OutboundAdapter] private static string AdaptOutbound(Result result) => result.Id; }", "PKPA003")] + public Task Generator_Reports_Invalid_Ports_And_Adapters_Declarations(string declaration, string diagnosticId) + => Given("an invalid ports and adapters declaration", () => Compile($$""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.PortsAndAdapters; + public sealed record Inbound(string Id); + public sealed record Command(string Id); + public sealed record Result(string Id); + public sealed record Outbound(string Id); + [GeneratePortsAndAdapters(typeof(Inbound), typeof(Command), typeof(Result), typeof(Outbound))] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + [Scenario("Ports and adapters attribute exposes generator configuration")] + [Fact] + public void Ports_And_Adapters_Attribute_Exposes_Generator_Configuration() + { + var attribute = new GeneratePortsAndAdaptersAttribute(typeof(string), typeof(int), typeof(long), typeof(decimal)) + { + FactoryName = "Build", + PipelineName = "orders" + }; + + ScenarioExpect.Equal(typeof(string), attribute.InboundType); + ScenarioExpect.Equal(typeof(int), attribute.CommandType); + ScenarioExpect.Equal(typeof(long), attribute.ResultType); + ScenarioExpect.Equal(typeof(decimal), attribute.OutboundType); + ScenarioExpect.Equal("Build", attribute.FactoryName); + ScenarioExpect.Equal("orders", attribute.PipelineName); + ScenarioExpect.Throws(() => new GeneratePortsAndAdaptersAttribute(null!, typeof(int), typeof(long), typeof(decimal))); + ScenarioExpect.Throws(() => new GeneratePortsAndAdaptersAttribute(typeof(string), null!, typeof(long), typeof(decimal))); + ScenarioExpect.Throws(() => new GeneratePortsAndAdaptersAttribute(typeof(string), typeof(int), null!, typeof(decimal))); + ScenarioExpect.Throws(() => new GeneratePortsAndAdaptersAttribute(typeof(string), typeof(int), typeof(long), null!)); + } + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "PortsAndAdaptersGeneratorTests", + extra: + [ + MetadataReference.CreateFromFile(typeof(PortsAndAdaptersPipeline<,,,>).Assembly.Location), + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()) + ]); + _ = RoslynTestHelpers.Run(compilation, new PortsAndAdaptersGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => new GeneratedSource(source.HintName, source.SourceText.ToString())).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(PortsAndAdaptersGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); + + private sealed record GeneratedSource(string HintName, string Source); +} diff --git a/test/PatternKit.Tests/Application/PortsAndAdapters/PortsAndAdaptersPipelineTests.cs b/test/PatternKit.Tests/Application/PortsAndAdapters/PortsAndAdaptersPipelineTests.cs new file mode 100644 index 00000000..62cc3afa --- /dev/null +++ b/test/PatternKit.Tests/Application/PortsAndAdapters/PortsAndAdaptersPipelineTests.cs @@ -0,0 +1,66 @@ +using PatternKit.Application.PortsAndAdapters; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.PortsAndAdapters; + +[Feature("Ports and Adapters")] +public sealed class PortsAndAdaptersPipelineTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Ports and Adapters isolates inbound and outbound adapters from the application port")] + [Fact] + public Task Ports_And_Adapters_Isolates_Inbound_And_Outbound_Adapters_From_The_Application_Port() + => Given("a ports and adapters pipeline", () => PortsAndAdaptersPipeline + .Create("order-entry") + .AdaptInboundWith(static inbound => new CreateOrderCommand(inbound.OrderId, inbound.CustomerEmail)) + .HandleWith(static (command, _) => new ValueTask(new CreateOrderResult(command.OrderId, command.Email, "accepted"))) + .AdaptOutboundWith(static result => new HttpCreateOrderResponse(202, result.OrderId, result.Status)) + .Build()) + .When("an inbound delivery DTO is executed", (Func, ValueTask>)(pipeline => + pipeline.ExecuteAsync(new HttpCreateOrderRequest("order-100", "buyer@example.com")))) + .Then("the outbound DTO is returned without leaking the application command", response => + { + ScenarioExpect.Equal(202, response.StatusCode); + ScenarioExpect.Equal("order-100", response.OrderId); + ScenarioExpect.Equal("accepted", response.Status); + }) + .AssertPassed(); + + [Scenario("Ports and Adapters validates configuration and execution arguments")] + [Fact] + public Task Ports_And_Adapters_Validates_Configuration_And_Execution_Arguments() + => Given("ports and adapters builders", () => true) + .Then("invalid configuration is rejected", (Func)(async _ => + { + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().AdaptInboundWith(null!)); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().HandleWith(null!)); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().AdaptOutboundWith(null!)); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().Build()); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().AdaptInboundWith(Inbound).Build()); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create().AdaptInboundWith(Inbound).HandleWith(Handle).Build()); + ScenarioExpect.Throws(() => PortsAndAdaptersPipeline.Create("").AdaptInboundWith(Inbound).HandleWith(Handle).AdaptOutboundWith(Outbound).Build()); + + var pipeline = PortsAndAdaptersPipeline.Create("order-entry") + .AdaptInboundWith(Inbound) + .HandleWith(Handle) + .AdaptOutboundWith(Outbound) + .Build(); + + ScenarioExpect.Equal("order-entry", pipeline.Name); + await ScenarioExpect.ThrowsAsync(() => pipeline.ExecuteAsync(null!).AsTask()); + await ScenarioExpect.ThrowsAsync(() => pipeline.ExecuteAsync(new("order", "email"), new CancellationToken(true)).AsTask()); + })) + .AssertPassed(); + + private static CreateOrderCommand Inbound(HttpCreateOrderRequest request) => new(request.OrderId, request.CustomerEmail); + + private static ValueTask Handle(CreateOrderCommand command, CancellationToken cancellationToken) => new(new CreateOrderResult(command.OrderId, command.Email, "accepted")); + + private static HttpCreateOrderResponse Outbound(CreateOrderResult result) => new(202, result.OrderId, result.Status); + + private sealed record HttpCreateOrderRequest(string OrderId, string CustomerEmail); + private sealed record CreateOrderCommand(string OrderId, string Email); + private sealed record CreateOrderResult(string OrderId, string Email, string Status); + private sealed record HttpCreateOrderResponse(int StatusCode, string OrderId, string Status); +}