From 175a64e14319a1b2e4e4347e777a841d8179c7f8 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 28 May 2026 17:10:15 -0500 Subject: [PATCH] feat: add bounded context pattern slice --- README.md | 6 +- .../Application/BoundedContextBenchmarks.cs | 35 +++ .../fulfillment-bounded-context-pattern.md | 21 ++ docs/examples/toc.yml | 3 + docs/generators/bounded-context.md | 21 ++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 14 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- docs/patterns/application/bounded-context.md | 25 ++ docs/patterns/toc.yml | 2 + .../BoundedContexts/BoundedContext.cs | 126 +++++++++ .../FulfillmentBoundedContextDemo.cs | 75 ++++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 + .../BoundedContextAttributes.cs | 41 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../BoundedContextDescriptorGenerator.cs | 252 ++++++++++++++++++ .../FulfillmentBoundedContextDemoTests.cs | 66 +++++ .../PatternKitBenchmarkCoverageTests.cs | 2 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 32 +++ .../BoundedContextDescriptorGeneratorTests.cs | 107 ++++++++ .../BoundedContexts/BoundedContextTests.cs | 57 ++++ 26 files changed, 922 insertions(+), 11 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/BoundedContextBenchmarks.cs create mode 100644 docs/examples/fulfillment-bounded-context-pattern.md create mode 100644 docs/generators/bounded-context.md create mode 100644 docs/patterns/application/bounded-context.md create mode 100644 src/PatternKit.Core/Application/BoundedContexts/BoundedContext.cs create mode 100644 src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/BoundedContexts/BoundedContextAttributes.cs create mode 100644 src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/BoundedContextDescriptorGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/BoundedContexts/BoundedContextTests.cs diff --git a/README.md b/README.md index ba56cb43..f3c08c60 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -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. +PatternKit currently tracks 105 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 | 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 | +| Application Architecture | 20 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, 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. | +| Bounded Context | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Bounded Context | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/BoundedContextBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/BoundedContextBenchmarks.cs new file mode 100644 index 00000000..6294a17d --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/BoundedContextBenchmarks.cs @@ -0,0 +1,35 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.BoundedContexts; +using PatternKit.Examples.BoundedContextDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "BoundedContext")] +public class BoundedContextBenchmarks +{ + private static readonly FulfillmentBoundedContextDemo.CatalogProduct Product = new("SKU-1", 42m); + + private readonly BoundedContextDescriptor _fluent = FulfillmentBoundedContextDemo.CreateFluentDescriptor(); + + private readonly BoundedContextDescriptor _generated = FulfillmentBoundedContextDemo.CreateGeneratedDescriptor(); + + [Benchmark(Baseline = true, Description = "Fluent: create bounded context descriptor")] + [BenchmarkCategory("Fluent", "Construction")] + public BoundedContextDescriptor Fluent_CreateDescriptor() + => FulfillmentBoundedContextDemo.CreateFluentDescriptor(); + + [Benchmark(Description = "Generated: create bounded context descriptor")] + [BenchmarkCategory("Generated", "Construction")] + public BoundedContextDescriptor Generated_CreateDescriptor() + => FulfillmentBoundedContextDemo.CreateGeneratedDescriptor(); + + [Benchmark(Description = "Fluent: inspect bounded context boundary")] + [BenchmarkCategory("Fluent", "Execution")] + public int Fluent_InspectBoundary() + => _fluent.Capabilities.Count + _fluent.Adapters.Count + FulfillmentBoundedContextDemo.Translate(Product).Sku.Length; + + [Benchmark(Description = "Generated: inspect bounded context boundary")] + [BenchmarkCategory("Generated", "Execution")] + public int Generated_InspectBoundary() + => _generated.Capabilities.Count + _generated.Adapters.Count + FulfillmentBoundedContextDemo.Translate(Product).Sku.Length; +} diff --git a/docs/examples/fulfillment-bounded-context-pattern.md b/docs/examples/fulfillment-bounded-context-pattern.md new file mode 100644 index 00000000..7901b1f3 --- /dev/null +++ b/docs/examples/fulfillment-bounded-context-pattern.md @@ -0,0 +1,21 @@ +# Fulfillment Bounded Context Pattern + +The fulfillment example demonstrates a production-shaped context boundary around shipment quoting and inventory allocation. + +The example exposes: + +- a fluent `BoundedContextDescriptor` +- a generated descriptor from `GeneratedFulfillmentContext` +- `IShipmentQuoter`, `IInventoryAllocator`, and `FulfillmentPlanner` +- `AddFulfillmentBoundedContextDemo()` for `IServiceCollection` + +```csharp +var services = new ServiceCollection() + .AddFulfillmentBoundedContextDemo(); + +using var provider = services.BuildServiceProvider(); +var planner = provider.GetRequiredService(); +var plan = planner.Plan(new CatalogProduct("SKU-1", 42m)); +``` + +The descriptor makes it clear that catalog products are translated into fulfillment items before the fulfillment model owns shipment and reservation decisions. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 8cc138bc..86527703 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -303,3 +303,6 @@ - name: Warehouse Scheduler Agent Supervisor href: warehouse-scheduler-agent-supervisor.md + +- name: Fulfillment Bounded Context Pattern + href: fulfillment-bounded-context-pattern.md diff --git a/docs/generators/bounded-context.md b/docs/generators/bounded-context.md new file mode 100644 index 00000000..fa846b0d --- /dev/null +++ b/docs/generators/bounded-context.md @@ -0,0 +1,21 @@ +# Bounded Context Generator + +`BoundedContextDescriptorGenerator` emits a `BoundedContextDescriptor` factory from attributes on a partial class or struct. + +```csharp +[GenerateBoundedContextDescriptor("Fulfillment", FactoryMethodName = "Build")] +[BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] +[BoundedContextAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem))] +public static partial class FulfillmentContext; +``` + +The generated method creates the descriptor, adds capabilities in deterministic order, adds model adapters, and returns the built descriptor. + +Diagnostics: + +| Id | Meaning | +| --- | --- | +| `PKCTX001` | The host type must be partial. | +| `PKCTX002` | At least one capability is required. | +| `PKCTX003` | Capability names must be unique within the context. | +| `PKCTX004` | Adapter registrations must be unique. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 2a347e17..97bd59a9 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -249,6 +249,9 @@ - name: Value Object href: value-object.md +- name: Bounded Context + href: bounded-context.md + - name: State Machine href: state-machine.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 88cb65d5..ddfe62f9 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. | +| Bounded Context | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Bounded Context | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | @@ -226,11 +228,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -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. +The coverage matrix currently publishes 105 catalog patterns and 420 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 | 19 | 76 | +| Application Architecture | 20 | 80 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -238,7 +240,7 @@ The coverage matrix currently publishes 104 catalog patterns and 416 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 99 generator source route results. +The generator matrix currently publishes 100 generator source route results. ## Hosting Integration Matrix Results @@ -261,8 +263,9 @@ The generator matrix currently publishes 99 generator source route results. | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | | Application Architecture | Aggregate Root | Covered | Covered | Covered | Covered | | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | -| Application Architecture | Audit Log | Covered | Covered | Covered | Covered | -| Application Architecture | CQRS | Covered | Covered | Covered | Covered | +| Application Architecture | Audit Log | Covered | Covered | Covered | Covered | +| Application Architecture | Bounded Context | 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 | Domain Service | Covered | Covered | Covered | Covered | @@ -387,6 +390,7 @@ The generator matrix currently publishes 99 generator source route results. | DataMapperGenerator | `src/PatternKit.Generators/DataMapping/DataMapperGenerator.cs` | Covered | | DecoratorGenerator | `src/PatternKit.Generators/DecoratorGenerator.cs` | Covered | | DomainEventDispatcherGenerator | `src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs` | Covered | +| BoundedContextDescriptorGenerator | `src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.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 | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index c9ae95e5..63424cdd 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. | +| Bounded Context | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Bounded Context | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | 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. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index ac680839..876b5019 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -116,6 +116,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | Bounded Context | `BoundedContextDescriptor` capabilities and adapters | Bounded Context 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 7eb2f748..68064856 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 104 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 105 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 | 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 | +| Application Architecture | 20 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, 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/bounded-context.md b/docs/patterns/application/bounded-context.md new file mode 100644 index 00000000..ebc1cfa7 --- /dev/null +++ b/docs/patterns/application/bounded-context.md @@ -0,0 +1,25 @@ +# Bounded Context + +Bounded Context makes a domain boundary explicit: the capabilities it owns, the services that implement those capabilities, and the adapters required when models cross into or out of that boundary. + +Use PatternKit's fluent path when the boundary is assembled dynamically or by configuration: + +```csharp +var descriptor = BoundedContextDescriptor.Create("Fulfillment") + .AddCapability("quote shipment", typeof(IShipmentQuoter)) + .AddCapability("allocate inventory", typeof(IInventoryAllocator)) + .AddAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem)) + .Build(); +``` + +Use the generated path when the boundary is stable and should be reviewed at compile time: + +```csharp +[GenerateBoundedContextDescriptor("Fulfillment")] +[BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] +[BoundedContextCapability("allocate inventory", typeof(IInventoryAllocator))] +[BoundedContextAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem))] +public static partial class FulfillmentContext; +``` + +The generated descriptor is suitable for `IServiceCollection` registration and for production-readiness checks that need to verify every context publishes its owned capabilities and model translations. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 285ac1be..89bd20ab 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -433,6 +433,8 @@ href: application/domain-event.md - name: Domain Service href: application/domain-service.md + - name: Bounded Context + href: application/bounded-context.md - name: Table Data Gateway href: application/table-data-gateway.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/BoundedContexts/BoundedContext.cs b/src/PatternKit.Core/Application/BoundedContexts/BoundedContext.cs new file mode 100644 index 00000000..a5aff5ea --- /dev/null +++ b/src/PatternKit.Core/Application/BoundedContexts/BoundedContext.cs @@ -0,0 +1,126 @@ +namespace PatternKit.Application.BoundedContexts; + +/// +/// Capability owned by a bounded context. +/// +public sealed record BoundedContextCapability +{ + public BoundedContextCapability(string name, Type serviceType) + { + Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Capability name is required.", nameof(name)) : name; + ServiceType = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); + } + + public string Name { get; } + + public Type ServiceType { get; } +} + +/// +/// Translation boundary between two bounded contexts. +/// +public sealed record BoundedContextAdapter +{ + public BoundedContextAdapter(string upstreamContext, string downstreamContext, Type sourceType, Type targetType) + { + UpstreamContext = string.IsNullOrWhiteSpace(upstreamContext) + ? throw new ArgumentException("Upstream context is required.", nameof(upstreamContext)) + : upstreamContext; + DownstreamContext = string.IsNullOrWhiteSpace(downstreamContext) + ? throw new ArgumentException("Downstream context is required.", nameof(downstreamContext)) + : downstreamContext; + SourceType = sourceType ?? throw new ArgumentNullException(nameof(sourceType)); + TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType)); + } + + public string UpstreamContext { get; } + + public string DownstreamContext { get; } + + public Type SourceType { get; } + + public Type TargetType { get; } +} + +/// +/// Explicit description of a domain boundary and the integration contracts it owns. +/// +public sealed class BoundedContextDescriptor +{ + private BoundedContextDescriptor(string name, IReadOnlyList capabilities, IReadOnlyList adapters) + { + Name = name; + Capabilities = capabilities; + Adapters = adapters; + } + + public string Name { get; } + + public IReadOnlyList Capabilities { get; } + + public IReadOnlyList Adapters { get; } + + public static Builder Create(string name) => new(name); + + public sealed class Builder + { + private readonly List _capabilities = []; + private readonly List _adapters = []; + + internal Builder(string name) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Context name is required.", nameof(name)) + : name; + } + + public string Name { get; } + + public Builder AddCapability(string name, Type serviceType) + => AddCapability(new BoundedContextCapability(name, serviceType)); + + public Builder AddCapability(BoundedContextCapability capability) + { + if (capability is null) + throw new ArgumentNullException(nameof(capability)); + + if (_capabilities.Any(existing => string.Equals(existing.Name, capability.Name, StringComparison.Ordinal))) + throw new InvalidOperationException($"Capability '{capability.Name}' is already registered for bounded context '{Name}'."); + + _capabilities.Add(capability); + return this; + } + + public Builder AddAdapter(string upstreamContext, string downstreamContext, Type sourceType, Type targetType) + => AddAdapter(new BoundedContextAdapter(upstreamContext, downstreamContext, sourceType, targetType)); + + public Builder AddAdapter(BoundedContextAdapter adapter) + { + if (adapter is null) + throw new ArgumentNullException(nameof(adapter)); + + if (_adapters.Any(existing => + string.Equals(existing.UpstreamContext, adapter.UpstreamContext, StringComparison.Ordinal) + && string.Equals(existing.DownstreamContext, adapter.DownstreamContext, StringComparison.Ordinal) + && existing.SourceType == adapter.SourceType + && existing.TargetType == adapter.TargetType)) + { + throw new InvalidOperationException( + $"Adapter '{adapter.UpstreamContext}->{adapter.DownstreamContext}' for '{adapter.SourceType.Name}' is already registered."); + } + + _adapters.Add(adapter); + return this; + } + + public BoundedContextDescriptor Build() + => new( + Name, + _capabilities.OrderBy(static capability => capability.Name, StringComparer.Ordinal).ToArray(), + _adapters + .OrderBy(static adapter => adapter.UpstreamContext, StringComparer.Ordinal) + .ThenBy(static adapter => adapter.DownstreamContext, StringComparer.Ordinal) + .ThenBy(static adapter => adapter.SourceType.FullName, StringComparer.Ordinal) + .ToArray()); + } +} diff --git a/src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs b/src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs new file mode 100644 index 00000000..0d342495 --- /dev/null +++ b/src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.BoundedContexts; +using PatternKit.Generators.BoundedContexts; + +namespace PatternKit.Examples.BoundedContextDemo; + +public static class FulfillmentBoundedContextDemo +{ + public sealed record CatalogProduct(string Sku, decimal Weight); + + public sealed record FulfillmentItem(string Sku, decimal Weight); + + public sealed record FulfillmentPlan(string Sku, string Carrier, bool InventoryReserved); + + public interface IShipmentQuoter + { + string SelectCarrier(FulfillmentItem item); + } + + public interface IInventoryAllocator + { + bool Reserve(FulfillmentItem item); + } + + public sealed class ShipmentQuoter : IShipmentQuoter + { + public string SelectCarrier(FulfillmentItem item) => item.Weight > 25m ? "freight" : "parcel"; + } + + public sealed class InventoryAllocator : IInventoryAllocator + { + public bool Reserve(FulfillmentItem item) => !string.IsNullOrWhiteSpace(item.Sku); + } + + public sealed class FulfillmentPlanner(IShipmentQuoter quoter, IInventoryAllocator allocator) + { + public FulfillmentPlan Plan(CatalogProduct product) + { + var item = Translate(product); + return new FulfillmentPlan(item.Sku, quoter.SelectCarrier(item), allocator.Reserve(item)); + } + } + + public static BoundedContextDescriptor CreateFluentDescriptor() + => BoundedContextDescriptor.Create("Fulfillment") + .AddCapability("quote shipment", typeof(IShipmentQuoter)) + .AddCapability("allocate inventory", typeof(IInventoryAllocator)) + .AddAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem)) + .Build(); + + public static BoundedContextDescriptor CreateGeneratedDescriptor() + => GeneratedFulfillmentContext.Create(); + + public static FulfillmentItem Translate(CatalogProduct product) + => new(product.Sku, product.Weight); + + public static IServiceCollection AddFulfillmentBoundedContextDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(CreateGeneratedDescriptor()); + return services; + } +} + +[GenerateBoundedContextDescriptor("Fulfillment")] +[BoundedContextCapability("quote shipment", typeof(FulfillmentBoundedContextDemo.IShipmentQuoter))] +[BoundedContextCapability("allocate inventory", typeof(FulfillmentBoundedContextDemo.IInventoryAllocator))] +[BoundedContextAdapter( + "Catalog", + "Fulfillment", + typeof(FulfillmentBoundedContextDemo.CatalogProduct), + typeof(FulfillmentBoundedContextDemo.FulfillmentItem))] +public static partial class GeneratedFulfillmentContext; diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 563974e1..bec6c799 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using PatternKit.Examples.AsyncStateDemo; using PatternKit.Examples.AuditLogDemo; using PatternKit.Examples.BackendsForFrontendsDemo; +using PatternKit.Examples.BoundedContextDemo; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; using PatternKit.Examples.CanonicalDataModelDemo; @@ -197,6 +198,7 @@ public sealed record MessagingBackplaneFacadeExample(Func Pricing, Interpreter Eligibility); public sealed record OrderAggregateRootPatternExample(OrderAggregateRootService Service); public sealed record ShippingDomainServicePatternExample(ShippingDomainService Service); +public sealed record FulfillmentBoundedContextPatternExample(FulfillmentBoundedContextDemo.FulfillmentPlanner Planner); public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry Registry, LoanApprovalService Service); public sealed record OrderValueObjectPatternExample(OrderValueObjectService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); @@ -309,6 +311,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedInterpreterRulesExample() .AddOrderAggregateRootPatternExample() .AddShippingDomainServicePatternExample() + .AddFulfillmentBoundedContextPatternExample() .AddLoanApprovalSpecificationsExample() .AddOrderValueObjectPatternExample() .AddOrderRepositoryPatternExample() @@ -928,6 +931,13 @@ public static IServiceCollection AddShippingDomainServicePatternExample(this ISe return services.RegisterExample("Shipping Domain Service Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddFulfillmentBoundedContextPatternExample(this IServiceCollection services) + { + services.AddFulfillmentBoundedContextDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Fulfillment Bounded Context Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServiceCollection services) { services.AddLoanApprovalSpecifications(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index f366a4d1..466437ef 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -536,6 +536,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Domain Service"], ["stateless domain operation", "source-generated operation registry", "DI composition"]), + Descriptor( + "Fulfillment Bounded Context Pattern", + "src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs", + "test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs", + "docs/examples/fulfillment-bounded-context-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Bounded Context"], + ["explicit domain boundary", "source-generated context descriptor", "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 0bcefdc6..47918817 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1207,6 +1207,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/DomainServiceDemo/ShippingDomainServiceDemoTests.cs", ["fluent stateless domain operation registry", "generated domain service registry", "DI-importable shipping decision example"]), + Pattern("Bounded Context", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/bounded-context.md", + "src/PatternKit.Core/Application/BoundedContexts/BoundedContext.cs", + "test/PatternKit.Tests/Application/BoundedContexts/BoundedContextTests.cs", + "docs/generators/bounded-context.md", + "src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs", + "test/PatternKit.Generators.Tests/BoundedContextDescriptorGeneratorTests.cs", + null, + "docs/examples/fulfillment-bounded-context-pattern.md", + "src/PatternKit.Examples/BoundedContextDemo/FulfillmentBoundedContextDemo.cs", + "test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs", + ["fluent context capability descriptor", "generated bounded context descriptor", "DI-importable fulfillment boundary 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/BoundedContexts/BoundedContextAttributes.cs b/src/PatternKit.Generators.Abstractions/BoundedContexts/BoundedContextAttributes.cs new file mode 100644 index 00000000..c3a5dc3c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/BoundedContexts/BoundedContextAttributes.cs @@ -0,0 +1,41 @@ +namespace PatternKit.Generators.BoundedContexts; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateBoundedContextDescriptorAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Context name is required.", nameof(name)) + : name; + + public string FactoryMethodName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class BoundedContextCapabilityAttribute(string name, Type serviceType) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Capability name is required.", nameof(name)) + : name; + + public Type ServiceType { get; } = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class BoundedContextAdapterAttribute( + string upstreamContext, + string downstreamContext, + Type sourceType, + Type targetType) : Attribute +{ + public string UpstreamContext { get; } = string.IsNullOrWhiteSpace(upstreamContext) + ? throw new ArgumentException("Upstream context is required.", nameof(upstreamContext)) + : upstreamContext; + + public string DownstreamContext { get; } = string.IsNullOrWhiteSpace(downstreamContext) + ? throw new ArgumentException("Downstream context is required.", nameof(downstreamContext)) + : downstreamContext; + + public Type SourceType { get; } = sourceType ?? throw new ArgumentNullException(nameof(sourceType)); + + public Type TargetType { get; } = targetType ?? throw new ArgumentNullException(nameof(targetType)); +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 56f8790e..0222dad7 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. +PKCTX001 | PatternKit.Generators.BoundedContexts | Error | Bounded context descriptor host must be partial. +PKCTX002 | PatternKit.Generators.BoundedContexts | Error | Bounded context descriptor must declare at least one capability. +PKCTX003 | PatternKit.Generators.BoundedContexts | Error | Bounded context capability is duplicated. +PKCTX004 | PatternKit.Generators.BoundedContexts | Error | Bounded context adapter is duplicated. 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. diff --git a/src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs b/src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs new file mode 100644 index 00000000..1f1fd55e --- /dev/null +++ b/src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs @@ -0,0 +1,252 @@ +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.BoundedContexts; + +[Generator] +public sealed class BoundedContextDescriptorGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.BoundedContexts.GenerateBoundedContextDescriptorAttribute"; + private const string CapabilityAttributeName = "PatternKit.Generators.BoundedContexts.BoundedContextCapabilityAttribute"; + private const string AdapterAttributeName = "PatternKit.Generators.BoundedContexts.BoundedContextAdapterAttribute"; + + 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( + "PKCTX001", + "Bounded context descriptor host must be partial", + "Type '{0}' is marked with [GenerateBoundedContextDescriptor] but is not declared as partial", + "PatternKit.Generators.BoundedContexts", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingCapabilities = new( + "PKCTX002", + "Bounded context descriptor has no capabilities", + "Type '{0}' is marked with [GenerateBoundedContextDescriptor] but does not declare any bounded context capabilities", + "PatternKit.Generators.BoundedContexts", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateCapability = new( + "PKCTX003", + "Bounded context capability is duplicated", + "Bounded context capability '{0}' is registered more than once", + "PatternKit.Generators.BoundedContexts", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateAdapter = new( + "PKCTX004", + "Bounded context adapter is duplicated", + "Bounded context adapter '{0}' is registered more than once", + "PatternKit.Generators.BoundedContexts", + 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 name = attribute.ConstructorArguments[0].Value as string; + if (string.IsNullOrWhiteSpace(name)) + return; + + var capabilities = GetCapabilities(type); + if (capabilities.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingCapabilities, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (TryFindDuplicateCapability(capabilities, out var duplicateCapability)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateCapability, duplicateCapability.Location, duplicateCapability.Name)); + return; + } + + var adapters = GetAdapters(type); + if (TryFindDuplicateAdapter(adapters, out var duplicateAdapter)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateAdapter, duplicateAdapter.Location, duplicateAdapter.Key)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + context.AddSource($"{type.Name}.BoundedContextDescriptor.g.cs", SourceText.From( + GenerateSource(type, name!, capabilities, adapters, factoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetCapabilities(INamedTypeSymbol type) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => attr.AttributeClass?.ToDisplayString() == CapabilityAttributeName)) + { + var name = attr.ConstructorArguments[0].Value as string; + var serviceType = attr.ConstructorArguments[1].Value as ITypeSymbol; + if (!string.IsNullOrWhiteSpace(name) && serviceType is not null) + builder.Add(new Capability(name!, serviceType, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation())); + } + + return builder.ToImmutable(); + } + + private static ImmutableArray GetAdapters(INamedTypeSymbol type) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => attr.AttributeClass?.ToDisplayString() == AdapterAttributeName)) + { + var upstream = attr.ConstructorArguments[0].Value as string; + var downstream = attr.ConstructorArguments[1].Value as string; + var sourceType = attr.ConstructorArguments[2].Value as ITypeSymbol; + var targetType = attr.ConstructorArguments[3].Value as ITypeSymbol; + if (!string.IsNullOrWhiteSpace(upstream) + && !string.IsNullOrWhiteSpace(downstream) + && sourceType is not null + && targetType is not null) + { + builder.Add(new Adapter( + upstream!, + downstream!, + sourceType, + targetType, + $"{upstream}->{downstream}:{sourceType.ToDisplayString(TypeFormat)}:{targetType.ToDisplayString(TypeFormat)}", + attr.ApplicationSyntaxReference?.GetSyntax().GetLocation())); + } + } + + return builder.ToImmutable(); + } + + private static bool TryFindDuplicateCapability(IReadOnlyList capabilities, out Capability duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var capability in capabilities) + { + if (!seen.Add(capability.Name)) + { + duplicate = capability; + return true; + } + } + + duplicate = default; + return false; + } + + private static bool TryFindDuplicateAdapter(IReadOnlyList adapters, out Adapter duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var adapter in adapters) + { + if (!seen.Add(adapter.Key)) + { + duplicate = adapter; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + string contextName, + IReadOnlyList capabilities, + IReadOnlyList adapters, + 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.BoundedContexts.BoundedContextDescriptor ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.BoundedContexts.BoundedContextDescriptor.Create(\"") + .Append(Escape(contextName)) + .AppendLine("\");"); + + foreach (var capability in capabilities.OrderBy(static capability => capability.Name, System.StringComparer.Ordinal)) + { + sb.Append(" builder.AddCapability(\"") + .Append(Escape(capability.Name)) + .Append("\", typeof(") + .Append(capability.ServiceType.ToDisplayString(TypeFormat)) + .AppendLine("));"); + } + + foreach (var adapter in adapters.OrderBy(static adapter => adapter.Key, System.StringComparer.Ordinal)) + { + sb.Append(" builder.AddAdapter(\"") + .Append(Escape(adapter.UpstreamContext)) + .Append("\", \"") + .Append(Escape(adapter.DownstreamContext)) + .Append("\", typeof(") + .Append(adapter.SourceType.ToDisplayString(TypeFormat)) + .Append("), typeof(") + .Append(adapter.TargetType.ToDisplayString(TypeFormat)) + .AppendLine("));"); + } + + 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 Capability(string Name, ITypeSymbol ServiceType, Location? Location); + + private readonly record struct Adapter( + string UpstreamContext, + string DownstreamContext, + ITypeSymbol SourceType, + ITypeSymbol TargetType, + string Key, + Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs b/test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs new file mode 100644 index 00000000..52d907cd --- /dev/null +++ b/test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.BoundedContextDemo; +using PatternKit.Examples.DependencyInjection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.BoundedContextDemo; + +[Feature("Fulfillment bounded context example")] +public sealed class FulfillmentBoundedContextDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated bounded context descriptors match")] + [Fact] + public Task Fluent_And_Generated_Bounded_Context_Descriptors_Match() + => Given("fluent and generated fulfillment context descriptors", () => new + { + Fluent = FulfillmentBoundedContextDemo.CreateFluentDescriptor(), + Generated = FulfillmentBoundedContextDemo.CreateGeneratedDescriptor() + }) + .Then("context names match", ctx => + ScenarioExpect.Equal(ctx.Fluent.Name, ctx.Generated.Name)) + .And("capability names match", ctx => + ScenarioExpect.Equal( + ctx.Fluent.Capabilities.Select(static capability => capability.Name).ToArray(), + ctx.Generated.Capabilities.Select(static capability => capability.Name).ToArray())) + .And("adapter boundaries match", ctx => + ScenarioExpect.Equal( + ctx.Fluent.Adapters.Select(static adapter => $"{adapter.UpstreamContext}->{adapter.DownstreamContext}").ToArray(), + ctx.Generated.Adapters.Select(static adapter => $"{adapter.UpstreamContext}->{adapter.DownstreamContext}").ToArray())) + .AssertPassed(); + + [Scenario("Fulfillment bounded context imports through IServiceCollection")] + [Fact] + public Task Fulfillment_Bounded_Context_Imports_Through_IServiceCollection() + => Given("services with the fulfillment bounded context example", () => + { + var services = new ServiceCollection(); + services.AddFulfillmentBoundedContextDemo(); + return services.BuildServiceProvider(); + }) + .When("planning fulfillment for a catalog product", sp => sp.GetRequiredService() + .Plan(new FulfillmentBoundedContextDemo.CatalogProduct("SKU-1", 42m))) + .Then("the plan uses the bounded context services", plan => + { + ScenarioExpect.Equal("SKU-1", plan.Sku); + ScenarioExpect.Equal("freight", plan.Carrier); + ScenarioExpect.True(plan.InventoryReserved); + }) + .AssertPassed(); + + [Scenario("Fulfillment bounded context is included in aggregate examples")] + [Fact] + public Task Fulfillment_Bounded_Context_Is_Included_In_Aggregate_Examples() + => Given("all PatternKit examples registered in DI", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(); + }) + .Then("the bounded context wrapper resolves", sp => + ScenarioExpect.NotNull(sp.GetRequiredService())) + .And("the generated descriptor resolves", sp => + ScenarioExpect.Equal("Fulfillment", sp.GetRequiredService().Name)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 6e9469ac..49487597 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("416 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("420 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 5f0fa319..8283e331 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", + "Bounded Context", "Domain Service", "Specification", "Value Object", @@ -161,7 +162,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(19, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(20, 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 799c3c79..625288aa 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -6,6 +6,7 @@ using PatternKit.Generators.AntiCorruption; using PatternKit.Generators.AuditLog; using PatternKit.Generators.BackendsForFrontends; +using PatternKit.Generators.BoundedContexts; using PatternKit.Generators.Bridge; using PatternKit.Generators.Bulkhead; using PatternKit.Generators.CacheAside; @@ -135,6 +136,9 @@ private enum TestTrigger { typeof(DomainEventHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDomainServiceRegistryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(DomainServiceOperationAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateBoundedContextDescriptorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(BoundedContextCapabilityAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, + { typeof(BoundedContextAdapterAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, 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 }, @@ -938,6 +942,34 @@ public void Domain_Service_Attributes_Expose_Defaults_And_Validation() ScenarioExpect.Throws(() => new DomainServiceOperationAttribute("")); } + [Scenario("Bounded Context Attributes Expose Defaults And Validation")] + [Fact] + public void Bounded_Context_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateBoundedContextDescriptorAttribute("Fulfillment") + { + FactoryMethodName = "Build" + }; + var capability = new BoundedContextCapabilityAttribute("quote shipment", typeof(string)); + var adapter = new BoundedContextAdapterAttribute("Catalog", "Fulfillment", typeof(string), typeof(int)); + + ScenarioExpect.Equal("Fulfillment", generator.Name); + ScenarioExpect.Equal("Build", generator.FactoryMethodName); + ScenarioExpect.Equal("quote shipment", capability.Name); + ScenarioExpect.Equal(typeof(string), capability.ServiceType); + ScenarioExpect.Equal("Catalog", adapter.UpstreamContext); + ScenarioExpect.Equal("Fulfillment", adapter.DownstreamContext); + ScenarioExpect.Equal(typeof(string), adapter.SourceType); + ScenarioExpect.Equal(typeof(int), adapter.TargetType); + ScenarioExpect.Throws(() => new GenerateBoundedContextDescriptorAttribute("")); + ScenarioExpect.Throws(() => new BoundedContextCapabilityAttribute("", typeof(string))); + ScenarioExpect.Throws(() => new BoundedContextCapabilityAttribute("quote shipment", null!)); + ScenarioExpect.Throws(() => new BoundedContextAdapterAttribute("", "Fulfillment", typeof(string), typeof(int))); + ScenarioExpect.Throws(() => new BoundedContextAdapterAttribute("Catalog", "", typeof(string), typeof(int))); + ScenarioExpect.Throws(() => new BoundedContextAdapterAttribute("Catalog", "Fulfillment", null!, typeof(int))); + ScenarioExpect.Throws(() => new BoundedContextAdapterAttribute("Catalog", "Fulfillment", typeof(string), null!)); + } + [Scenario("Retry Attributes Expose Defaults And Configuration")] [Fact] public void Retry_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/BoundedContextDescriptorGeneratorTests.cs b/test/PatternKit.Generators.Tests/BoundedContextDescriptorGeneratorTests.cs new file mode 100644 index 00000000..a0b71057 --- /dev/null +++ b/test/PatternKit.Generators.Tests/BoundedContextDescriptorGeneratorTests.cs @@ -0,0 +1,107 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.BoundedContexts; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class BoundedContextDescriptorGeneratorTests +{ + [Scenario("Generates bounded context descriptor from attributes")] + [Fact] + public void Generates_Bounded_Context_Descriptor_From_Attributes() + { + var source = """ + using PatternKit.Generators.BoundedContexts; + + namespace Demo; + + public interface IShipmentQuoter; + public interface IInventoryAllocator; + public sealed record Product(string Sku); + public sealed record ShipmentItem(string Sku); + + [GenerateBoundedContextDescriptor("Fulfillment", FactoryMethodName = "Build")] + [BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] + [BoundedContextCapability("allocate inventory", typeof(IInventoryAllocator))] + [BoundedContextAdapter("Catalog", "Fulfillment", typeof(Product), typeof(ShipmentItem))] + public static partial class FulfillmentContext; + """; + + var comp = CreateCompilation(source, nameof(Generates_Bounded_Context_Descriptor_From_Attributes)); + var gen = new BoundedContextDescriptorGenerator(); + _ = 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("FulfillmentContext.BoundedContextDescriptor.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("BoundedContextDescriptor.Create(\"Fulfillment\")", text); + ScenarioExpect.Contains("builder.AddCapability(\"allocate inventory\", typeof(global::Demo.IInventoryAllocator));", text); + ScenarioExpect.Contains("builder.AddAdapter(\"Catalog\", \"Fulfillment\", typeof(global::Demo.Product), typeof(global::Demo.ShipmentItem));", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Theory] + [InlineData(""" + using PatternKit.Generators.BoundedContexts; + [GenerateBoundedContextDescriptor("Fulfillment")] + public static class Context; + """, "PKCTX001")] + [InlineData(""" + using PatternKit.Generators.BoundedContexts; + [GenerateBoundedContextDescriptor("Fulfillment")] + public static partial class Context; + """, "PKCTX002")] + [InlineData(""" + using PatternKit.Generators.BoundedContexts; + public interface IShipmentQuoter; + [GenerateBoundedContextDescriptor("Fulfillment")] + [BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] + [BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] + public static partial class Context; + """, "PKCTX003")] + [InlineData(""" + using PatternKit.Generators.BoundedContexts; + public interface IShipmentQuoter; + public sealed record Product(string Sku); + public sealed record ShipmentItem(string Sku); + [GenerateBoundedContextDescriptor("Fulfillment")] + [BoundedContextCapability("quote shipment", typeof(IShipmentQuoter))] + [BoundedContextAdapter("Catalog", "Fulfillment", typeof(Product), typeof(ShipmentItem))] + [BoundedContextAdapter("Catalog", "Fulfillment", typeof(Product), typeof(ShipmentItem))] + public static partial class Context; + """, "PKCTX004")] + public void Reports_Bounded_Context_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.BoundedContexts.BoundedContextDescriptor).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(BoundedContextDescriptorGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new BoundedContextDescriptorGenerator(); + _ = 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/BoundedContexts/BoundedContextTests.cs b/test/PatternKit.Tests/Application/BoundedContexts/BoundedContextTests.cs new file mode 100644 index 00000000..53d07276 --- /dev/null +++ b/test/PatternKit.Tests/Application/BoundedContexts/BoundedContextTests.cs @@ -0,0 +1,57 @@ +using PatternKit.Application.BoundedContexts; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.BoundedContexts; + +[Feature("Bounded Context")] +public sealed class BoundedContextTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Builds descriptor with capabilities and adapters")] + [Fact] + public Task Builds_Descriptor_With_Capabilities_And_Adapters() + => Given("a bounded context builder", () => BoundedContextDescriptor.Create("Fulfillment")) + .When("registering capabilities and translations", builder => builder + .AddCapability("quote shipment", typeof(IShipmentQuoter)) + .AddCapability("allocate inventory", typeof(IInventoryAllocator)) + .AddAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem)) + .Build()) + .Then("the context name is preserved", descriptor => + ScenarioExpect.Equal("Fulfillment", descriptor.Name)) + .And("capabilities are ordered by name", descriptor => + ScenarioExpect.Equal(["allocate inventory", "quote shipment"], descriptor.Capabilities.Select(static capability => capability.Name).ToArray())) + .And("translation metadata captures the upstream and downstream contexts", descriptor => + { + var adapter = ScenarioExpect.Single(descriptor.Adapters); + ScenarioExpect.Equal("Catalog", adapter.UpstreamContext); + ScenarioExpect.Equal("Fulfillment", adapter.DownstreamContext); + ScenarioExpect.Equal(typeof(CatalogProduct), adapter.SourceType); + ScenarioExpect.Equal(typeof(FulfillmentItem), adapter.TargetType); + }) + .AssertPassed(); + + [Scenario("Rejects invalid bounded context registrations")] + [Fact] + public Task Rejects_Invalid_Bounded_Context_Registrations() + => Given("a bounded context builder", () => BoundedContextDescriptor.Create("Fulfillment")) + .Then("empty context names are rejected", _ => + ScenarioExpect.Throws(() => BoundedContextDescriptor.Create(" "))) + .And("duplicate capabilities are rejected", builder => + ScenarioExpect.Throws(() => builder + .AddCapability("quote shipment", typeof(IShipmentQuoter)) + .AddCapability("quote shipment", typeof(IShipmentQuoter)))) + .And("duplicate adapters are rejected", _ => + ScenarioExpect.Throws(() => BoundedContextDescriptor.Create("Fulfillment") + .AddAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem)) + .AddAdapter("Catalog", "Fulfillment", typeof(CatalogProduct), typeof(FulfillmentItem)))) + .AssertPassed(); + + private interface IShipmentQuoter; + + private interface IInventoryAllocator; + + private sealed record CatalogProduct(string Sku); + + private sealed record FulfillmentItem(string Sku); +}