From a8d5f4b24560e765c05d84a1d29ba24934640b99 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 28 May 2026 18:13:13 -0500 Subject: [PATCH] feat: add context map pattern slice --- README.md | 6 +- .../Application/ContextMapBenchmarks.cs | 33 +++ docs/examples/commerce-context-map-pattern.md | 20 ++ docs/examples/toc.yml | 3 + docs/generators/context-map.md | 17 ++ docs/generators/toc.yml | 3 + docs/guides/benchmark-results.md | 10 +- docs/guides/benchmarks.md | 2 + docs/guides/pattern-coverage.md | 1 + docs/index.md | 4 +- docs/patterns/application/context-map.md | 23 ++ docs/patterns/toc.yml | 2 + .../Application/ContextMaps/ContextMap.cs | 106 +++++++++ .../ContextMapDemo/CommerceContextMapDemo.cs | 53 +++++ ...rnKitExampleServiceCollectionExtensions.cs | 10 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../ContextMaps/ContextMapAttributes.cs | 45 ++++ .../AnalyzerReleases.Unshipped.md | 3 + .../ContextMapDescriptorGenerator.cs | 202 ++++++++++++++++++ .../CommerceContextMapDemoTests.cs | 62 ++++++ .../PatternKitBenchmarkCoverageTests.cs | 2 +- .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 29 +++ .../ContextMapDescriptorGeneratorTests.cs | 88 ++++++++ .../ContextMaps/ContextMapTests.cs | 45 ++++ 26 files changed, 784 insertions(+), 9 deletions(-) create mode 100644 benchmarks/PatternKit.Benchmarks/Application/ContextMapBenchmarks.cs create mode 100644 docs/examples/commerce-context-map-pattern.md create mode 100644 docs/generators/context-map.md create mode 100644 docs/patterns/application/context-map.md create mode 100644 src/PatternKit.Core/Application/ContextMaps/ContextMap.cs create mode 100644 src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/ContextMaps/ContextMapAttributes.cs create mode 100644 src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/ContextMapDescriptorGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/ContextMaps/ContextMapTests.cs diff --git a/README.md b/README.md index f3c08c60..447bb49b 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -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. +PatternKit currently tracks 106 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 | 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 | +| Application Architecture | 21 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, 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 | @@ -563,6 +563,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | 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. | +| Context Map | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Context Map | 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/ContextMapBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/ContextMapBenchmarks.cs new file mode 100644 index 00000000..643667a6 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/ContextMapBenchmarks.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.ContextMaps; +using PatternKit.Examples.ContextMapDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "ContextMap")] +public class ContextMapBenchmarks +{ + private readonly ContextMapDescriptor _fluent = CommerceContextMapDemo.CreateFluentMap(); + + private readonly ContextMapDescriptor _generated = CommerceContextMapDemo.CreateGeneratedMap(); + + [Benchmark(Baseline = true, Description = "Fluent: create context map descriptor")] + [BenchmarkCategory("Fluent", "Construction")] + public ContextMapDescriptor Fluent_CreateMap() + => CommerceContextMapDemo.CreateFluentMap(); + + [Benchmark(Description = "Generated: create context map descriptor")] + [BenchmarkCategory("Generated", "Construction")] + public ContextMapDescriptor Generated_CreateMap() + => CommerceContextMapDemo.CreateGeneratedMap(); + + [Benchmark(Description = "Fluent: inspect context map relationships")] + [BenchmarkCategory("Fluent", "Execution")] + public int Fluent_InspectRelationships() + => _fluent.Relationships.Count(static relationship => relationship.Kind == ContextRelationshipKind.PublishedLanguage); + + [Benchmark(Description = "Generated: inspect context map relationships")] + [BenchmarkCategory("Generated", "Execution")] + public int Generated_InspectRelationships() + => _generated.Relationships.Count(static relationship => relationship.Kind == ContextRelationshipKind.PublishedLanguage); +} diff --git a/docs/examples/commerce-context-map-pattern.md b/docs/examples/commerce-context-map-pattern.md new file mode 100644 index 00000000..7845936f --- /dev/null +++ b/docs/examples/commerce-context-map-pattern.md @@ -0,0 +1,20 @@ +# Commerce Context Map Pattern + +The commerce context map example shows how Catalog, Fulfillment, and Billing integrate without hiding the ownership boundary. + +The example exposes: + +- a fluent `ContextMapDescriptor` +- a generated descriptor from `GeneratedCommerceContextMap` +- a catalog-to-fulfillment translator +- `AddCommerceContextMapDemo()` for `IServiceCollection` + +```csharp +var services = new ServiceCollection() + .AddCommerceContextMapDemo(); + +using var provider = services.BuildServiceProvider(); +var summary = provider.GetRequiredService().Summarize(); +``` + +The generated map is useful for architecture tests, operational diagnostics, and documentation pipelines that need to inspect integration relationships. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 86527703..3d118bad 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -306,3 +306,6 @@ - name: Fulfillment Bounded Context Pattern href: fulfillment-bounded-context-pattern.md + +- name: Commerce Context Map Pattern + href: commerce-context-map-pattern.md diff --git a/docs/generators/context-map.md b/docs/generators/context-map.md new file mode 100644 index 00000000..76dfc76f --- /dev/null +++ b/docs/generators/context-map.md @@ -0,0 +1,17 @@ +# Context Map Generator + +`ContextMapDescriptorGenerator` emits a `ContextMapDescriptor` factory from relationship attributes on a partial class or struct. + +```csharp +[GenerateContextMapDescriptor("Commerce", FactoryMethodName = "Build")] +[ContextMapRelationship("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")] +public static partial class CommerceMap; +``` + +Diagnostics: + +| Id | Meaning | +| --- | --- | +| `PKCMAP001` | The host type must be partial. | +| `PKCMAP002` | At least one relationship is required. | +| `PKCMAP003` | Relationship registrations must be unique. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 97bd59a9..4798d3ac 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -252,6 +252,9 @@ - name: Bounded Context href: bounded-context.md +- name: Context Map + href: context-map.md + - name: State Machine href: state-machine.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index ddfe62f9..2d85e7e9 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -83,6 +83,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | 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. | +| Context Map | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Context Map | 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. | @@ -228,11 +230,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -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. +The coverage matrix currently publishes 106 catalog patterns and 424 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 | 20 | 80 | +| Application Architecture | 21 | 84 | | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | @@ -240,7 +242,7 @@ The coverage matrix currently publishes 105 catalog patterns and 420 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 100 generator source route results. +The generator matrix currently publishes 101 generator source route results. ## Hosting Integration Matrix Results @@ -265,6 +267,7 @@ The generator matrix currently publishes 100 generator source route results. | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | | Application Architecture | Audit Log | Covered | Covered | Covered | Covered | | Application Architecture | Bounded Context | Covered | Covered | Covered | Covered | +| Application Architecture | Context Map | 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 | @@ -391,6 +394,7 @@ The generator matrix currently publishes 100 generator source route results. | DecoratorGenerator | `src/PatternKit.Generators/DecoratorGenerator.cs` | Covered | | DomainEventDispatcherGenerator | `src/PatternKit.Generators/DomainEvents/DomainEventDispatcherGenerator.cs` | Covered | | BoundedContextDescriptorGenerator | `src/PatternKit.Generators/BoundedContexts/BoundedContextDescriptorGenerator.cs` | Covered | +| ContextMapDescriptorGenerator | `src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.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 63424cdd..003a38c7 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -98,6 +98,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | 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. | +| Context Map | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Context Map | 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 876b5019..18be3bd3 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -117,6 +117,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | Context Map | `ContextMapDescriptor` relationships and contracts | Context Map 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 68064856..12f205d3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 105 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 106 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 | 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 | +| Application Architecture | 21 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, 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/context-map.md b/docs/patterns/application/context-map.md new file mode 100644 index 00000000..5307ec02 --- /dev/null +++ b/docs/patterns/application/context-map.md @@ -0,0 +1,23 @@ +# Context Map + +Context Map documents how bounded contexts relate to each other. It captures upstream/downstream direction, the relationship style, and the contract used between contexts. + +Use the fluent path when the map is assembled from configuration or architecture metadata: + +```csharp +var map = ContextMapDescriptor.Create("Commerce") + .AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.PublishedLanguage, "ProductFeed") + .AddRelationship("Fulfillment", "Billing", ContextRelationshipKind.CustomerSupplier, "ShipmentBilling") + .Build(); +``` + +Use the generated path when the map is stable and should be reviewed in source: + +```csharp +[GenerateContextMapDescriptor("Commerce")] +[ContextMapRelationship("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")] +[ContextMapRelationship("Fulfillment", "Billing", ContextMapRelationshipKind.CustomerSupplier, "ShipmentBilling")] +public static partial class CommerceMap; +``` + +Register the generated descriptor in DI so services, health checks, documentation tools, or architecture tests can inspect context relationships at runtime. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 89bd20ab..88338454 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -435,6 +435,8 @@ href: application/domain-service.md - name: Bounded Context href: application/bounded-context.md + - name: Context Map + href: application/context-map.md - name: Table Data Gateway href: application/table-data-gateway.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/ContextMaps/ContextMap.cs b/src/PatternKit.Core/Application/ContextMaps/ContextMap.cs new file mode 100644 index 00000000..97a89b22 --- /dev/null +++ b/src/PatternKit.Core/Application/ContextMaps/ContextMap.cs @@ -0,0 +1,106 @@ +namespace PatternKit.Application.ContextMaps; + +/// +/// Relationship style between bounded contexts. +/// +public enum ContextRelationshipKind +{ + Partnership, + SharedKernel, + CustomerSupplier, + Conformist, + AntiCorruptionLayer, + OpenHostService, + PublishedLanguage, + SeparateWays +} + +/// +/// Directed relationship between two bounded contexts. +/// +public sealed record ContextMapRelationship +{ + public ContextMapRelationship(string upstreamContext, string downstreamContext, ContextRelationshipKind kind, string contractName) + { + 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; + ContractName = string.IsNullOrWhiteSpace(contractName) + ? throw new ArgumentException("Contract name is required.", nameof(contractName)) + : contractName; + Kind = kind; + } + + public string UpstreamContext { get; } + + public string DownstreamContext { get; } + + public ContextRelationshipKind Kind { get; } + + public string ContractName { get; } +} + +/// +/// Explicit map of bounded-context relationships for integration and ownership reviews. +/// +public sealed class ContextMapDescriptor +{ + private ContextMapDescriptor(string name, IReadOnlyList relationships) + { + Name = name; + Relationships = relationships; + } + + public string Name { get; } + + public IReadOnlyList Relationships { get; } + + public static Builder Create(string name) => new(name); + + public sealed class Builder + { + private readonly List _relationships = []; + + internal Builder(string name) + { + Name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Context map name is required.", nameof(name)) + : name; + } + + public string Name { get; } + + public Builder AddRelationship(string upstreamContext, string downstreamContext, ContextRelationshipKind kind, string contractName) + => AddRelationship(new ContextMapRelationship(upstreamContext, downstreamContext, kind, contractName)); + + public Builder AddRelationship(ContextMapRelationship relationship) + { + if (relationship is null) + throw new ArgumentNullException(nameof(relationship)); + + if (_relationships.Any(existing => + string.Equals(existing.UpstreamContext, relationship.UpstreamContext, StringComparison.Ordinal) + && string.Equals(existing.DownstreamContext, relationship.DownstreamContext, StringComparison.Ordinal) + && string.Equals(existing.ContractName, relationship.ContractName, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Relationship '{relationship.UpstreamContext}->{relationship.DownstreamContext}' for '{relationship.ContractName}' is already registered."); + } + + _relationships.Add(relationship); + return this; + } + + public ContextMapDescriptor Build() + => new( + Name, + _relationships + .OrderBy(static relationship => relationship.UpstreamContext, StringComparer.Ordinal) + .ThenBy(static relationship => relationship.DownstreamContext, StringComparer.Ordinal) + .ThenBy(static relationship => relationship.ContractName, StringComparer.Ordinal) + .ToArray()); + } +} diff --git a/src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs b/src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs new file mode 100644 index 00000000..6111dfca --- /dev/null +++ b/src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.ContextMaps; +using PatternKit.Generators.ContextMaps; +using GeneratedRelationshipKind = PatternKit.Generators.ContextMaps.ContextMapRelationshipKind; + +namespace PatternKit.Examples.ContextMapDemo; + +public static class CommerceContextMapDemo +{ + public sealed record CatalogProduct(string Sku, string Name); + + public sealed record FulfillmentProduct(string Sku, string Description); + + public sealed record BillingShipment(string ShipmentId, decimal Charge); + + public sealed record ContextMapSummary(int RelationshipCount, bool HasPublishedLanguage, bool HasCustomerSupplier); + + public sealed class CatalogToFulfillmentTranslator + { + public FulfillmentProduct Translate(CatalogProduct product) => new(product.Sku, product.Name); + } + + public sealed class CommerceContextMapReporter(ContextMapDescriptor descriptor) + { + public ContextMapSummary Summarize() + => new( + descriptor.Relationships.Count, + descriptor.Relationships.Any(static relationship => relationship.Kind == ContextRelationshipKind.PublishedLanguage), + descriptor.Relationships.Any(static relationship => relationship.Kind == ContextRelationshipKind.CustomerSupplier)); + } + + public static ContextMapDescriptor CreateFluentMap() + => ContextMapDescriptor.Create("Commerce") + .AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.PublishedLanguage, "ProductFeed") + .AddRelationship("Fulfillment", "Billing", ContextRelationshipKind.CustomerSupplier, "ShipmentBilling") + .Build(); + + public static ContextMapDescriptor CreateGeneratedMap() + => GeneratedCommerceContextMap.Create(); + + public static IServiceCollection AddCommerceContextMapDemo(this IServiceCollection services) + { + services.AddSingleton(CreateGeneratedMap()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} + +[GenerateContextMapDescriptor("Commerce")] +[ContextMapRelationship("Catalog", "Fulfillment", GeneratedRelationshipKind.PublishedLanguage, "ProductFeed")] +[ContextMapRelationship("Fulfillment", "Billing", GeneratedRelationshipKind.CustomerSupplier, "ShipmentBilling")] +public static partial class GeneratedCommerceContextMap; diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index bec6c799..17268f35 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -34,6 +34,7 @@ using PatternKit.Examples.Chain; using PatternKit.Examples.Chain.ConfigDriven; using PatternKit.Examples.CircuitBreakerDemo; +using PatternKit.Examples.ContextMapDemo; using PatternKit.Examples.DataMapperDemo; using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.DomainServiceDemo; @@ -199,6 +200,7 @@ public sealed record GeneratedInterpreterRulesExample(Interpreter Registry, LoanApprovalService Service); public sealed record OrderValueObjectPatternExample(OrderValueObjectService Service); public sealed record OrderRepositoryPatternExample(OrderRepositoryDemoRunner Runner, OrderRepositoryWorkflow Workflow); @@ -312,6 +314,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderAggregateRootPatternExample() .AddShippingDomainServicePatternExample() .AddFulfillmentBoundedContextPatternExample() + .AddCommerceContextMapPatternExample() .AddLoanApprovalSpecificationsExample() .AddOrderValueObjectPatternExample() .AddOrderRepositoryPatternExample() @@ -938,6 +941,13 @@ public static IServiceCollection AddFulfillmentBoundedContextPatternExample(this return services.RegisterExample("Fulfillment Bounded Context Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddCommerceContextMapPatternExample(this IServiceCollection services) + { + services.AddCommerceContextMapDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Commerce Context Map 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 466437ef..31d7d648 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -544,6 +544,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Bounded Context"], ["explicit domain boundary", "source-generated context descriptor", "DI composition"]), + Descriptor( + "Commerce Context Map Pattern", + "src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs", + "test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs", + "docs/examples/commerce-context-map-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Context Map"], + ["bounded context relationships", "source-generated context map", "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 47918817..bdf2546b 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1220,6 +1220,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/BoundedContextDemo/FulfillmentBoundedContextDemoTests.cs", ["fluent context capability descriptor", "generated bounded context descriptor", "DI-importable fulfillment boundary example"]), + Pattern("Context Map", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/context-map.md", + "src/PatternKit.Core/Application/ContextMaps/ContextMap.cs", + "test/PatternKit.Tests/Application/ContextMaps/ContextMapTests.cs", + "docs/generators/context-map.md", + "src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.cs", + "test/PatternKit.Generators.Tests/ContextMapDescriptorGeneratorTests.cs", + null, + "docs/examples/commerce-context-map-pattern.md", + "src/PatternKit.Examples/ContextMapDemo/CommerceContextMapDemo.cs", + "test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs", + ["fluent context relationship map", "generated context map descriptor", "DI-importable commerce relationship 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/ContextMaps/ContextMapAttributes.cs b/src/PatternKit.Generators.Abstractions/ContextMaps/ContextMapAttributes.cs new file mode 100644 index 00000000..f9a12f01 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/ContextMaps/ContextMapAttributes.cs @@ -0,0 +1,45 @@ +namespace PatternKit.Generators.ContextMaps; + +public enum ContextMapRelationshipKind +{ + Partnership, + SharedKernel, + CustomerSupplier, + Conformist, + AntiCorruptionLayer, + OpenHostService, + PublishedLanguage, + SeparateWays +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateContextMapDescriptorAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Context map name is required.", nameof(name)) + : name; + + public string FactoryMethodName { get; set; } = "Create"; +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class ContextMapRelationshipAttribute( + string upstreamContext, + string downstreamContext, + ContextMapRelationshipKind kind, + string contractName) : 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 ContextMapRelationshipKind Kind { get; } = kind; + + public string ContractName { get; } = string.IsNullOrWhiteSpace(contractName) + ? throw new ArgumentException("Contract name is required.", nameof(contractName)) + : contractName; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 0222dad7..871c0d2b 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -415,6 +415,9 @@ 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. +PKCMAP001 | PatternKit.Generators.ContextMaps | Error | Context map descriptor host must be partial. +PKCMAP002 | PatternKit.Generators.ContextMaps | Error | Context map descriptor must declare at least one relationship. +PKCMAP003 | PatternKit.Generators.ContextMaps | Error | Context map relationship is duplicated. 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. diff --git a/src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.cs b/src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.cs new file mode 100644 index 00000000..8a2ba035 --- /dev/null +++ b/src/PatternKit.Generators/ContextMaps/ContextMapDescriptorGenerator.cs @@ -0,0 +1,202 @@ +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.ContextMaps; + +[Generator] +public sealed class ContextMapDescriptorGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.ContextMaps.GenerateContextMapDescriptorAttribute"; + private const string RelationshipAttributeName = "PatternKit.Generators.ContextMaps.ContextMapRelationshipAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKCMAP001", + "Context map descriptor host must be partial", + "Type '{0}' is marked with [GenerateContextMapDescriptor] but is not declared as partial", + "PatternKit.Generators.ContextMaps", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingRelationships = new( + "PKCMAP002", + "Context map descriptor has no relationships", + "Type '{0}' is marked with [GenerateContextMapDescriptor] but does not declare any context map relationships", + "PatternKit.Generators.ContextMaps", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRelationship = new( + "PKCMAP003", + "Context map relationship is duplicated", + "Context map relationship '{0}' is registered more than once", + "PatternKit.Generators.ContextMaps", + 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 relationships = GetRelationships(type); + if (relationships.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingRelationships, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (TryFindDuplicate(relationships, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRelationship, duplicate.Location, duplicate.Key)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + context.AddSource($"{type.Name}.ContextMapDescriptor.g.cs", SourceText.From( + GenerateSource(type, name!, relationships, factoryMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetRelationships(INamedTypeSymbol type) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => attr.AttributeClass?.ToDisplayString() == RelationshipAttributeName)) + { + var upstream = attr.ConstructorArguments[0].Value as string; + var downstream = attr.ConstructorArguments[1].Value as string; + var kind = attr.ConstructorArguments[2].Value; + var contract = attr.ConstructorArguments[3].Value as string; + if (!string.IsNullOrWhiteSpace(upstream) + && !string.IsNullOrWhiteSpace(downstream) + && kind is int kindValue + && !string.IsNullOrWhiteSpace(contract)) + { + builder.Add(new Relationship( + upstream!, + downstream!, + kindValue, + contract!, + $"{upstream}->{downstream}:{contract}", + attr.ApplicationSyntaxReference?.GetSyntax().GetLocation())); + } + } + + return builder.ToImmutable(); + } + + private static bool TryFindDuplicate(IReadOnlyList relationships, out Relationship duplicate) + { + var seen = new HashSet(System.StringComparer.Ordinal); + foreach (var relationship in relationships) + { + if (!seen.Add(relationship.Key)) + { + duplicate = relationship; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + string mapName, + IReadOnlyList relationships, + 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.ContextMaps.ContextMapDescriptor ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.ContextMaps.ContextMapDescriptor.Create(\"") + .Append(Escape(mapName)) + .AppendLine("\");"); + + foreach (var relationship in relationships.OrderBy(static relationship => relationship.Key, System.StringComparer.Ordinal)) + { + sb.Append(" builder.AddRelationship(\"") + .Append(Escape(relationship.UpstreamContext)) + .Append("\", \"") + .Append(Escape(relationship.DownstreamContext)) + .Append("\", global::PatternKit.Application.ContextMaps.ContextRelationshipKind.") + .Append(GetRelationshipKindName(relationship.Kind)) + .Append(", \"") + .Append(Escape(relationship.ContractName)) + .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 GetRelationshipKindName(int value) + => value switch + { + 0 => "Partnership", + 1 => "SharedKernel", + 2 => "CustomerSupplier", + 3 => "Conformist", + 4 => "AntiCorruptionLayer", + 5 => "OpenHostService", + 6 => "PublishedLanguage", + 7 => "SeparateWays", + _ => "SeparateWays" + }; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private readonly record struct Relationship( + string UpstreamContext, + string DownstreamContext, + int Kind, + string ContractName, + string Key, + Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs b/test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs new file mode 100644 index 00000000..71d619b3 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ContextMapDemo/CommerceContextMapDemoTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.ContextMaps; +using PatternKit.Examples.ContextMapDemo; +using PatternKit.Examples.DependencyInjection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ContextMapDemo; + +[Feature("Commerce context map example")] +public sealed class CommerceContextMapDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated context maps match")] + [Fact] + public Task Fluent_And_Generated_Context_Maps_Match() + => Given("fluent and generated context maps", () => new + { + Fluent = CommerceContextMapDemo.CreateFluentMap(), + Generated = CommerceContextMapDemo.CreateGeneratedMap() + }) + .Then("map names match", ctx => + ScenarioExpect.Equal(ctx.Fluent.Name, ctx.Generated.Name)) + .And("relationship contracts match", ctx => + ScenarioExpect.Equal( + ctx.Fluent.Relationships.Select(static item => item.ContractName).ToArray(), + ctx.Generated.Relationships.Select(static item => item.ContractName).ToArray())) + .AssertPassed(); + + [Scenario("Commerce context map imports through IServiceCollection")] + [Fact] + public Task Commerce_Context_Map_Imports_Through_IServiceCollection() + => Given("services with the commerce context map example", () => + { + var services = new ServiceCollection(); + services.AddCommerceContextMapDemo(); + return services.BuildServiceProvider(); + }) + .When("summarizing the context map", sp => sp.GetRequiredService().Summarize()) + .Then("the summary reflects the production relationships", summary => + { + ScenarioExpect.Equal(2, summary.RelationshipCount); + ScenarioExpect.True(summary.HasPublishedLanguage); + ScenarioExpect.True(summary.HasCustomerSupplier); + }) + .AssertPassed(); + + [Scenario("Commerce context map is included in aggregate examples")] + [Fact] + public Task Commerce_Context_Map_Is_Included_In_Aggregate_Examples() + => Given("all PatternKit examples registered in DI", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(); + }) + .Then("the context map wrapper resolves", sp => + ScenarioExpect.NotNull(sp.GetRequiredService())) + .And("the generated descriptor resolves", sp => + ScenarioExpect.Equal("Commerce", sp.GetServices().Single().Name)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 49487597..eedf713f 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("420 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("424 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 8283e331..1a76cac6 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -102,6 +102,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "CQRS", "Aggregate Root", "Bounded Context", + "Context Map", "Domain Service", "Specification", "Value Object", @@ -162,7 +163,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(20, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(21, 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 625288aa..155bc12d 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -17,6 +17,7 @@ using PatternKit.Generators.Command; using PatternKit.Generators.Composer; using PatternKit.Generators.Composite; +using PatternKit.Generators.ContextMaps; using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; using PatternKit.Generators.DomainEvents; @@ -139,6 +140,8 @@ private enum TestTrigger { 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(GenerateContextMapDescriptorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ContextMapRelationshipAttribute), 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 }, @@ -970,6 +973,32 @@ public void Bounded_Context_Attributes_Expose_Defaults_And_Validation() ScenarioExpect.Throws(() => new BoundedContextAdapterAttribute("Catalog", "Fulfillment", typeof(string), null!)); } + [Scenario("Context Map Attributes Expose Defaults And Validation")] + [Fact] + public void Context_Map_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateContextMapDescriptorAttribute("Commerce") + { + FactoryMethodName = "Build" + }; + var relationship = new ContextMapRelationshipAttribute( + "Catalog", + "Fulfillment", + ContextMapRelationshipKind.PublishedLanguage, + "ProductFeed"); + + ScenarioExpect.Equal("Commerce", generator.Name); + ScenarioExpect.Equal("Build", generator.FactoryMethodName); + ScenarioExpect.Equal("Catalog", relationship.UpstreamContext); + ScenarioExpect.Equal("Fulfillment", relationship.DownstreamContext); + ScenarioExpect.Equal(ContextMapRelationshipKind.PublishedLanguage, relationship.Kind); + ScenarioExpect.Equal("ProductFeed", relationship.ContractName); + ScenarioExpect.Throws(() => new GenerateContextMapDescriptorAttribute("")); + ScenarioExpect.Throws(() => new ContextMapRelationshipAttribute("", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")); + ScenarioExpect.Throws(() => new ContextMapRelationshipAttribute("Catalog", "", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")); + ScenarioExpect.Throws(() => new ContextMapRelationshipAttribute("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "")); + } + [Scenario("Retry Attributes Expose Defaults And Configuration")] [Fact] public void Retry_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/ContextMapDescriptorGeneratorTests.cs b/test/PatternKit.Generators.Tests/ContextMapDescriptorGeneratorTests.cs new file mode 100644 index 00000000..1a35229a --- /dev/null +++ b/test/PatternKit.Generators.Tests/ContextMapDescriptorGeneratorTests.cs @@ -0,0 +1,88 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.ContextMaps; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class ContextMapDescriptorGeneratorTests +{ + [Scenario("Generates context map descriptor from attributes")] + [Fact] + public void Generates_Context_Map_Descriptor_From_Attributes() + { + var source = """ + using PatternKit.Generators.ContextMaps; + + namespace Demo; + + [GenerateContextMapDescriptor("Commerce", FactoryMethodName = "Build")] + [ContextMapRelationship("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")] + [ContextMapRelationship("Fulfillment", "Billing", ContextMapRelationshipKind.CustomerSupplier, "ShipmentBilling")] + public static partial class CommerceMap; + """; + + var comp = CreateCompilation(source, nameof(Generates_Context_Map_Descriptor_From_Attributes)); + var gen = new ContextMapDescriptorGenerator(); + _ = 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("CommerceMap.ContextMapDescriptor.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("ContextMapDescriptor.Create(\"Commerce\")", text); + ScenarioExpect.Contains("builder.AddRelationship(\"Catalog\", \"Fulfillment\", global::PatternKit.Application.ContextMaps.ContextRelationshipKind.PublishedLanguage, \"ProductFeed\");", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Theory] + [InlineData(""" + using PatternKit.Generators.ContextMaps; + [GenerateContextMapDescriptor("Commerce")] + public static class Contexts; + """, "PKCMAP001")] + [InlineData(""" + using PatternKit.Generators.ContextMaps; + [GenerateContextMapDescriptor("Commerce")] + public static partial class Contexts; + """, "PKCMAP002")] + [InlineData(""" + using PatternKit.Generators.ContextMaps; + [GenerateContextMapDescriptor("Commerce")] + [ContextMapRelationship("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")] + [ContextMapRelationship("Catalog", "Fulfillment", ContextMapRelationshipKind.PublishedLanguage, "ProductFeed")] + public static partial class Contexts; + """, "PKCMAP003")] + public void Reports_Context_Map_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.ContextMaps.ContextMapDescriptor).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(ContextMapDescriptorGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new ContextMapDescriptorGenerator(); + _ = 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/ContextMaps/ContextMapTests.cs b/test/PatternKit.Tests/Application/ContextMaps/ContextMapTests.cs new file mode 100644 index 00000000..21f376de --- /dev/null +++ b/test/PatternKit.Tests/Application/ContextMaps/ContextMapTests.cs @@ -0,0 +1,45 @@ +using PatternKit.Application.ContextMaps; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.ContextMaps; + +[Feature("Context Map")] +public sealed class ContextMapTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Builds context map relationships")] + [Fact] + public Task Builds_Context_Map_Relationships() + => Given("a context map builder", () => ContextMapDescriptor.Create("Commerce")) + .When("registering context relationships", builder => builder + .AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.PublishedLanguage, "ProductFeed") + .AddRelationship("Fulfillment", "Billing", ContextRelationshipKind.CustomerSupplier, "ShipmentBilling") + .Build()) + .Then("the map name is preserved", map => + ScenarioExpect.Equal("Commerce", map.Name)) + .And("relationships are ordered by upstream context", map => + ScenarioExpect.Equal(["Catalog", "Fulfillment"], map.Relationships.Select(static relationship => relationship.UpstreamContext).ToArray())) + .And("relationship metadata is preserved", map => + { + var relationship = ScenarioExpect.Single(map.Relationships.Where(static item => item.UpstreamContext == "Catalog")); + ScenarioExpect.Equal("Fulfillment", relationship.DownstreamContext); + ScenarioExpect.Equal(ContextRelationshipKind.PublishedLanguage, relationship.Kind); + ScenarioExpect.Equal("ProductFeed", relationship.ContractName); + }) + .AssertPassed(); + + [Scenario("Rejects invalid context map registrations")] + [Fact] + public Task Rejects_Invalid_Context_Map_Registrations() + => Given("a context map builder", () => ContextMapDescriptor.Create("Commerce")) + .Then("empty map names are rejected", _ => + ScenarioExpect.Throws(() => ContextMapDescriptor.Create(""))) + .And("empty relationship contracts are rejected", builder => + ScenarioExpect.Throws(() => builder.AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.PublishedLanguage, ""))) + .And("duplicate relationships are rejected", _ => + ScenarioExpect.Throws(() => ContextMapDescriptor.Create("Commerce") + .AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.PublishedLanguage, "ProductFeed") + .AddRelationship("Catalog", "Fulfillment", ContextRelationshipKind.AntiCorruptionLayer, "ProductFeed"))) + .AssertPassed(); +}