From 8bb3b31fddfc759384871fde84922e30b7096919 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 20:58:50 -0500 Subject: [PATCH] feat: add anti-corruption layer pattern support --- .../legacy-order-anti-corruption-layer.md | 21 ++ docs/examples/toc.yml | 3 + docs/generators/anti-corruption-layer.md | 36 +++ docs/generators/index.md | 5 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + .../application/anti-corruption-layer.md | 29 +++ docs/patterns/toc.yml | 6 + .../AntiCorruption/AntiCorruptionLayer.cs | 168 ++++++++++++ .../LegacyOrderAntiCorruptionDemo.cs | 106 ++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 13 + .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 15 +- .../AntiCorruptionAttributes.cs | 30 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../AntiCorruptionLayerGenerator.cs | 243 ++++++++++++++++++ .../LegacyOrderAntiCorruptionDemoTests.cs | 61 +++++ ...tternKitExampleDependencyInjectionTests.cs | 3 + .../PatternKitPatternCatalogTests.cs | 5 +- .../AbstractionsAttributeCoverageTests.cs | 32 +++ .../AntiCorruptionLayerGeneratorTests.cs | 159 ++++++++++++ .../AntiCorruptionLayerTests.cs | 95 +++++++ 22 files changed, 1043 insertions(+), 3 deletions(-) create mode 100644 docs/examples/legacy-order-anti-corruption-layer.md create mode 100644 docs/generators/anti-corruption-layer.md create mode 100644 docs/patterns/application/anti-corruption-layer.md create mode 100644 src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs create mode 100644 src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/AntiCorruption/AntiCorruptionAttributes.cs create mode 100644 src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/AntiCorruptionLayerGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Application/AntiCorruption/AntiCorruptionLayerTests.cs diff --git a/docs/examples/legacy-order-anti-corruption-layer.md b/docs/examples/legacy-order-anti-corruption-layer.md new file mode 100644 index 00000000..75631dcd --- /dev/null +++ b/docs/examples/legacy-order-anti-corruption-layer.md @@ -0,0 +1,21 @@ +# Legacy Order Anti-Corruption Layer + +This example models a legacy ERP order feed imported into a commerce domain. The anti-corruption layer keeps legacy DTO shape, currency codes, whitespace, and customer identifiers out of the domain model. + +It demonstrates: + +- a fluent `AntiCorruptionLayer` +- a source-generated anti-corruption layer factory +- an `IServiceCollection` extension that imports the demo into a standard .NET host + +```csharp +var services = new ServiceCollection(); +services.AddLegacyOrderAntiCorruptionDemo(); + +using var provider = services.BuildServiceProvider(); +var importer = provider.GetRequiredService(); + +var result = await importer.ImportAsync("ORD-100"); +``` + +Applications can replace `ILegacyOrderFeed` with their own gateway while keeping the generated layer registration. The accompanying TinyBDD tests validate accepted imports, rejected model drift, and DI composition. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index b73ee136..4675a67b 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -112,6 +112,9 @@ - name: Template Method (Async) href: template-method-async-demo.md +- name: Legacy Order Anti-Corruption Layer + href: legacy-order-anti-corruption-layer.md + - name: Inventory Retry Policy href: inventory-retry-policy.md diff --git a/docs/generators/anti-corruption-layer.md b/docs/generators/anti-corruption-layer.md new file mode 100644 index 00000000..41cfb902 --- /dev/null +++ b/docs/generators/anti-corruption-layer.md @@ -0,0 +1,36 @@ +# Anti-Corruption Layer Generator + +The anti-corruption layer generator creates a strongly typed `AntiCorruptionLayer` factory from declarative translator and validation attributes. + +```csharp +[GenerateAntiCorruptionLayer( + typeof(LegacyOrderDto), + typeof(CommerceOrder), + FactoryMethodName = "CreateGeneratedLayer", + LayerName = "legacy-order-import", + SourceSystem = "legacy-erp")] +public static partial class LegacyOrderAcl +{ + [AntiCorruptionTranslator] + private static CommerceOrder Translate(LegacyOrderDto order) + => new(order.OrderNumber.Trim(), order.GrossAmount, order.CustomerCode.Trim()); + + [AntiCorruptionExternalRule("Only USD orders are imported.")] + private static bool IsUsd(LegacyOrderDto order) => order.CurrencyCode == "USD"; + + [AntiCorruptionDomainRule("Imported order total must be positive.")] + private static bool HasPositiveTotal(CommerceOrder order) => order.TotalUsd > 0m; +} +``` + +The generated factory returns the same runtime layer as the fluent API. + +## Rules + +- The host type must be `partial`. +- Exactly one `[AntiCorruptionTranslator]` method is required. +- The translator must be `static`, return the domain type, and accept one external model parameter. +- External rules must be `static bool` methods with one external model parameter. +- Domain rules must be `static bool` methods with one domain model parameter. + +Diagnostics use the `PKACL` prefix. diff --git a/docs/generators/index.md b/docs/generators/index.md index 9b4147ea..260f50fb 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -59,6 +59,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**State Machine**](state-machine.md) | Deterministic finite state machines | `[StateMachine]` | | [**Strategy**](strategy.md) | Predicate-based dispatch with fluent builder | `[GenerateStrategy]` | | [**Specification**](specification.md) | Named business-rule registries | `[GenerateSpecificationRegistry]` | +| [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | @@ -154,6 +155,10 @@ public static partial class PricingRules { } [Template] public abstract partial class DataProcessor { } +// Anti-corruption layer - external model boundary +[GenerateAntiCorruptionLayer(typeof(LegacyOrderDto), typeof(CommerceOrder))] +public static partial class LegacyOrderAcl { } + // Visitor - type-safe double dispatch [GenerateVisitor] public interface IDocumentVisitor { } diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 9168aa7c..09f18b70 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -7,6 +7,9 @@ - name: Adapter href: adapter.md +- name: Anti-Corruption Layer + href: anti-corruption-layer.md + - name: Builder href: builder.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 301c328f..ce21875c 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -64,6 +64,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | +| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/anti-corruption-layer.md b/docs/patterns/application/anti-corruption-layer.md new file mode 100644 index 00000000..571d2276 --- /dev/null +++ b/docs/patterns/application/anti-corruption-layer.md @@ -0,0 +1,29 @@ +# Anti-Corruption Layer + +An Anti-Corruption Layer protects a domain model from external schemas, naming, invariants, and operational shortcuts. It validates the external model, translates only accepted inputs, then validates the resulting domain model before anything downstream observes it. + +PatternKit provides `AntiCorruptionLayer` in `PatternKit.Application.AntiCorruption`. + +```csharp +var layer = AntiCorruptionLayer + .Create("legacy-order-import") + .FromSource("legacy-erp") + .RequireExternal(static order => order.CurrencyCode == "USD", "Only USD orders are imported.") + .TranslateWith(static order => new CommerceOrder(order.OrderNumber.Trim(), order.GrossAmount, order.CustomerCode.Trim())) + .RequireDomain(static order => order.TotalUsd > 0m, "Imported order total must be positive.") + .Build(); + +var result = layer.Translate(legacyOrder); +``` + +The result reports whether the import was accepted, the source system, the protected domain value, and any rejection reason. + +## Production Notes + +- Keep external DTOs outside the domain model and translate at the application boundary. +- Use external validation for schema drift, unsupported values, missing identifiers, and source-specific quirks. +- Use domain validation for invariants that must remain true after mapping and normalization. +- Return explicit rejection reasons so ingestion pipelines can route invalid input to diagnostics or remediation. +- Register the layer through DI beside the gateway/feed that reads the external system. + +The legacy order example shows fluent and source-generated layer creation plus `IServiceCollection` registration for importing applications. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index eef1e10b..7ddbb817 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -323,6 +323,12 @@ href: cloud/cache-aside.md - name: Rate Limiting href: cloud/rate-limiting.md + - name: Application Architecture + items: + - name: Anti-Corruption Layer + href: application/anti-corruption-layer.md + - name: Specification + href: application/specification.md - name: Type-Dispatcher href: behavioral/type-dispatcher/index.md items: diff --git a/src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs b/src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs new file mode 100644 index 00000000..df27cbf4 --- /dev/null +++ b/src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs @@ -0,0 +1,168 @@ +namespace PatternKit.Application.AntiCorruption; + +/// +/// Outcome returned by an anti-corruption layer translation. +/// +public sealed class AntiCorruptionResult +{ + private AntiCorruptionResult(string sourceSystem, TDomain? value, bool accepted, string? rejectionReason) + { + SourceSystem = sourceSystem; + Value = value; + Accepted = accepted; + RejectionReason = rejectionReason; + } + + public string SourceSystem { get; } + public TDomain? Value { get; } + public bool Accepted { get; } + public bool Rejected => !Accepted; + public string? RejectionReason { get; } + + public static AntiCorruptionResult Success(string sourceSystem, TDomain value) + => new(sourceSystem, value, true, null); + + public static AntiCorruptionResult Rejection(string sourceSystem, string reason) + => new(sourceSystem, default, false, reason); +} + +/// +/// Translates external models into a protected domain model while rejecting invalid external or domain shapes. +/// +public sealed class AntiCorruptionLayer +{ + public delegate TDomain Translator(TExternal external); + public delegate bool Validator(T value); + + private readonly Translator _translator; + private readonly IReadOnlyList> _externalRules; + private readonly IReadOnlyList> _domainRules; + + private AntiCorruptionLayer( + string name, + string sourceSystem, + Translator translator, + IReadOnlyList> externalRules, + IReadOnlyList> domainRules) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Anti-corruption layer name is required.", nameof(name)); + if (string.IsNullOrWhiteSpace(sourceSystem)) + throw new ArgumentException("Source system is required.", nameof(sourceSystem)); + + Name = name; + SourceSystem = sourceSystem; + _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + _externalRules = externalRules ?? throw new ArgumentNullException(nameof(externalRules)); + _domainRules = domainRules ?? throw new ArgumentNullException(nameof(domainRules)); + } + + public string Name { get; } + public string SourceSystem { get; } + + public static Builder Create(string name = "anti-corruption-layer") => new(name); + + public AntiCorruptionResult Translate(TExternal external) + { + if (external is null) + throw new ArgumentNullException(nameof(external)); + + foreach (var rule in _externalRules) + { + if (!rule.Predicate(external)) + return AntiCorruptionResult.Rejection(SourceSystem, rule.RejectionReason); + } + + var domain = _translator(external); + if (domain is null) + return AntiCorruptionResult.Rejection(SourceSystem, "Translator returned a null domain value."); + + foreach (var rule in _domainRules) + { + if (!rule.Predicate(domain)) + return AntiCorruptionResult.Rejection(SourceSystem, rule.RejectionReason); + } + + return AntiCorruptionResult.Success(SourceSystem, domain); + } + + public async ValueTask> TranslateAsync( + TExternal external, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return await new ValueTask>(Translate(external)).ConfigureAwait(false); + } + + public sealed class Builder + { + private readonly string _name; + private readonly List> _externalRules = []; + private readonly List> _domainRules = []; + private string _sourceSystem = "external"; + private Translator? _translator; + + internal Builder(string name) => _name = name; + + public Builder FromSource(string sourceSystem) + { + _sourceSystem = sourceSystem; + return this; + } + + public Builder TranslateWith(Translator translator) + { + _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + return this; + } + + public Builder RejectExternalWhen(Validator predicate, string reason) + => AddExternalRule(value => !predicate(value), reason); + + public Builder RequireExternal(Validator predicate, string reason) + => AddExternalRule(predicate, reason); + + public Builder RejectDomainWhen(Validator predicate, string reason) + => AddDomainRule(value => !predicate(value), reason); + + public Builder RequireDomain(Validator predicate, string reason) + => AddDomainRule(predicate, reason); + + public AntiCorruptionLayer Build() + { + if (_translator is null) + throw new InvalidOperationException("Anti-corruption layer translator is required."); + + return new(_name, _sourceSystem, _translator, _externalRules.ToArray(), _domainRules.ToArray()); + } + + private Builder AddExternalRule(Validator predicate, string reason) + { + _externalRules.Add(new(predicate ?? throw new ArgumentNullException(nameof(predicate)), RequireReason(reason))); + return this; + } + + private Builder AddDomainRule(Validator predicate, string reason) + { + _domainRules.Add(new(predicate ?? throw new ArgumentNullException(nameof(predicate)), RequireReason(reason))); + return this; + } + + private static string RequireReason(string reason) + => string.IsNullOrWhiteSpace(reason) + ? throw new ArgumentException("Validation rejection reason is required.", nameof(reason)) + : reason; + } + + private sealed class ValidationRule + { + public ValidationRule(Validator predicate, string rejectionReason) + { + Predicate = predicate; + RejectionReason = rejectionReason; + } + + public Validator Predicate { get; } + public string RejectionReason { get; } + } +} diff --git a/src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs b/src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs new file mode 100644 index 00000000..e007760a --- /dev/null +++ b/src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.AntiCorruption; +using PatternKit.Generators.AntiCorruption; + +namespace PatternKit.Examples.AntiCorruptionDemo; + +public sealed record LegacyOrderDto(string OrderNumber, decimal GrossAmount, string CurrencyCode, string CustomerCode); +public sealed record CommerceOrder(string OrderId, decimal TotalUsd, string CustomerId); +public sealed record OrderImportResult(string SourceSystem, bool Accepted, bool Rejected, string? RejectionReason, CommerceOrder? Order); + +public interface ILegacyOrderFeed +{ + ValueTask ReadAsync(string orderNumber, CancellationToken cancellationToken = default); +} + +public sealed class ScriptedLegacyOrderFeed : ILegacyOrderFeed +{ + public ValueTask ReadAsync(string orderNumber, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber); + cancellationToken.ThrowIfCancellationRequested(); + return new(new LegacyOrderDto(orderNumber, 125m, "USD", "CUST-42")); + } +} + +public sealed class LegacyOrderImportService( + ILegacyOrderFeed feed, + AntiCorruptionLayer layer) +{ + public async ValueTask ImportAsync(string orderNumber, CancellationToken cancellationToken = default) + { + var legacy = await feed.ReadAsync(orderNumber, cancellationToken); + return Import(legacy); + } + + public OrderImportResult Import(LegacyOrderDto legacy) + { + var result = layer.Translate(legacy); + return new( + result.SourceSystem, + result.Accepted, + result.Rejected, + result.RejectionReason, + result.Value); + } +} + +public static partial class LegacyOrderAntiCorruptionPolicies +{ + public static AntiCorruptionLayer CreateFluentLayer() + => AntiCorruptionLayer + .Create("legacy-order-import") + .FromSource("legacy-erp") + .RequireExternal(static order => !string.IsNullOrWhiteSpace(order.OrderNumber), "Legacy order number is required.") + .RequireExternal(static order => string.Equals(order.CurrencyCode, "USD", StringComparison.OrdinalIgnoreCase), "Only USD orders are imported.") + .TranslateWith(static order => new CommerceOrder( + order.OrderNumber.Trim(), + order.GrossAmount, + order.CustomerCode.Trim().ToUpperInvariant())) + .RequireDomain(static order => order.TotalUsd > 0m, "Imported order total must be positive.") + .RequireDomain(static order => order.CustomerId.Length > 0, "Imported order must have a customer.") + .Build(); +} + +[GenerateAntiCorruptionLayer( + typeof(LegacyOrderDto), + typeof(CommerceOrder), + FactoryMethodName = "CreateGeneratedLayer", + LayerName = "legacy-order-import", + SourceSystem = "legacy-erp")] +public static partial class GeneratedLegacyOrderAntiCorruptionLayer +{ + [AntiCorruptionTranslator] + private static CommerceOrder Translate(LegacyOrderDto order) + => new( + order.OrderNumber.Trim(), + order.GrossAmount, + order.CustomerCode.Trim().ToUpperInvariant()); + + [AntiCorruptionExternalRule("Legacy order number is required.")] + private static bool HasOrderNumber(LegacyOrderDto order) + => !string.IsNullOrWhiteSpace(order.OrderNumber); + + [AntiCorruptionExternalRule("Only USD orders are imported.")] + private static bool IsUsd(LegacyOrderDto order) + => string.Equals(order.CurrencyCode, "USD", StringComparison.OrdinalIgnoreCase); + + [AntiCorruptionDomainRule("Imported order total must be positive.")] + private static bool HasPositiveTotal(CommerceOrder order) + => order.TotalUsd > 0m; + + [AntiCorruptionDomainRule("Imported order must have a customer.")] + private static bool HasCustomer(CommerceOrder order) + => order.CustomerId.Length > 0; +} + +public static class LegacyOrderAntiCorruptionDemoServiceCollectionExtensions +{ + public static IServiceCollection AddLegacyOrderAntiCorruptionDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedLegacyOrderAntiCorruptionLayer.CreateGeneratedLayer()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 59d5e5cf..2afd63ab 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using PatternKit.Application.AntiCorruption; using PatternKit.Application.Specification; using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Interpreter; @@ -15,6 +16,7 @@ using PatternKit.Creational.Prototype; using PatternKit.Creational.Singleton; using PatternKit.Examples.ApiGateway; +using PatternKit.Examples.AntiCorruptionDemo; using PatternKit.Examples.AsyncStateDemo; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; @@ -124,6 +126,7 @@ public sealed record ReactiveTransactionExample(ReactiveTransaction Transaction) public sealed record AsyncConnectionStateMachineExample(Func Log)>> RunAsync); public sealed record TemplateMethodSubclassingExample(DataProcessor Processor); public sealed record TemplateMethodAsyncExample(AsyncDataPipeline Pipeline); +public sealed record LegacyOrderAntiCorruptionExample(AntiCorruptionLayer Layer, LegacyOrderImportService Service); public sealed record InventoryRetryExample(RetryPolicy Policy, InventoryLookupService Service); public sealed record FulfillmentCircuitBreakerExample(CircuitBreakerPolicy Policy, FulfillmentCircuitBreakerService Service); public sealed record ShippingBulkheadExample(BulkheadPolicy Policy, ShippingBulkheadService Service); @@ -176,6 +179,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddAsyncConnectionStateMachineExample() .AddTemplateMethodSubclassingExample() .AddTemplateMethodAsyncExample() + .AddLegacyOrderAntiCorruptionExample() .AddInventoryRetryExample() .AddFulfillmentCircuitBreakerExample() .AddShippingBulkheadExample() @@ -547,6 +551,15 @@ public static IServiceCollection AddTemplateMethodAsyncExample(this IServiceColl return services.RegisterExample("Template Method Async", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddLegacyOrderAntiCorruptionExample(this IServiceCollection services) + { + services.AddLegacyOrderAntiCorruptionDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Legacy Order Anti-Corruption Layer", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddInventoryRetryExample(this IServiceCollection services) { services.AddInventoryRetryDemo(); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 5f608c3f..caf8cdc2 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -392,6 +392,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly, ["AsyncTemplate", "AsyncTemplateMethod"], ["cancellation", "async storage", "error observation"]), + Descriptor( + "Legacy Order Anti-Corruption Layer", + "src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs", + "test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs", + "docs/examples/legacy-order-anti-corruption-layer.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Anti-Corruption Layer"], + ["external model validation", "domain normalization", "DI composition"]), Descriptor( "Inventory Retry Policy", "src/PatternKit.Examples/RetryDemo/InventoryRetryDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 3ef8070e..7771bb42 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -620,7 +620,20 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/examples/loan-approval-specifications.md", "src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs", "test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs", - ["fluent specification composition", "generated specification registry", "DI-importable loan approval example"]) + ["fluent specification composition", "generated specification registry", "DI-importable loan approval example"]), + + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/anti-corruption-layer.md", + "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", + "test/PatternKit.Tests/Application/AntiCorruption/AntiCorruptionLayerTests.cs", + "docs/generators/anti-corruption-layer.md", + "src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs", + "test/PatternKit.Generators.Tests/AntiCorruptionLayerGeneratorTests.cs", + null, + "docs/examples/legacy-order-anti-corruption-layer.md", + "src/PatternKit.Examples/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemo.cs", + "test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs", + ["fluent translation boundary", "generated anti-corruption layer", "DI-importable legacy order import example"]) ]; public IReadOnlyList Patterns => Items; diff --git a/src/PatternKit.Generators.Abstractions/AntiCorruption/AntiCorruptionAttributes.cs b/src/PatternKit.Generators.Abstractions/AntiCorruption/AntiCorruptionAttributes.cs new file mode 100644 index 00000000..e05741e7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/AntiCorruption/AntiCorruptionAttributes.cs @@ -0,0 +1,30 @@ +namespace PatternKit.Generators.AntiCorruption; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateAntiCorruptionLayerAttribute(Type externalType, Type domainType) : Attribute +{ + public Type ExternalType { get; } = externalType ?? throw new ArgumentNullException(nameof(externalType)); + public Type DomainType { get; } = domainType ?? throw new ArgumentNullException(nameof(domainType)); + public string FactoryMethodName { get; set; } = "Create"; + public string LayerName { get; set; } = "anti-corruption-layer"; + public string SourceSystem { get; set; } = "external"; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class AntiCorruptionTranslatorAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class AntiCorruptionExternalRuleAttribute(string rejectionReason) : Attribute +{ + public string RejectionReason { get; } = string.IsNullOrWhiteSpace(rejectionReason) + ? throw new ArgumentException("Rejection reason is required.", nameof(rejectionReason)) + : rejectionReason; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class AntiCorruptionDomainRuleAttribute(string rejectionReason) : Attribute +{ + public string RejectionReason { get; } = string.IsNullOrWhiteSpace(rejectionReason) + ? throw new ArgumentException("Rejection reason is required.", nameof(rejectionReason)) + : rejectionReason; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index bf802cfa..903a0b3f 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -116,6 +116,10 @@ PKADP015 | PatternKit.Generators.Adapter | Error | Mapping method must be access PKADP016 | PatternKit.Generators.Adapter | Error | Static members are not supported PKADP017 | PatternKit.Generators.Adapter | Error | Ref-return members are not supported PKADP018 | PatternKit.Generators.Adapter | Error | Indexers are not supported +PKACL001 | PatternKit.Generators.AntiCorruption | Error | Anti-corruption layer host must be partial. +PKACL002 | PatternKit.Generators.AntiCorruption | Error | Anti-corruption layer must declare exactly one translator. +PKACL003 | PatternKit.Generators.AntiCorruption | Error | Anti-corruption layer translator signature is invalid. +PKACL004 | PatternKit.Generators.AntiCorruption | Error | Anti-corruption layer validation rule signature is invalid. PKBRG001 | PatternKit.Generators.Bridge | Error | Bridge abstraction must be partial PKBRG002 | PatternKit.Generators.Bridge | Error | Bridge implementor must be an interface or abstract class PKBRG003 | PatternKit.Generators.Bridge | Error | Implementor member is unsupported diff --git a/src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs b/src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs new file mode 100644 index 00000000..99773b3d --- /dev/null +++ b/src/PatternKit.Generators/AntiCorruption/AntiCorruptionLayerGenerator.cs @@ -0,0 +1,243 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.AntiCorruption; + +[Generator] +public sealed class AntiCorruptionLayerGenerator : IIncrementalGenerator +{ + private const string GenerateAntiCorruptionLayerAttributeName = "PatternKit.Generators.AntiCorruption.GenerateAntiCorruptionLayerAttribute"; + private const string AntiCorruptionTranslatorAttributeName = "PatternKit.Generators.AntiCorruption.AntiCorruptionTranslatorAttribute"; + private const string AntiCorruptionExternalRuleAttributeName = "PatternKit.Generators.AntiCorruption.AntiCorruptionExternalRuleAttribute"; + private const string AntiCorruptionDomainRuleAttributeName = "PatternKit.Generators.AntiCorruption.AntiCorruptionDomainRuleAttribute"; + + 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( + "PKACL001", + "Anti-corruption layer host must be partial", + "Type '{0}' is marked with [GenerateAntiCorruptionLayer] but is not declared as partial", + "PatternKit.Generators.AntiCorruption", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingTranslator = new( + "PKACL002", + "Anti-corruption layer translator is missing", + "Anti-corruption layer '{0}' must declare exactly one translator", + "PatternKit.Generators.AntiCorruption", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidTranslator = new( + "PKACL003", + "Anti-corruption layer translator signature is invalid", + "Translator method '{0}' must be static, return the domain type, and accept exactly one external parameter", + "PatternKit.Generators.AntiCorruption", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRule = new( + "PKACL004", + "Anti-corruption layer validation rule signature is invalid", + "Validation rule method '{0}' must be static, return bool, and accept exactly one matching model parameter", + "PatternKit.Generators.AntiCorruption", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAntiCorruptionLayerAttributeName, + 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() == GenerateAntiCorruptionLayerAttributeName); + 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 externalType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + var domainType = attribute.ConstructorArguments.Length >= 2 + ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol + : null; + if (externalType is null || domainType is null) + return; + + var translators = type.GetMembers().OfType() + .Where(static method => HasAttribute(method, AntiCorruptionTranslatorAttributeName)) + .ToArray(); + if (translators.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingTranslator, node.Identifier.GetLocation(), type.Name)); + return; + } + + var translator = translators[0]; + if (!IsTranslator(translator, externalType, domainType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidTranslator, translator.Locations.FirstOrDefault(), translator.Name)); + return; + } + + var externalRules = GetRules(type, AntiCorruptionExternalRuleAttributeName, externalType, context); + var domainRules = GetRules(type, AntiCorruptionDomainRuleAttributeName, domainType, context); + if (externalRules.IsDefault || domainRules.IsDefault) + return; + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var layerName = GetNamedString(attribute, "LayerName") ?? "anti-corruption-layer"; + var sourceSystem = GetNamedString(attribute, "SourceSystem") ?? "external"; + context.AddSource($"{type.Name}.AntiCorruptionLayer.g.cs", SourceText.From( + GenerateSource(type, externalType, domainType, translator.Name, externalRules, domainRules, factoryMethodName, layerName, sourceSystem), + Encoding.UTF8)); + } + + private static ImmutableArray GetRules( + INamedTypeSymbol type, + string attributeName, + INamedTypeSymbol modelType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attr in method.GetAttributes().Where(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + { + if (!IsRule(method, modelType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRule, method.Locations.FirstOrDefault(), method.Name)); + return default; + } + + var reason = attr.ConstructorArguments.Length == 1 + ? attr.ConstructorArguments[0].Value as string + : null; + builder.Add(new Rule(method.Name, reason ?? "Validation rule rejected the model.")); + } + } + + return builder.ToImmutable(); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol externalType, + INamedTypeSymbol domainType, + string translator, + IReadOnlyList externalRules, + IReadOnlyList domainRules, + string factoryMethodName, + string layerName, + string sourceSystem) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var externalTypeName = externalType.ToDisplayString(TypeFormat); + var domainTypeName = domainType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Application.AntiCorruption.AntiCorruptionLayer<") + .Append(externalTypeName) + .Append(", ") + .Append(domainTypeName) + .Append("> ") + .Append(factoryMethodName) + .AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.AntiCorruption.AntiCorruptionLayer<") + .Append(externalTypeName) + .Append(", ") + .Append(domainTypeName) + .Append(">.Create(\"") + .Append(Escape(layerName)) + .AppendLine("\")"); + sb.Append(" .FromSource(\"").Append(Escape(sourceSystem)).AppendLine("\")"); + sb.Append(" .TranslateWith(static external => ").Append(translator).AppendLine("(external));"); + + foreach (var rule in externalRules) + sb.Append(" builder.RequireExternal(static external => ").Append(rule.MethodName).Append("(external), \"").Append(Escape(rule.RejectionReason)).AppendLine("\");"); + foreach (var rule in domainRules) + sb.Append(" builder.RequireDomain(static domain => ").Append(rule.MethodName).Append("(domain), \"").Append(Escape(rule.RejectionReason)).AppendLine("\");"); + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsTranslator(IMethodSymbol method, ITypeSymbol externalType, ITypeSymbol domainType) + => method.IsStatic + && !method.IsGenericMethod + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, externalType) + && SymbolEqualityComparer.Default.Equals(method.ReturnType, domainType); + + private static bool IsRule(IMethodSymbol method, ITypeSymbol modelType) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, modelType); + + private static bool HasAttribute(IMethodSymbol method, string metadataName) + => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == metadataName); + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private readonly record struct Rule(string MethodName, string RejectionReason); +} diff --git a/test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs b/test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs new file mode 100644 index 00000000..e2d759ec --- /dev/null +++ b/test/PatternKit.Examples.Tests/AntiCorruptionDemo/LegacyOrderAntiCorruptionDemoTests.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.AntiCorruptionDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.AntiCorruptionDemo; + +[Feature("Legacy order anti-corruption demo")] +public sealed class LegacyOrderAntiCorruptionDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent anti-corruption layer imports valid legacy orders")] + [Fact] + public Task Fluent_Anti_Corruption_Layer_Imports_Valid_Legacy_Orders() + => Given("a legacy order import service using the fluent layer", () => + new LegacyOrderImportService(new ScriptedLegacyOrderFeed(), LegacyOrderAntiCorruptionPolicies.CreateFluentLayer())) + .When("importing a valid legacy order", service => + service.Import(new LegacyOrderDto(" ORD-100 ", 125m, "USD", " cust-42 "))) + .Then("the domain order is accepted with normalized identifiers", result => + result.Accepted + && result.Order is not null + && result.Order.OrderId == "ORD-100" + && result.Order.CustomerId == "CUST-42") + .AssertPassed(); + + [Scenario("Generated anti-corruption layer rejects legacy model drift")] + [Fact] + public Task Generated_Anti_Corruption_Layer_Rejects_Legacy_Model_Drift() + => Given("a legacy order import service using the generated layer", () => + new LegacyOrderImportService(new ScriptedLegacyOrderFeed(), GeneratedLegacyOrderAntiCorruptionLayer.CreateGeneratedLayer())) + .When("importing an order with an unsupported currency", service => + service.Import(new LegacyOrderDto("ORD-100", 125m, "EUR", "CUST-42"))) + .Then("the import is rejected before the domain model is exposed", result => + result.Rejected + && result.Order is null + && result.RejectionReason == "Only USD orders are imported.") + .AssertPassed(); + + [Scenario("Legacy order anti-corruption demo is importable through IServiceCollection")] + [Fact] + public Task Legacy_Order_Anti_Corruption_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service collection importing the anti-corruption demo", () => + { + var services = new ServiceCollection(); + services.AddLegacyOrderAntiCorruptionDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and using the registered import service", RunImportedDemoAsync) + .Then("the DI-owned generated layer imports the order", result => + result.Accepted && result.Order?.CustomerId == "CUST-42") + .AssertPassed(); + + private static async Task RunImportedDemoAsync(ServiceProvider provider) + { + using (provider) + { + var service = provider.GetRequiredService(); + return await service.ImportAsync("ORD-100"); + } + } +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 8a3f17c4..034e9a4d 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.AntiCorruptionDemo; using PatternKit.Examples.ApiGateway; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; @@ -85,6 +86,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var asyncState = provider.GetRequiredService(); var template = provider.GetRequiredService(); var asyncTemplate = provider.GetRequiredService(); + var antiCorruption = provider.GetRequiredService(); var routing = provider.GetRequiredService(); var generatedRecipients = provider.GetRequiredService(); var envelope = provider.GetRequiredService(); @@ -149,6 +151,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("async state machine connects", state.Final == PatternKit.Examples.AsyncStateDemo.ConnectionStateDemo.Mode.Connected), ("template method counts words", template.Processor.Execute("one two") == 2), ("async template method formats payloads", asyncResult == "PAYLOAD:7"), + ("generated anti-corruption layer imports legacy orders", antiCorruption.Service.Import(new LegacyOrderDto("ORD-100", 125m, "USD", "cust-42")).Accepted), ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), ("generated recipient list delivers billing and audit recipients", generatedRecipientList.DeliveredRecipients.Count == 2), ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index b9c458da..4bb73286 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -57,7 +57,8 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Cache-Aside", "Rate Limiting", "CQRS", - "Specification" + "Specification", + "Anti-Corruption Layer" ]; [Scenario("Catalog covers every canonical GoF pattern")] @@ -101,7 +102,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(10, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(2, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(3, 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 dc3a8712..ad18df3b 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -25,6 +25,7 @@ using PatternKit.Generators.Template; using PatternKit.Generators.Visitors; using PatternKit.Generators; +using PatternKit.Generators.AntiCorruption; using TinyBDD; namespace PatternKit.Generators.Tests; @@ -44,6 +45,10 @@ private enum TestTrigger public static TheoryData AttributeUsageCases => new() { + { typeof(GenerateAntiCorruptionLayerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(AntiCorruptionTranslatorAttribute), AttributeTargets.Method, false, false }, + { typeof(AntiCorruptionExternalRuleAttribute), AttributeTargets.Method, true, false }, + { typeof(AntiCorruptionDomainRuleAttribute), AttributeTargets.Method, true, false }, { typeof(GenerateAdapterAttribute), AttributeTargets.Class, true, false }, { typeof(AdapterMapAttribute), AttributeTargets.Method, false, false }, { typeof(BridgeImplementorAttribute), AttributeTargets.Interface | AttributeTargets.Class, false, false }, @@ -185,6 +190,33 @@ public void CacheAside_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new CacheAsidePredicateAttribute()); } + [Scenario("Anti Corruption Attributes Expose Defaults And Validation")] + [Fact] + public void AntiCorruption_Attributes_Expose_Defaults_And_Validation() + { + var generator = new GenerateAntiCorruptionLayerAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildAcl", + LayerName = "orders", + SourceSystem = "legacy-erp" + }; + var external = new AntiCorruptionExternalRuleAttribute("external invalid"); + var domain = new AntiCorruptionDomainRuleAttribute("domain invalid"); + + ScenarioExpect.Equal(typeof(string), generator.ExternalType); + ScenarioExpect.Equal(typeof(int), generator.DomainType); + ScenarioExpect.Equal("BuildAcl", generator.FactoryMethodName); + ScenarioExpect.Equal("orders", generator.LayerName); + ScenarioExpect.Equal("legacy-erp", generator.SourceSystem); + ScenarioExpect.Equal("external invalid", external.RejectionReason); + ScenarioExpect.Equal("domain invalid", domain.RejectionReason); + ScenarioExpect.Throws(() => new GenerateAntiCorruptionLayerAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateAntiCorruptionLayerAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new AntiCorruptionExternalRuleAttribute("")); + ScenarioExpect.Throws(() => new AntiCorruptionDomainRuleAttribute(" ")); + ScenarioExpect.IsType(new AntiCorruptionTranslatorAttribute()); + } + [Scenario("Rate Limiting Attributes Expose Defaults And Configuration")] [Fact] public void RateLimiting_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/AntiCorruptionLayerGeneratorTests.cs b/test/PatternKit.Generators.Tests/AntiCorruptionLayerGeneratorTests.cs new file mode 100644 index 00000000..0fb93c73 --- /dev/null +++ b/test/PatternKit.Generators.Tests/AntiCorruptionLayerGeneratorTests.cs @@ -0,0 +1,159 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.AntiCorruption; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class AntiCorruptionLayerGeneratorTests +{ + [Scenario("Generates anti-corruption layer factory")] + [Fact] + public void GeneratesAntiCorruptionLayerFactory() + { + var source = """ + using PatternKit.Generators.AntiCorruption; + + namespace Demo; + + public sealed record LegacyOrder(string Id, decimal Amount); + public sealed record Order(string OrderId, decimal Total); + + [GenerateAntiCorruptionLayer(typeof(LegacyOrder), typeof(Order), FactoryMethodName = "Build", LayerName = "orders", SourceSystem = "legacy-erp")] + public static partial class OrderAcl + { + [AntiCorruptionTranslator] + private static Order Translate(LegacyOrder order) => new(order.Id, order.Amount); + + [AntiCorruptionExternalRule("Legacy order id is required.")] + private static bool HasId(LegacyOrder order) => order.Id.Length > 0; + + [AntiCorruptionDomainRule("Domain total must be positive.")] + private static bool HasPositiveTotal(Order order) => order.Total > 0m; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesAntiCorruptionLayerFactory)); + var gen = new AntiCorruptionLayerGenerator(); + _ = 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(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Equal("OrderAcl.AntiCorruptionLayer.g.cs", generated.HintName); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("AntiCorruptionLayer.Create(\"orders\")", text); + ScenarioExpect.Contains(".FromSource(\"legacy-erp\")", text); + ScenarioExpect.Contains(".TranslateWith(static external => Translate(external));", text); + ScenarioExpect.Contains("builder.RequireExternal(static external => HasId(external), \"Legacy order id is required.\");", text); + ScenarioExpect.Contains("builder.RequireDomain(static domain => HasPositiveTotal(domain), \"Domain total must be positive.\");", text); + + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("Reports diagnostic for non-partial anti-corruption host")] + [Fact] + public void ReportsDiagnosticForNonPartialAntiCorruptionHost() + { + var source = """ + using PatternKit.Generators.AntiCorruption; + + namespace Demo; + + [GenerateAntiCorruptionLayer(typeof(string), typeof(int))] + public static class Host; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialAntiCorruptionHost)); + + ScenarioExpect.Equal("PKACL001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing anti-corruption translator")] + [Fact] + public void ReportsDiagnosticForMissingAntiCorruptionTranslator() + { + var source = """ + using PatternKit.Generators.AntiCorruption; + + namespace Demo; + + [GenerateAntiCorruptionLayer(typeof(string), typeof(int))] + public static partial class Host; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForMissingAntiCorruptionTranslator)); + + ScenarioExpect.Equal("PKACL002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid anti-corruption translator")] + [Fact] + public void ReportsDiagnosticForInvalidAntiCorruptionTranslator() + { + var source = """ + using PatternKit.Generators.AntiCorruption; + + namespace Demo; + + [GenerateAntiCorruptionLayer(typeof(string), typeof(int))] + public static partial class Host + { + [AntiCorruptionTranslator] + private static string Translate(string value) => value; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidAntiCorruptionTranslator)); + + ScenarioExpect.Equal("PKACL003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid anti-corruption rule")] + [Fact] + public void ReportsDiagnosticForInvalidAntiCorruptionRule() + { + var source = """ + using PatternKit.Generators.AntiCorruption; + + namespace Demo; + + [GenerateAntiCorruptionLayer(typeof(string), typeof(int))] + public static partial class Host + { + [AntiCorruptionTranslator] + private static int Translate(string value) => value.Length; + + [AntiCorruptionExternalRule("required")] + private static string HasValue(string value) => value; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidAntiCorruptionRule)); + + ScenarioExpect.Equal("PKACL004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(PatternKit.Application.AntiCorruption.AntiCorruptionLayer<,>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(AntiCorruptionLayerGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new AntiCorruptionLayerGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Application/AntiCorruption/AntiCorruptionLayerTests.cs b/test/PatternKit.Tests/Application/AntiCorruption/AntiCorruptionLayerTests.cs new file mode 100644 index 00000000..29820189 --- /dev/null +++ b/test/PatternKit.Tests/Application/AntiCorruption/AntiCorruptionLayerTests.cs @@ -0,0 +1,95 @@ +using PatternKit.Application.AntiCorruption; +using TinyBDD; + +namespace PatternKit.Tests.Application.AntiCorruption; + +public sealed class AntiCorruptionLayerTests +{ + private sealed record LegacyOrder(string Id, decimal Amount, string Currency); + private sealed record Order(string OrderId, decimal TotalUsd); + + [Scenario("Anti-corruption layer translates accepted external models")] + [Fact] + public void AntiCorruptionLayer_Translates_Accepted_External_Models() + { + var layer = CreateLayer(); + + var result = layer.Translate(new LegacyOrder("ORD-100", 25m, "USD")); + + ScenarioExpect.True(result.Accepted); + ScenarioExpect.Equal("legacy-erp", result.SourceSystem); + ScenarioExpect.Equal(new Order("ORD-100", 25m), result.Value); + } + + [Scenario("Anti-corruption layer rejects invalid external models before translation")] + [Fact] + public void AntiCorruptionLayer_Rejects_Invalid_External_Models_Before_Translation() + { + var calls = 0; + var layer = AntiCorruptionLayer + .Create("orders") + .FromSource("legacy-erp") + .RequireExternal(static order => order.Currency == "USD", "Only USD orders are accepted.") + .TranslateWith(order => + { + calls++; + return new Order(order.Id, order.Amount); + }) + .Build(); + + var result = layer.Translate(new LegacyOrder("ORD-100", 25m, "EUR")); + + ScenarioExpect.True(result.Rejected); + ScenarioExpect.Equal("Only USD orders are accepted.", result.RejectionReason); + ScenarioExpect.Equal(0, calls); + } + + [Scenario("Anti-corruption layer rejects invalid domain models after translation")] + [Fact] + public void AntiCorruptionLayer_Rejects_Invalid_Domain_Models_After_Translation() + { + var layer = CreateLayer(); + + var result = layer.Translate(new LegacyOrder("ORD-100", -5m, "USD")); + + ScenarioExpect.True(result.Rejected); + ScenarioExpect.Equal("Domain orders must have positive totals.", result.RejectionReason); + } + + [Scenario("Async anti-corruption layer preserves cancellation")] + [Fact] + public async Task AsyncAntiCorruptionLayer_Preserves_Cancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var layer = CreateLayer(); + + await ScenarioExpect.ThrowsAsync(() => + layer.TranslateAsync(new LegacyOrder("ORD-100", 25m, "USD"), cts.Token).AsTask()); + } + + [Scenario("Anti-corruption layer rejects invalid configuration")] + [Fact] + public void AntiCorruptionLayer_Rejects_Invalid_Configuration() + { + var layer = CreateLayer(); + + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create("").TranslateWith(static order => new Order(order.Id, order.Amount)).Build()); + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create().FromSource("").TranslateWith(static order => new Order(order.Id, order.Amount)).Build()); + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create().TranslateWith(null!)); + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create().RequireExternal(null!, "reason")); + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create().RequireExternal(static _ => true, "")); + ScenarioExpect.Throws(() => AntiCorruptionLayer.Create().Build()); + ScenarioExpect.Throws(() => layer.Translate(null!)); + } + + private static AntiCorruptionLayer CreateLayer() + => AntiCorruptionLayer + .Create("orders") + .FromSource("legacy-erp") + .RequireExternal(static order => !string.IsNullOrWhiteSpace(order.Id), "Legacy order id is required.") + .RequireExternal(static order => order.Currency == "USD", "Only USD orders are accepted.") + .TranslateWith(static order => new Order(order.Id, order.Amount)) + .RequireDomain(static order => order.TotalUsd > 0m, "Domain orders must have positive totals.") + .Build(); +}