From 7189254ad380f888863b4b6d16ce8c999b953ece Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 18:29:14 -0500 Subject: [PATCH 1/7] feat: add example DI integrations --- .../examples/production-ready-integrations.md | 30 ++ ...rnKitExampleServiceCollectionExtensions.cs | 473 ++++++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 40 ++ 3 files changed, 543 insertions(+) create mode 100644 src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs create mode 100644 test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs diff --git a/docs/examples/production-ready-integrations.md b/docs/examples/production-ready-integrations.md index 9a1a929d..acfc6342 100644 --- a/docs/examples/production-ready-integrations.md +++ b/docs/examples/production-ready-integrations.md @@ -18,6 +18,35 @@ using var provider = services.BuildServiceProvider(validateScopes: true); var catalog = provider.GetRequiredService(); ``` +## Register runnable examples + +The examples package also exposes a fluent IoC surface for every catalog entry. Existing applications can import the complete example suite with one call: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; + +var services = new ServiceCollection() + .AddLogging() + .AddPatternKitExamples(); + +using var provider = services.BuildServiceProvider(validateScopes: true); + +var pricing = provider.GetRequiredService(); +var catalog = provider.GetRequiredService(); +``` + +Each example also has its own focused extension, so sample applications can import only the slice they need: + +```csharp +services + .AddPricingCalculatorExample() + .AddPaymentProcessorDecoratorExample() + .AddMessagingBackplaneFacadeExample(); +``` + +Every extension registers a concrete typed entry point, such as `PricingCalculatorExample`, `EnterpriseFeatureSlicesExample`, `MinimalWebRequestRouterExample`, or `ReactiveTransactionExample`. The companion `PatternKitExampleServiceDescriptor` registrations make the IoC surface auditable: tests compare those descriptors against the production-readiness catalog and resolve every registered example through `IServiceProvider`. + Each `PatternKitExampleDescriptor` includes: | Field | Purpose | @@ -84,6 +113,7 @@ Every documented example should satisfy the same baseline contract: | Documented in DocFX | `docs/examples/toc.yml` links to the page and documentation tests validate hrefs. | | Covered by TinyBDD scenarios | The catalog points to a test file and catalog tests verify it exists. | | Importable from the examples package | The catalog is part of `PatternKit.Examples` and is registered through `IServiceCollection`/`IHostApplicationBuilder`. | +| Has an IoC entry point | `AddPatternKitExamples()` and per-example `Add...Example()` extensions register typed example services through `IServiceCollection`. | | Host/tooling friendly where applicable | Integration flags identify examples that demonstrate `IOptions`, DI, generic host, ASP.NET Core, source generators, messaging, or external infrastructure. | | Production-shaped behavior called out | `ProductionChecks` records the behaviors each example must keep validating. | diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs new file mode 100644 index 00000000..620484eb --- /dev/null +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -0,0 +1,473 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PatternKit.Behavioral.Chain; +using PatternKit.Behavioral.Strategy; +using PatternKit.Behavioral.TypeDispatcher; +using PatternKit.Creational.Prototype; +using PatternKit.Creational.Singleton; +using PatternKit.Examples.ApiGateway; +using PatternKit.Examples.AsyncStateDemo; +using PatternKit.Examples.Chain; +using PatternKit.Examples.Chain.ConfigDriven; +using PatternKit.Examples.EnterpriseFeatureSlices; +using PatternKit.Examples.FlyweightDemo; +using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; +using PatternKit.Examples.Generators.Visitors; +using PatternKit.Examples.MementoDemo; +using PatternKit.Examples.Messaging; +using PatternKit.Examples.ObserverDemo; +using PatternKit.Examples.PatternShowcase; +using PatternKit.Examples.PointOfSale; +using PatternKit.Examples.Pricing; +using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.PrototypeDemo; +using PatternKit.Examples.ProxyDemo; +using PatternKit.Examples.Singleton; +using PatternKit.Examples.Strategies.Coercion; +using PatternKit.Examples.Strategies.Composed; +using PatternKit.Examples.TemplateDemo; +using PatternKit.Examples.VisitorDemo; +using PatternKit.Messaging.Routing; +using PatternKit.Structural.Decorator; +using PatternKit.Structural.Proxy; +using CheckoutRequest = PatternKit.Examples.Messaging.CheckoutRequest; +using ConfigTenderHandler = PatternKit.Examples.Chain.ConfigDriven.ITenderHandler; +using ConfigPaymentPipeline = PatternKit.Examples.Chain.ConfigDriven.ConfigDrivenPipelineDemo.PaymentPipeline; +using DocumentValidationResult = PatternKit.Examples.Generators.Visitors.DocumentProcessingDemo.ValidationResult; +using EnterpriseCheckout = PatternKit.Examples.EnterpriseFeatureSlices.EnterpriseFeatureSlicesDemo.IEnterpriseCheckout; +using EnterpriseCheckoutRequest = PatternKit.Examples.EnterpriseFeatureSlices.EnterpriseFeatureSlicesDemo.CheckoutRequest; +using EnterpriseCheckoutResult = PatternKit.Examples.EnterpriseFeatureSlices.EnterpriseFeatureSlicesDemo.CheckoutResult; +using PosPaymentKind = PatternKit.Examples.ObserverDemo.PaymentKind; +using ShowcaseFacade = PatternKit.Examples.PatternShowcase.PatternShowcase.IOrderProcessingFacade; +using TransactionPipeline = PatternKit.Examples.Chain.TransactionPipeline; +using VisitorTender = PatternKit.Examples.VisitorDemo.Tender; + +namespace PatternKit.Examples.DependencyInjection; + +/// +/// Describes the concrete service registered for an example-level PatternKit integration. +/// +public sealed record PatternKitExampleServiceDescriptor( + string ExampleName, + Type ServiceType, + ExampleIntegrationSurface Integration); + +/// +/// Open-generic coercion adapter so consumers can inject coercers through standard IoC. +/// +public interface ICoercer +{ + T? From(object? value); +} + +/// +/// Default open-generic coercion adapter backed by . +/// +public sealed class CoercerService : ICoercer +{ + public T? From(object? value) => Coercer.From(value); +} + +public sealed record ProductionReadyExampleIntegrations(IPatternKitExampleCatalog Catalog); +public sealed record AuthLoggingChainExample(ActionChain Chain, List Log); +public sealed record CoercionExample(ICoercer Integers, ICoercer Booleans, ICoercer Strings); +public sealed record ComposedNotificationStrategyExample(AsyncStrategy Strategy); +public sealed record MediatedTransactionPipelineExample(TransactionPipeline Pipeline); +public sealed record ConfigDrivenTransactionPipelineExample(ConfigPaymentPipeline Pipeline); +public sealed record EnterpriseFeatureSlicesExample(EnterpriseCheckout Checkout, Func CreateRequest); +public sealed record MinimalWebRequestRouterExample(MiniRouter Router); +public sealed record PaymentProcessorDecoratorExample(Decorator Processor); +public sealed record PosAppStateSingletonExample(Singleton State); +public sealed record PricingCalculatorExample(PricingDemo.DemoArtifacts Artifacts); +public sealed record PosTenderVisitorExample(TypeDispatcher Renderer, ActionTypeDispatcher Router); +public sealed record ApiExceptionMappingVisitorExample(Func RunAsync); +public sealed record EventProcessingVisitorExample(Func RunAsync); +public sealed record MessageRouterVisitorExample(Func Run); +public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); +public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); +public sealed record EnterpriseMessagingWorkflowSuiteExample(Func Run); +public sealed record ResilientCheckoutMailboxesExample(Func Run); +public sealed record MessagingBackplaneFacadeExample(Func> RunAsync); +public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); +public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); +public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); +public sealed record TextEditorMementoExample(MementoDemo.MementoDemo.TextEditor Editor); +public sealed record ObserverEventHubExample(EventHub Hub); +public sealed record ReactiveViewModelExample(ProfileViewModel ViewModel); +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); + +/// +/// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. +/// +public static class PatternKitExampleServiceCollectionExtensions +{ + public static IServiceCollection AddPatternKitExamples(this IServiceCollection services, IConfiguration? configuration = null) + => services + .AddProductionReadyExampleIntegrations() + .AddAuthLoggingChainExample() + .AddStrategyBasedDataCoercionExample() + .AddComposedNotificationStrategyExample() + .AddMediatedTransactionPipelineExample() + .AddConfigurationDrivenTransactionPipelineExample(configuration) + .AddEnterpriseFeatureSlicesExample() + .AddMinimalWebRequestRouterExample() + .AddPaymentProcessorDecoratorExample() + .AddPosAppStateSingletonExample() + .AddPricingCalculatorExample() + .AddPosTenderVisitorExample() + .AddApiExceptionMappingVisitorExample() + .AddEventProcessingVisitorExample() + .AddMessageRouterVisitorExample() + .AddPatternsShowcaseExample() + .AddSourceGeneratorApplicationSuiteExample() + .AddEnterpriseMessagingWorkflowSuiteExample() + .AddResilientCheckoutMailboxesExample() + .AddMessagingBackplaneFacadeExample() + .AddPrototypeGameCharacterFactoryExample() + .AddProxyPatternDemonstrationsExample() + .AddFlyweightGlyphCacheExample() + .AddTextEditorMementoExample() + .AddObserverEventHubExample() + .AddReactiveViewModelExample() + .AddReactiveTransactionExample() + .AddAsyncConnectionStateMachineExample() + .AddTemplateMethodSubclassingExample() + .AddTemplateMethodAsyncExample(); + + public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) + { + services.AddPatternKitExampleCatalog(); + services.AddSingleton(sp => + new(sp.GetRequiredService())); + return services.RegisterExample("Production-Ready Example Integrations", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); + } + + public static IServiceCollection AddAuthLoggingChainExample(this IServiceCollection services) + { + services.AddSingleton(_ => + { + var log = new List(); + var chain = ActionChain.Create() + .When(static (in r) => r.Headers.ContainsKey("X-Request-Id")) + .ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}")) + .When(static (in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) && !r.Headers.ContainsKey("Authorization")) + .ThenStop(_ => log.Add("deny: missing auth")) + .Finally((in r, next) => + { + log.Add($"{r.Method} {r.Path}"); + next(r); + }) + .Build(); + return new AuthLoggingChainExample(chain, log); + }); + return services.RegisterExample("Auth & Logging Chain", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddStrategyBasedDataCoercionExample(this IServiceCollection services) + { + services.AddSingleton(typeof(ICoercer<>), typeof(CoercerService<>)); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>())); + return services.RegisterExample("Strategy-Based Data Coercion", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddComposedNotificationStrategyExample(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(sp => new( + ComposedStrategies.BuildPreferenceAware( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()))); + return services.RegisterExample("Composed Notification Strategy", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddMediatedTransactionPipelineExample(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(_ => new(new() + { + [CardVendor.Visa] = new GenericProcessor("VisaNet"), + [CardVendor.Mastercard] = new GenericProcessor("MC"), + [CardVendor.Amex] = new GenericProcessor("Amex"), + [CardVendor.Chase] = new GenericProcessor("ChaseNet"), + [CardVendor.InHouse] = new GenericProcessor("InHouse"), + [CardVendor.Unknown] = new GenericProcessor("FallbackNet") + })); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.AddSingleton(sp => + { + var pipeline = TransactionPipelineBuilder.New() + .WithDeviceBus(sp.GetRequiredService()) + .WithTenderHandlers(sp.GetServices().ToArray()) + .AddPreauth() + .AddDiscountsAndTax() + .AddRounding() + .AddTenderHandling() + .AddFinalize() + .Build(); + + return new MediatedTransactionPipelineExample(pipeline); + }); + return services.RegisterExample("Mediated Transaction Pipeline", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddConfigurationDrivenTransactionPipelineExample(this IServiceCollection services, IConfiguration? configuration = null) + { + services.AddPaymentPipeline(configuration ?? new ConfigurationBuilder().Build()); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Configuration-Driven Transaction Pipeline", ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.Options); + } + + public static IServiceCollection AddEnterpriseFeatureSlicesExample(this IServiceCollection services) + { + services.AddEnterpriseFeatureSlices(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + EnterpriseFeatureSlicesDemo.CreateRetailRequest)); + return services.RegisterExample("Enterprise Feature Slices with .NET DI", ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddMinimalWebRequestRouterExample(this IServiceCollection services) + { + services.AddSingleton(_ => MiniRouter.Create() + .Map(static (in r) => string.Equals(r.Path, "/health", StringComparison.OrdinalIgnoreCase), static (in _) => Responses.Text(200, "ok")) + .Map(static (in r) => string.Equals(r.Path, "/orders", StringComparison.OrdinalIgnoreCase), static (in _) => Responses.Json(200, """{"items":[]}""")) + .NotFound(static (in _) => Responses.NotFound()) + .Build()); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Minimal Web Request Router", ExampleIntegrationSurface.AspNetCore | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPaymentProcessorDecoratorExample(this IServiceCollection services) + { + services.AddSingleton(_ => PaymentProcessorDemo.CreateEcommerceProcessor([])); + services.AddSingleton(sp => new(sp.GetRequiredService>())); + return services.RegisterExample("Payment Processor Decorator", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPosAppStateSingletonExample(this IServiceCollection services) + { + services.AddSingleton(_ => PosAppStateDemo.BuildLazy()); + services.AddSingleton(sp => new(sp.GetRequiredService>())); + return services.RegisterExample("POS App State Singleton", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPricingCalculatorExample(this IServiceCollection services) + { + services.AddSingleton(_ => PricingDemo.BuildDefault()); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Pricing Calculator", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPosTenderVisitorExample(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(_ => ReceiptRendering.CreateRenderer()); + services.AddSingleton(sp => Routing.CreateRouter(sp.GetRequiredService())); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService>())); + return services.RegisterExample("POS Tender Visitor", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddApiExceptionMappingVisitorExample(this IServiceCollection services) + { + services.AddSingleton(new ApiExceptionMappingVisitorExample(DocumentProcessingDemo.RunAsync)); + return services.RegisterExample("API Exception Mapping Visitor", ExampleIntegrationSurface.AspNetCore | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddEventProcessingVisitorExample(this IServiceCollection services) + { + services.AddSingleton(new EventProcessingVisitorExample(DocumentProcessingDemo.RunAsync)); + return services.RegisterExample("Event Processing Visitor", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddMessageRouterVisitorExample(this IServiceCollection services) + { + services.AddSingleton(new MessageRouterVisitorExample(MessageRoutingExample.Run)); + return services.RegisterExample("Message Router Visitor", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPatternsShowcaseExample(this IServiceCollection services) + { + services.AddSingleton(_ => PatternShowcase.Build()); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Patterns Showcase", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddSourceGeneratorApplicationSuiteExample(this IServiceCollection services) + { + services.AddSingleton(new SourceGeneratorApplicationSuiteExample(CorporateApplicationDemo.BuildProductionAsync)); + return services.RegisterExample("Source Generator Application Suite", ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + + public static IServiceCollection AddEnterpriseMessagingWorkflowSuiteExample(this IServiceCollection services) + { + services.AddSingleton(new EnterpriseMessagingWorkflowSuiteExample(MessageEnvelopeExample.Run)); + return services.RegisterExample("Enterprise Messaging Workflow Suite", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(new ResilientCheckoutMailboxesExample(ResilientCheckoutDemo.Run)); + return services.RegisterExample("Resilient Checkout and Collaborating Mailboxes", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddMessagingBackplaneFacadeExample(this IServiceCollection services) + { + services.AddSingleton(new MessagingBackplaneFacadeExample(BackplaneFacadeDemo.RunAsync)); + return services.RegisterExample("Messaging Backplane Facade", ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.ExternalInfrastructure | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) + { + services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); + services.AddSingleton(sp => new(sp.GetRequiredService>())); + return services.RegisterExample("Prototype Game Character Factory", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddProxyPatternDemonstrationsExample(this IServiceCollection services) + { + services.AddSingleton(_ => Proxy.Create(id => $"Remote data for ID {id}").CachingProxy().Build()); + services.AddSingleton(_ => + { + var mock = ProxyDemo.MockFramework.CreateMock<(string To, string Subject, string Body), bool>() + .Setup(input => input.To.Contains("@example.com", StringComparison.OrdinalIgnoreCase), true) + .Returns(true); + return mock.Build(); + }); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService>())); + return services.RegisterExample("Proxy Pattern Demonstrations", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddFlyweightGlyphCacheExample(this IServiceCollection services) + { + services.AddSingleton(new FlyweightGlyphCacheExample(FlyweightDemo.FlyweightDemo.RenderSentence)); + return services.RegisterExample("Flyweight Glyph Cache", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddTextEditorMementoExample(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Text Editor Memento", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddObserverEventHubExample(this IServiceCollection services) + { + services.AddSingleton(_ => EventHub.CreateDefault()); + services.AddSingleton(sp => new(sp.GetRequiredService>())); + return services.RegisterExample("Observer Event Hub", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddReactiveViewModelExample(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Reactive ViewModel", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddReactiveTransactionExample(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Reactive Transaction", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddAsyncConnectionStateMachineExample(this IServiceCollection services) + { + services.AddSingleton(new AsyncConnectionStateMachineExample(events => ConnectionStateDemo.RunAsync(events))); + return services.RegisterExample("Async Connection State Machine", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddTemplateMethodSubclassingExample(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Template Method Subclassing", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + public static IServiceCollection AddTemplateMethodAsyncExample(this IServiceCollection services) + { + services.AddTransient(); + services.AddTransient(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Template Method Async", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); + } + + private static IServiceCollection RegisterExample( + this IServiceCollection services, + string name, + ExampleIntegrationSurface integration) + where T : class + { + services.AddSingleton(new PatternKitExampleServiceDescriptor(name, typeof(T), integration)); + return services; + } + + private sealed class DemoIdentityService : IIdentityService + { + public ValueTask HasVerifiedEmailAsync(Guid userId, CancellationToken ct) => new(true); + public ValueTask HasSmsOptInAsync(Guid userId, CancellationToken ct) => new(true); + public ValueTask HasPushTokenAsync(Guid userId, CancellationToken ct) => new(true); + } + + private sealed class DemoPresenceService : IPresenceService + { + public ValueTask IsOnlineInImAsync(Guid userId, CancellationToken ct) => new(true); + public ValueTask IsDoNotDisturbAsync(Guid userId, CancellationToken ct) => new(false); + } + + private sealed class DemoRateLimiter : IRateLimiter + { + public ValueTask CanSendAsync(Channel channel, Guid userId, CancellationToken ct) => new(true); + } + + private sealed class DemoPreferenceService : IPreferenceService + { + public ValueTask GetPreferredOrderAsync(Guid userId, CancellationToken ct) + => new([Channel.Push, Channel.Email, Channel.Sms]); + } + + private sealed class DemoEmailSender : IEmailSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Email, true, "email:accepted")); + } + + private sealed class DemoSmsSender : ISmsSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Sms, true, "sms:accepted")); + } + + private sealed class DemoPushSender : IPushSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Push, true, "push:accepted")); + } + + private sealed class DemoImSender : IImSender + { + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Im, true, "im:accepted")); + } +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs new file mode 100644 index 00000000..6d9c5d5e --- /dev/null +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.DependencyInjection; + +[Feature("Example-level dependency injection integrations")] +public sealed class PatternKitExampleDependencyInjectionTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Every catalog example has a fluent IServiceCollection integration")] + [Fact] + public Task Every_Catalog_Example_Has_A_Fluent_IServiceCollection_Integration() + => Given("a service collection configured with all PatternKit examples", () => + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the catalog and registered example service descriptors", provider => new + { + Provider = provider, + Catalog = provider.GetRequiredService(), + Descriptors = provider.GetServices().ToArray() + }) + .Then("each catalog entry has a matching IoC descriptor", result => + result.Catalog.Entries.All(entry => + result.Descriptors.Any(descriptor => + string.Equals(descriptor.ExampleName, entry.Name, StringComparison.Ordinal)))) + .And("each descriptor resolves its concrete integration service", result => + result.Descriptors.All(descriptor => + result.Provider.GetRequiredService(descriptor.ServiceType) is not null)) + .And("each integration advertises dependency injection as an available surface", result => + result.Descriptors.All(descriptor => + descriptor.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection))) + .AssertPassed(); +} From 455a76f81e6325a0f865be4b3f2bee7a041e2925 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 18:33:16 -0500 Subject: [PATCH 2/7] fix: correct example DI build errors --- ...PatternKitExampleServiceCollectionExtensions.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 620484eb..7690629f 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -243,7 +243,7 @@ public static IServiceCollection AddEnterpriseFeatureSlicesExample(this IService services.AddEnterpriseFeatureSlices(); services.AddSingleton(sp => new( sp.GetRequiredService(), - EnterpriseFeatureSlicesDemo.CreateRetailRequest)); + () => EnterpriseFeatureSlicesDemo.CreateRetailRequest())); return services.RegisterExample("Enterprise Feature Slices with .NET DI", ExampleIntegrationSurface.DependencyInjection); } @@ -310,7 +310,7 @@ public static IServiceCollection AddMessageRouterVisitorExample(this IServiceCol public static IServiceCollection AddPatternsShowcaseExample(this IServiceCollection services) { - services.AddSingleton(_ => PatternShowcase.Build()); + services.AddSingleton(_ => PatternShowcase.PatternShowcase.Build()); services.AddSingleton(sp => new(sp.GetRequiredService())); return services.RegisterExample("Patterns Showcase", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.DependencyInjection); } @@ -352,7 +352,7 @@ public static IServiceCollection AddProxyPatternDemonstrationsExample(this IServ services.AddSingleton(_ => Proxy.Create(id => $"Remote data for ID {id}").CachingProxy().Build()); services.AddSingleton(_ => { - var mock = ProxyDemo.MockFramework.CreateMock<(string To, string Subject, string Body), bool>() + var mock = ProxyDemo.ProxyDemo.MockFramework.CreateMock<(string To, string Subject, string Body), bool>() .Setup(input => input.To.Contains("@example.com", StringComparison.OrdinalIgnoreCase), true) .Returns(true); return mock.Build(); @@ -453,21 +453,21 @@ public ValueTask GetPreferredOrderAsync(Guid userId, CancellationToke private sealed class DemoEmailSender : IEmailSender { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Email, true, "email:accepted")); + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new ValueTask(new SendResult(Channel.Email, true, "email:accepted")); } private sealed class DemoSmsSender : ISmsSender { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Sms, true, "sms:accepted")); + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new ValueTask(new SendResult(Channel.Sms, true, "sms:accepted")); } private sealed class DemoPushSender : IPushSender { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Push, true, "push:accepted")); + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new ValueTask(new SendResult(Channel.Push, true, "push:accepted")); } private sealed class DemoImSender : IImSender { - public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new(new(Channel.Im, true, "im:accepted")); + public ValueTask SendAsync(SendContext ctx, CancellationToken ct) => new ValueTask(new SendResult(Channel.Im, true, "im:accepted")); } } From 525e8180ffda7bb611de39a419e9c5773dd92ed5 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 18:42:51 -0500 Subject: [PATCH 3/7] test: exercise registered example integrations --- ...tternKitExampleDependencyInjectionTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 6d9c5d5e..e2d08818 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -1,6 +1,11 @@ using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.ApiGateway; using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ObserverDemo; +using PatternKit.Examples.PointOfSale; using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.Strategies.Composed; +using Showcase = PatternKit.Examples.PatternShowcase.PatternShowcase; using TinyBDD; using TinyBDD.Xunit; using Xunit.Abstractions; @@ -37,4 +42,122 @@ public Task Every_Catalog_Example_Has_A_Fluent_IServiceCollection_Integration() result.Descriptors.All(descriptor => descriptor.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection))) .AssertPassed(); + + [Scenario("IoC-registered examples can be used by importing applications")] + [Fact] + public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() + => Given("a provider configured with all PatternKit examples", () => + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("using representative registered examples", UseRegisteredExamples) + .Then("the registered examples produce expected outputs", passed => passed) + .AssertPassed(); + + private static bool UseRegisteredExamples(ServiceProvider provider) + { + var coercion = provider.GetRequiredService(); + var notifications = provider.GetRequiredService(); + var auth = provider.GetRequiredService(); + var router = provider.GetRequiredService(); + var payment = provider.GetRequiredService(); + var singleton = provider.GetRequiredService(); + var showcase = provider.GetRequiredService(); + var proxy = provider.GetRequiredService(); + var flyweight = provider.GetRequiredService(); + var editor = provider.GetRequiredService(); + var eventHub = provider.GetRequiredService(); + var viewModel = provider.GetRequiredService(); + var transaction = provider.GetRequiredService(); + var asyncState = provider.GetRequiredService(); + var template = provider.GetRequiredService(); + var asyncTemplate = provider.GetRequiredService(); + var routing = provider.GetRequiredService(); + var envelope = provider.GetRequiredService(); + var checkout = provider.GetRequiredService(); + + auth.Chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary())); + + var json = router.Router.Handle(new Request("GET", "/orders", new Dictionary())); + var receipt = payment.Processor.Execute(CreateOrder()); + var order = showcase.Facade.Place(new Showcase.OrderDto( + "ORD-DI", + "VIP-100", + "cash", + [new Showcase.OrderItemDto("SKU-1", "Widget", 20m, 1)])); + + var received = false; + using var subscription = eventHub.Hub.On((in UserEvent _) => received = true); + eventHub.Hub.Publish(new UserEvent(1, "login")); + + viewModel.ViewModel.FirstName.Value = "Ada"; + viewModel.ViewModel.LastName.Value = "Lovelace"; + transaction.Transaction.AddItem(new LineItem("SKU-1", 1, 10m)); + transaction.Transaction.SetPayment(PaymentKind.CreditCard); + + var send = notifications.Strategy.ExecuteAsync(new SendContext(Guid.NewGuid(), "hello", false), CancellationToken.None) + .GetAwaiter() + .GetResult(); + var state = asyncState.RunAsync(["connect", "ok"]).GetAwaiter().GetResult(); + var asyncResult = asyncTemplate.Pipeline.ExecuteAsync(7, CancellationToken.None).GetAwaiter().GetResult(); + editor.Editor.Insert("hello"); + + return coercion.Integers.From("42") == 42 + && coercion.Booleans.From("true") == true + && !string.IsNullOrWhiteSpace(coercion.Strings.From(12)) + && send.Success + && auth.Log.Contains("deny: missing auth", StringComparer.Ordinal) + && json.StatusCode == 200 + && receipt.FinalTotal > 0 + && singleton.State.Instance.Devices.PrinterReady + && order.ok + && proxy.RemoteProxy.Execute(42).Contains("42", StringComparison.Ordinal) + && proxy.EmailProxy.Execute(("user@example.com", "Hello", "Body")) + && flyweight.RenderSentence("hello").Count == 5 + && editor.Editor.State.Text == "hello" + && received + && viewModel.ViewModel.CanSave.Value + && transaction.Transaction.CanCheckout.Value + && state.Final == PatternKit.Examples.AsyncStateDemo.ConnectionStateDemo.Mode.Connected + && template.Processor.Execute("one two") == 2 + && asyncResult == "PAYLOAD:7" + && routing.Run().AggregatedTotal == 100m + && envelope.Run().Attempt == 1 + && checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded; + } + + private static PurchaseOrder CreateOrder() + => new() + { + OrderId = "DI-ORDER", + Customer = new CustomerInfo { CustomerId = "CUST-1", LoyaltyTier = "Gold" }, + Store = new StoreLocation + { + StoreId = "STORE-1", + State = "CA", + Country = "USA", + StateTaxRate = 0.0725m, + LocalTaxRate = 0.0125m + }, + Items = + [ + new OrderLineItem + { + Sku = "SKU-1", + ProductName = "Widget", + UnitPrice = 25m, + Quantity = 1, + Category = "General" + } + ] + }; + + private static PatternKit.Examples.Messaging.CheckoutRequest CreateCheckoutRequest() + => new( + "checkout-di", + "customer-1", + [new PatternKit.Examples.Messaging.CheckoutLine("SKU-1", 1, 25m)]); } From 4cf46e01398c8c9286126a0fff96662a01a5b983 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 18:50:12 -0500 Subject: [PATCH 4/7] fix: import chain request in DI tests --- .../PatternKitExampleDependencyInjectionTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index e2d08818..38f5c061 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using PatternKit.Examples.ApiGateway; +using PatternKit.Examples.Chain; using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PointOfSale; From ba09192e89311e8fddbd13b9cfd8e2117be70bff Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 18:57:02 -0500 Subject: [PATCH 5/7] fix: qualify chain request in DI tests --- .../PatternKitExampleDependencyInjectionTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 38f5c061..4a023008 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using PatternKit.Examples.ApiGateway; -using PatternKit.Examples.Chain; using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PointOfSale; @@ -80,7 +79,7 @@ private static bool UseRegisteredExamples(ServiceProvider provider) var envelope = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); - auth.Chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary())); + auth.Chain.Execute(new PatternKit.Examples.Chain.HttpRequest("GET", "/admin/metrics", new Dictionary())); var json = router.Router.Handle(new Request("GET", "/orders", new Dictionary())); var receipt = payment.Processor.Execute(CreateOrder()); From 7cea2088d1d7750e0711bbc3330812496a068995 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 19:06:30 -0500 Subject: [PATCH 6/7] test: name DI example usage assertions --- ...tternKitExampleDependencyInjectionTests.cs | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 4a023008..ab58bcc6 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -54,10 +54,14 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() return services.BuildServiceProvider(validateScopes: true); }) .When("using representative registered examples", UseRegisteredExamples) - .Then("the registered examples produce expected outputs", passed => passed) + .Then("the registered examples produce expected outputs", checks => + { + foreach (var check in checks) + ScenarioExpect.True(check.Passed, check.Name); + }) .AssertPassed(); - private static bool UseRegisteredExamples(ServiceProvider provider) + private static IReadOnlyList<(string Name, bool Passed)> UseRegisteredExamples(ServiceProvider provider) { var coercion = provider.GetRequiredService(); var notifications = provider.GetRequiredService(); @@ -105,28 +109,31 @@ private static bool UseRegisteredExamples(ServiceProvider provider) var asyncResult = asyncTemplate.Pipeline.ExecuteAsync(7, CancellationToken.None).GetAwaiter().GetResult(); editor.Editor.Insert("hello"); - return coercion.Integers.From("42") == 42 - && coercion.Booleans.From("true") == true - && !string.IsNullOrWhiteSpace(coercion.Strings.From(12)) - && send.Success - && auth.Log.Contains("deny: missing auth", StringComparer.Ordinal) - && json.StatusCode == 200 - && receipt.FinalTotal > 0 - && singleton.State.Instance.Devices.PrinterReady - && order.ok - && proxy.RemoteProxy.Execute(42).Contains("42", StringComparison.Ordinal) - && proxy.EmailProxy.Execute(("user@example.com", "Hello", "Body")) - && flyweight.RenderSentence("hello").Count == 5 - && editor.Editor.State.Text == "hello" - && received - && viewModel.ViewModel.CanSave.Value - && transaction.Transaction.CanCheckout.Value - && state.Final == PatternKit.Examples.AsyncStateDemo.ConnectionStateDemo.Mode.Connected - && template.Processor.Execute("one two") == 2 - && asyncResult == "PAYLOAD:7" - && routing.Run().AggregatedTotal == 100m - && envelope.Run().Attempt == 1 - && checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded; + return + [ + ("integer coercer converts text", coercion.Integers.From("42") == 42), + ("boolean coercer converts text", coercion.Booleans.From("true") == true), + ("string coercer converts values", !string.IsNullOrWhiteSpace(coercion.Strings.From(12))), + ("notification strategy sends", send.Success), + ("auth chain logs denied admin requests", auth.Log.Contains("deny: missing auth", StringComparer.Ordinal)), + ("minimal router returns a successful response", json.StatusCode == 200), + ("decorated payment processor totals the order", receipt.FinalTotal > 0), + ("singleton POS app state is initialized", singleton.State.Instance.Devices.PrinterReady), + ("pattern showcase facade places an order", order.ok), + ("remote proxy returns remote data", proxy.RemoteProxy.Execute(42).Contains("42", StringComparison.Ordinal)), + ("email proxy accepts example addresses", proxy.EmailProxy.Execute(("user@example.com", "Hello", "Body"))), + ("flyweight renderer returns one glyph per character", flyweight.RenderSentence("hello").Count == 5), + ("memento editor tracks inserted text", editor.Editor.State.Text == "hello"), + ("observer event hub publishes events", received), + ("reactive view model enables save", viewModel.ViewModel.CanSave.Value), + ("reactive transaction enables checkout", transaction.Transaction.CanCheckout.Value), + ("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"), + ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), + ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), + ("resilient checkout succeeds", checkout.Run(CreateCheckoutRequest(), new PatternKit.Examples.Messaging.CheckoutServices()).Succeeded) + ]; } private static PurchaseOrder CreateOrder() From 75f5dbccde0836f9b2289c86ccead749b21c0725 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 19:14:20 -0500 Subject: [PATCH 7/7] test: align DI coercion usage with string support --- .../PatternKitExampleDependencyInjectionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index ab58bcc6..ac8bcf27 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -113,7 +113,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() [ ("integer coercer converts text", coercion.Integers.From("42") == 42), ("boolean coercer converts text", coercion.Booleans.From("true") == true), - ("string coercer converts values", !string.IsNullOrWhiteSpace(coercion.Strings.From(12))), + ("string coercer accepts strings", coercion.Strings.From("patternkit") == "patternkit"), ("notification strategy sends", send.Success), ("auth chain logs denied admin requests", auth.Log.Contains("deny: missing auth", StringComparer.Ordinal)), ("minimal router returns a successful response", json.StatusCode == 200),