diff --git a/docs/examples/customer-service-layer-pattern.md b/docs/examples/customer-service-layer-pattern.md new file mode 100644 index 00000000..3add7d00 --- /dev/null +++ b/docs/examples/customer-service-layer-pattern.md @@ -0,0 +1,28 @@ +# Customer Service Layer Pattern + +This production-shaped example shows customer registration as a Service Layer operation. + +It demonstrates: + +- fluent `ServiceLayerOperation` construction +- generated operation factory with `[GenerateServiceLayerOperation]` +- repository coordination inside the operation handler +- scoped `IServiceOperation` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddCustomerServiceLayerDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.RegisterAsync(new RegisterCustomerRequest("customer-100", "buyer@example.com", "retail")); +``` + +The registered operation is scoped so importing applications can compose it with request-scoped repositories, database sessions, tenant services, and ASP.NET Core request services. + +Files: + +- `src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs` +- `test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index e9d63dcc..af88f19e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -94,6 +94,9 @@ - name: Order Transaction Script Pattern href: order-transaction-script-pattern.md +- name: Customer Service Layer Pattern + href: customer-service-layer-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index cb987f9e..4d692772 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -65,6 +65,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | +| [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | | [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` | | [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` | diff --git a/docs/generators/service-layer.md b/docs/generators/service-layer.md new file mode 100644 index 00000000..1f06030c --- /dev/null +++ b/docs/generators/service-layer.md @@ -0,0 +1,37 @@ +# Service Layer Generator + +`GenerateServiceLayerOperationAttribute` creates a typed `ServiceLayerOperation` factory from a static partial host. + +```csharp +[GenerateServiceLayerOperation(typeof(RegisterCustomerRequest), typeof(CustomerRegistrationReceipt), FactoryName = "CreateOperation", OperationName = "register-customer")] +public static partial class GeneratedCustomerServiceLayer +{ + [ServiceLayerRule("email", "Email is required.", 10)] + private static bool HasEmail(RegisterCustomerRequest request) + => !string.IsNullOrWhiteSpace(request.Email); + + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomerRequest request, CancellationToken cancellationToken) + => new(new CustomerRegistrationReceipt(request.CustomerId, request.Email)); +} +``` + +The generated factory is equivalent to: + +```csharp +ServiceLayerOperation + .Create("register-customer") + .Require("email", "Email is required.", HasEmail) + .Handle(Handle) + .Build(); +``` + +Rules are ordered by the `order` argument on `[ServiceLayerRule]`. + +Diagnostics: + +- `PKSL001`: host type must be partial. +- `PKSL002`: exactly one `[ServiceLayerHandler]` method is required. +- `PKSL003`: handler must be static and return `ValueTask` from `(TRequest, CancellationToken)`. +- `PKSL004`: rule must be static and return `bool` from `TRequest`. +- `PKSL005`: rule order values must be unique. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index c32195c9..8b6d32e4 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -117,6 +117,9 @@ - name: Transaction Script href: transaction-script.md +- name: Service Layer + href: service-layer.md + - name: Visitor Generator href: visitor-generator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 22258859..a0d5e3cc 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -72,6 +72,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Data Mapper | `DataMapper` | Data Mapper generator | | Application Architecture | Identity Map | `IdentityMap` | Identity Map generator | | Application Architecture | Transaction Script | `TransactionScript` | Transaction Script generator | +| Application Architecture | Service Layer | `IServiceOperation` and `ServiceLayerOperation` | Service Layer generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/service-layer.md b/docs/patterns/application/service-layer.md new file mode 100644 index 00000000..5e8868eb --- /dev/null +++ b/docs/patterns/application/service-layer.md @@ -0,0 +1,28 @@ +# Service Layer + +Service Layer models application-facing operations behind a typed boundary. It centralizes preconditions, orchestration, persistence calls, and result handling so controllers, workers, and hosted services can depend on an operation instead of coordinating the workflow themselves. + +PatternKit provides `IServiceOperation` and `ServiceLayerOperation` in `PatternKit.Application.ServiceLayer`. + +```csharp +var operation = ServiceLayerOperation + .Create("register-customer") + .Require("email", "Email is required.", request => !string.IsNullOrWhiteSpace(request.Email)) + .Handle(async (request, ct) => + { + await repository.AddAsync(new RegisteredCustomer(request.CustomerId, request.Email, request.Segment), ct); + return new CustomerRegistrationReceipt(request.CustomerId, request.Email); + }) + .Build(); + +var result = await operation.ExecuteAsync(request, cancellationToken); +``` + +The result distinguishes completed, rejected, and failed executions. That makes the operation usable from ASP.NET Core endpoints, background workers, and command handlers without mixing validation branches and exception-only control flow. + +Register `IServiceOperation` as scoped when it depends on repositories, database sessions, tenant context, or request-scoped infrastructure. Use the source-generated path when operation rules and handler methods are stable application code. + +See also: + +- [Service Layer generator](../../generators/service-layer.md) +- [Customer Service Layer example](../../examples/customer-service-layer-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 282c0635..b449a3ab 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -343,6 +343,8 @@ href: application/identity-map.md - name: Transaction Script href: application/transaction-script.md + - name: Service Layer + href: application/service-layer.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/ServiceLayer/ServiceLayer.cs b/src/PatternKit.Core/Application/ServiceLayer/ServiceLayer.cs new file mode 100644 index 00000000..38914af7 --- /dev/null +++ b/src/PatternKit.Core/Application/ServiceLayer/ServiceLayer.cs @@ -0,0 +1,150 @@ +namespace PatternKit.Application.ServiceLayer; + +/// Application service operation exposed by a Service Layer boundary. +public interface IServiceOperation +{ + string Name { get; } + + ValueTask> ExecuteAsync(TRequest request, CancellationToken cancellationToken = default); +} + +/// Fluent Service Layer operation with preconditions and a typed handler. +public sealed class ServiceLayerOperation : IServiceOperation +{ + private readonly IReadOnlyList> _rules; + private readonly Func> _handler; + + private ServiceLayerOperation( + string name, + IReadOnlyList> rules, + Func> handler) + { + Name = name; + _rules = rules; + _handler = handler; + } + + public string Name { get; } + + public static Builder Create(string name) + => new(name); + + public async ValueTask> ExecuteAsync(TRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + cancellationToken.ThrowIfCancellationRequested(); + foreach (var rule in _rules) + { + if (!rule.Predicate(request)) + return ServiceLayerResult.Rejected(rule.Code, rule.Message); + } + + try + { + return ServiceLayerResult.Completed(await _handler(request, cancellationToken).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return ServiceLayerResult.Failed(ex); + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly List> _rules = new(); + private Func>? _handler; + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Service Layer operation name is required.", nameof(name)) + : name; + } + + public Builder Require(string code, string message, Func predicate) + { + _rules.Add(new ServiceLayerRule(code, message, predicate)); + return this; + } + + public Builder Handle(Func> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public ServiceLayerOperation Build() + => new(_name, _rules.ToArray(), _handler ?? throw new InvalidOperationException("Service Layer operation handler is required.")); + } +} + +/// Precondition rule used by a Service Layer operation. +public sealed class ServiceLayerRule +{ + public ServiceLayerRule(string code, string message, Func predicate) + { + Code = string.IsNullOrWhiteSpace(code) + ? throw new ArgumentException("Service Layer rule code is required.", nameof(code)) + : code; + Message = string.IsNullOrWhiteSpace(message) + ? throw new ArgumentException("Service Layer rule message is required.", nameof(message)) + : message; + Predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + } + + public string Code { get; } + + public string Message { get; } + + public Func Predicate { get; } +} + +/// Result returned by a Service Layer operation. +public sealed class ServiceLayerResult +{ + private ServiceLayerResult(TResponse? response, ServiceLayerStatus status, string? code, string? message, Exception? exception) + { + Response = response; + Status = status; + Code = code; + Message = message; + Exception = exception; + } + + public TResponse? Response { get; } + + public ServiceLayerStatus Status { get; } + + public string? Code { get; } + + public string? Message { get; } + + public Exception? Exception { get; } + + public bool Succeeded => Status == ServiceLayerStatus.Completed; + + public static ServiceLayerResult Completed(TResponse response) + => new(response, ServiceLayerStatus.Completed, null, null, null); + + public static ServiceLayerResult Rejected(string code, string message) + => new(default, ServiceLayerStatus.Rejected, Validate(code, nameof(code)), Validate(message, nameof(message)), null); + + public static ServiceLayerResult Failed(Exception exception) + => new(default, ServiceLayerStatus.Failed, null, null, exception ?? throw new ArgumentNullException(nameof(exception))); + + private static string Validate(string value, string parameterName) + => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Service Layer result values are required.", parameterName) + : value; +} + +/// Execution status for a Service Layer operation. +public enum ServiceLayerStatus +{ + Completed, + Rejected, + Failed +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index cb9d790d..0616434a 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -41,6 +41,7 @@ using PatternKit.Examples.RateLimitingDemo; using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.RetryDemo; +using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.Singleton; using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.Strategies.Coercion; @@ -130,6 +131,7 @@ public sealed record CheckoutUnitOfWorkPatternExample(CheckoutUnitOfWorkDemoRunn public sealed record OrderDataMapperPatternExample(OrderDataMapperDemoRunner Runner, OrderDataMapperWorkflow Workflow); public sealed record OrderIdentityMapPatternExample(OrderIdentityMapDemoRunner Runner); public sealed record OrderTransactionScriptPatternExample(OrderTransactionScriptDemoRunner Runner); +public sealed record CustomerServiceLayerPatternExample(CustomerServiceLayerDemoRunner Runner); 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); @@ -191,6 +193,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderDataMapperPatternExample() .AddOrderIdentityMapPatternExample() .AddOrderTransactionScriptPatternExample() + .AddCustomerServiceLayerPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -564,6 +567,13 @@ public static IServiceCollection AddOrderTransactionScriptPatternExample(this IS return services.RegisterExample("Order Transaction Script Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddCustomerServiceLayerPatternExample(this IServiceCollection services) + { + services.AddCustomerServiceLayerDemo(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Customer Service Layer Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index a566845f..427d0a44 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -336,6 +336,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["TransactionScript"], ["explicit application workflow", "source-generated script factory", "DI composition"]), + Descriptor( + "Customer Service Layer Pattern", + "src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs", + "test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs", + "docs/examples/customer-service-layer-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["ServiceLayer"], + ["application operation boundary", "source-generated operation factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 611f4005..546dd883 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -726,6 +726,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/TransactionScriptDemo/OrderTransactionScriptDemoTests.cs", ["fluent application workflow", "generated script factory", "DI-importable service operation"]), + Pattern("Service Layer", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/service-layer.md", + "src/PatternKit.Core/Application/ServiceLayer/ServiceLayer.cs", + "test/PatternKit.Tests/Application/ServiceLayer/ServiceLayerTests.cs", + "docs/generators/service-layer.md", + "src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs", + "test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs", + null, + "docs/examples/customer-service-layer-pattern.md", + "src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs", + "test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs", + ["fluent application service boundary", "generated operation factory", "DI-importable workflow"]), + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, "docs/patterns/application/anti-corruption-layer.md", "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", diff --git a/src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs b/src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs new file mode 100644 index 00000000..448ac3e6 --- /dev/null +++ b/src/PatternKit.Examples/ServiceLayerDemo/CustomerServiceLayerDemo.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.Repository; +using PatternKit.Application.ServiceLayer; +using PatternKit.Generators.ServiceLayer; + +namespace PatternKit.Examples.ServiceLayerDemo; + +public static class CustomerServiceLayerDemo +{ + public static async ValueTask RunFluentAsync() + { + var repository = InMemoryRepository.Create(static customer => customer.CustomerId).Build(); + var operation = CustomerServiceLayerPolicies.CreateFluentOperation(repository); + var result = await operation.ExecuteAsync(new RegisterCustomerRequest("customer-100", "buyer@example.com", "retail")); + return new(result.Succeeded, result.Response?.CustomerId ?? "", (await repository.ListAsync()).Count); + } + + public static async ValueTask RunGeneratedAsync() + { + GeneratedCustomerServiceLayer.Repository = InMemoryRepository.Create(static customer => customer.CustomerId).Build(); + var result = await GeneratedCustomerServiceLayer.CreateOperation().ExecuteAsync(new RegisterCustomerRequest("customer-200", "buyer2@example.com", "enterprise")); + return new(result.Succeeded, result.Response?.CustomerId ?? "", (await GeneratedCustomerServiceLayer.Repository.ListAsync()).Count); + } +} + +public sealed record RegisterCustomerRequest(string CustomerId, string Email, string Segment); + +public sealed record CustomerRegistrationReceipt(string CustomerId, string Email); + +public sealed record RegisteredCustomer(string CustomerId, string Email, string Segment); + +public sealed record CustomerServiceLayerSummary(bool Registered, string CustomerId, int RepositoryCount); + +public static class CustomerServiceLayerPolicies +{ + public static ServiceLayerOperation CreateFluentOperation(IRepository repository) + { + if (repository is null) + throw new ArgumentNullException(nameof(repository)); + + return ServiceLayerOperation.Create("register-customer") + .Require("customer-id", "Customer id is required.", static request => !string.IsNullOrWhiteSpace(request.CustomerId)) + .Require("email", "Email is required.", static request => !string.IsNullOrWhiteSpace(request.Email)) + .Require("segment", "Customer segment is required.", static request => !string.IsNullOrWhiteSpace(request.Segment)) + .Handle(async (request, cancellationToken) => + { + var result = await repository.AddAsync(new RegisteredCustomer(request.CustomerId, request.Email, request.Segment), cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + throw new InvalidOperationException(result.Reason); + + return new CustomerRegistrationReceipt(request.CustomerId, request.Email); + }) + .Build(); + } +} + +public sealed class CustomerServiceLayerWorkflow +{ + private readonly IServiceOperation _operation; + + public CustomerServiceLayerWorkflow(IServiceOperation operation) + { + _operation = operation; + } + + public async ValueTask RegisterAsync(RegisterCustomerRequest request, CancellationToken cancellationToken = default) + { + var result = await _operation.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + return new(result.Succeeded, result.Response?.CustomerId ?? "", result.Succeeded ? 1 : 0); + } +} + +public sealed record CustomerServiceLayerDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class CustomerServiceLayerServiceCollectionExtensions +{ + public static IServiceCollection AddCustomerServiceLayerDemo(this IServiceCollection services) + { + services.AddScoped>(_ => InMemoryRepository.Create(static customer => customer.CustomerId).Build()); + services.AddScoped>(sp => + CustomerServiceLayerPolicies.CreateFluentOperation(sp.GetRequiredService>())); + services.AddScoped(); + services.AddSingleton(new CustomerServiceLayerDemoRunner( + CustomerServiceLayerDemo.RunFluentAsync, + CustomerServiceLayerDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateServiceLayerOperation(typeof(RegisterCustomerRequest), typeof(CustomerRegistrationReceipt), FactoryName = "CreateOperation", OperationName = "register-customer")] +public static partial class GeneratedCustomerServiceLayer +{ + public static IRepository Repository { get; set; } = + InMemoryRepository.Create(static customer => customer.CustomerId).Build(); + + [ServiceLayerRule("customer-id", "Customer id is required.", 10)] + private static bool HasCustomerId(RegisterCustomerRequest request) => !string.IsNullOrWhiteSpace(request.CustomerId); + + [ServiceLayerRule("email", "Email is required.", 20)] + private static bool HasEmail(RegisterCustomerRequest request) => !string.IsNullOrWhiteSpace(request.Email); + + [ServiceLayerRule("segment", "Customer segment is required.", 30)] + private static bool HasSegment(RegisterCustomerRequest request) => !string.IsNullOrWhiteSpace(request.Segment); + + [ServiceLayerHandler] + private static async ValueTask Handle(RegisterCustomerRequest request, CancellationToken cancellationToken) + { + var result = await Repository.AddAsync(new RegisteredCustomer(request.CustomerId, request.Email, request.Segment), cancellationToken).ConfigureAwait(false); + if (!result.Succeeded) + throw new InvalidOperationException(result.Reason); + + return new CustomerRegistrationReceipt(request.CustomerId, request.Email); + } +} diff --git a/src/PatternKit.Generators.Abstractions/ServiceLayer/ServiceLayerAttributes.cs b/src/PatternKit.Generators.Abstractions/ServiceLayer/ServiceLayerAttributes.cs new file mode 100644 index 00000000..75d08f20 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/ServiceLayer/ServiceLayerAttributes.cs @@ -0,0 +1,46 @@ +namespace PatternKit.Generators.ServiceLayer; + +/// Generates a Service Layer operation factory from attributed handler and rule methods. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateServiceLayerOperationAttribute : Attribute +{ + public GenerateServiceLayerOperationAttribute(Type requestType, Type responseType) + { + RequestType = requestType ?? throw new ArgumentNullException(nameof(requestType)); + ResponseType = responseType ?? throw new ArgumentNullException(nameof(responseType)); + } + + public Type RequestType { get; } + + public Type ResponseType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string OperationName { get; set; } = ""; +} + +/// Marks the handler method for a generated Service Layer operation. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class ServiceLayerHandlerAttribute : Attribute; + +/// Marks a precondition rule for a generated Service Layer operation. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ServiceLayerRuleAttribute : Attribute +{ + public ServiceLayerRuleAttribute(string code, string message, int order) + { + Code = string.IsNullOrWhiteSpace(code) + ? throw new ArgumentException("Service Layer rule code is required.", nameof(code)) + : code; + Message = string.IsNullOrWhiteSpace(message) + ? throw new ArgumentException("Service Layer rule message is required.", nameof(message)) + : message; + Order = order; + } + + public string Code { get; } + + public string Message { get; } + + public int Order { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 59d13284..970a0a65 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -67,6 +67,11 @@ PKTS001 | PatternKit.Generators.TransactionScript | Error | Transaction Script h PKTS002 | PatternKit.Generators.TransactionScript | Error | Transaction Script must declare exactly one handler. PKTS003 | PatternKit.Generators.TransactionScript | Error | Transaction Script handler signature is invalid. PKTS004 | PatternKit.Generators.TransactionScript | Error | Transaction Script validator signature is invalid. +PKSL001 | PatternKit.Generators.ServiceLayer | Error | Service Layer host must be partial. +PKSL002 | PatternKit.Generators.ServiceLayer | Error | Service Layer operation must declare exactly one handler. +PKSL003 | PatternKit.Generators.ServiceLayer | Error | Service Layer handler signature is invalid. +PKSL004 | PatternKit.Generators.ServiceLayer | Error | Service Layer rule signature is invalid. +PKSL005 | PatternKit.Generators.ServiceLayer | Error | Service Layer rule order is duplicated. PKUOW001 | PatternKit.Generators.UnitOfWork | Error | Unit of work host must be partial. PKUOW002 | PatternKit.Generators.UnitOfWork | Error | Unit of work must declare at least one step. PKUOW003 | PatternKit.Generators.UnitOfWork | Error | Unit of work step signature is invalid. diff --git a/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs b/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs new file mode 100644 index 00000000..bf8f94ae --- /dev/null +++ b/src/PatternKit.Generators/ServiceLayer/ServiceLayerOperationGenerator.cs @@ -0,0 +1,201 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.ServiceLayer; + +[Generator] +public sealed class ServiceLayerOperationGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.ServiceLayer.GenerateServiceLayerOperationAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.ServiceLayer.ServiceLayerHandlerAttribute"; + private const string RuleAttributeName = "PatternKit.Generators.ServiceLayer.ServiceLayerRuleAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSL001", "Service Layer host must be partial", + "Type '{0}' is marked with [GenerateServiceLayerOperation] but is not declared as partial", + "PatternKit.Generators.ServiceLayer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKSL002", "Service Layer handler is missing", + "Service Layer operation '{0}' must declare exactly one [ServiceLayerHandler] method", + "PatternKit.Generators.ServiceLayer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKSL003", "Service Layer handler signature is invalid", + "Service Layer handler '{0}' must be static and return ValueTask from TRequest and CancellationToken parameters", + "PatternKit.Generators.ServiceLayer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidRule = new( + "PKSL004", "Service Layer rule signature is invalid", + "Service Layer rule '{0}' must be static and return bool from one TRequest parameter", + "PatternKit.Generators.ServiceLayer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateRule = new( + "PKSL005", "Service Layer rule order is duplicated", + "Service Layer rule order values must be unique", + "PatternKit.Generators.ServiceLayer", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var requestType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var responseType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (requestType is null || responseType is null) + return; + + var handlers = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == HandlerAttributeName)) + .ToArray(); + if (handlers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var handler = handlers[0]; + if (!IsHandler(handler, requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + + var rules = GetRules(type); + if (rules.Select(static rule => rule.Order).Distinct().Count() != rules.Count) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRule, node.Identifier.GetLocation())); + return; + } + + foreach (var rule in rules) + { + if (!IsRule(rule.Method, requestType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRule, rule.Method.Locations.FirstOrDefault(), rule.Method.Name)); + return; + } + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var operationName = GetNamedString(attribute, "OperationName"); + if (string.IsNullOrWhiteSpace(operationName)) + operationName = type.Name; + + context.AddSource($"{type.Name}.ServiceLayer.g.cs", SourceText.From( + GenerateSource(type, requestType, responseType, handler.Name, rules.OrderBy(static rule => rule.Order).ToArray(), factoryName, operationName!), + Encoding.UTF8)); + } + + private static List GetRules(INamedTypeSymbol type) + => type.GetMembers().OfType() + .SelectMany(method => method.GetAttributes() + .Where(static attr => attr.AttributeClass?.ToDisplayString() == RuleAttributeName) + .Select(attr => new RuleConfig( + method, + attr.ConstructorArguments[0].Value?.ToString() ?? method.Name, + attr.ConstructorArguments[1].Value?.ToString() ?? method.Name, + (int)(attr.ConstructorArguments[2].Value ?? 0)))) + .ToList(); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + string handlerName, + IReadOnlyList rules, + string factoryName, + string operationName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var requestName = requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var responseName = responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + 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.ServiceLayer.ServiceLayerOperation<") + .Append(requestName).Append(", ").Append(responseName).Append("> ").Append(factoryName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Application.ServiceLayer.ServiceLayerOperation<") + .Append(requestName).Append(", ").Append(responseName).Append(">.Create(\"").Append(Escape(operationName)).AppendLine("\");"); + foreach (var rule in rules) + sb.Append(" builder.Require(\"").Append(Escape(rule.Code)).Append("\", \"").Append(Escape(rule.Message)).Append("\", ").Append(rule.Method.Name).AppendLine(");"); + sb.Append(" return builder.Handle(").Append(handlerName).AppendLine(").Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType is INamedTypeSymbol returnType + && returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.Tasks.ValueTask<" + responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">" + && method.Parameters.Length == 2 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType) + && method.Parameters[1].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"; + + private static bool IsRule(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic + && !method.IsGenericMethod + && method.ReturnType.SpecialType == SpecialType.System_Boolean + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private 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 sealed record RuleConfig(IMethodSymbol Method, string Code, string Message, int Order); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 09fdcb5f..b2ea73d2 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -13,6 +13,7 @@ using PatternKit.Examples.ProductionReadiness; using PatternKit.Examples.RateLimitingDemo; using PatternKit.Examples.RepositoryDemo; +using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.Strategies.Composed; using PatternKit.Examples.TransactionScriptDemo; using PatternKit.Examples.UnitOfWorkDemo; @@ -108,6 +109,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var dataMapper = provider.GetRequiredService(); var identityMap = provider.GetRequiredService(); var transactionScript = provider.GetRequiredService(); + var serviceLayer = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -183,6 +185,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("data mapper example rehydrates stored orders", dataMapper.Workflow.RunAsync().AsTask().GetAwaiter().GetResult().LoadedCustomerId == "customer-1"), ("identity map example reuses loaded orders", identityMap.Runner.RunFluent().ReusedInstance), ("transaction script example submits orders", transactionScript.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Submitted), + ("service layer example registers customers", serviceLayer.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Registered), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 62f8bef3..094700c9 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -66,6 +66,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Data Mapper", "Identity Map", "Transaction Script", + "Service Layer", "Anti-Corruption Layer" ]; @@ -110,7 +111,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, 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(8, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs b/test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs new file mode 100644 index 00000000..acec72e0 --- /dev/null +++ b/test/PatternKit.Examples.Tests/ServiceLayerDemo/CustomerServiceLayerDemoTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.ServiceLayerDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.ServiceLayerDemo; + +[Feature("Customer Service Layer demo")] +public sealed partial class CustomerServiceLayerDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Customer Service Layer demo registers customers")] + [Theory] + [InlineData(false)] + [InlineData(true)] + public Task Customer_Service_Layer_Demo_Registers_Customers(bool sourceGenerated) + => Given("the customer service layer demo", () => sourceGenerated) + .When("the selected path runs", (Func>)(async generated => + generated + ? await CustomerServiceLayerDemo.RunGeneratedAsync() + : await CustomerServiceLayerDemo.RunFluentAsync())) + .Then("the customer is registered", summary => + { + ScenarioExpect.True(summary.Registered); + ScenarioExpect.False(string.IsNullOrWhiteSpace(summary.CustomerId)); + ScenarioExpect.Equal(1, summary.RepositoryCount); + }) + .AssertPassed(); + + [Scenario("Customer Service Layer demo is importable through IServiceCollection")] + [Fact] + public Task Customer_Service_Layer_Demo_Is_Importable_Through_IServiceCollection() + => Given("a service provider with the customer service layer demo", () => + { + var services = new ServiceCollection(); + services.AddCustomerServiceLayerDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("a scoped workflow registers a customer", (Func>)(async provider => + { + using (provider) + using (var scope = provider.CreateScope()) + { + var workflow = scope.ServiceProvider.GetRequiredService(); + return await workflow.RegisterAsync(new RegisterCustomerRequest("customer-300", "buyer3@example.com", "retail")); + } + })) + .Then("the operation succeeds", summary => + { + ScenarioExpect.True(summary.Registered); + ScenarioExpect.Equal("customer-300", summary.CustomerId); + ScenarioExpect.Equal(1, summary.RepositoryCount); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 0da9a21c..ff81ebda 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -22,6 +22,7 @@ using PatternKit.Generators.RateLimiting; using PatternKit.Generators.Repository; using PatternKit.Generators.Retry; +using PatternKit.Generators.ServiceLayer; using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; using PatternKit.Generators.State; @@ -174,6 +175,9 @@ private enum TestTrigger { typeof(GenerateTransactionScriptAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(TransactionScriptHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(TransactionScriptValidatorAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateServiceLayerOperationAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ServiceLayerHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(ServiceLayerRuleAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateUnitOfWorkAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(UnitOfWorkStepAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateVisitorAttribute), AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, false, false } @@ -1027,6 +1031,12 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() FactoryName = "BuildSubmitOrder", ScriptName = "submit-order" }; + var serviceLayer = new GenerateServiceLayerOperationAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildRegisterCustomer", + OperationName = "register-customer" + }; + var serviceLayerRule = new ServiceLayerRuleAttribute("email", "Email is required.", 10); ScenarioExpect.Equal(typeof(TestState), stateMachine.StateType); ScenarioExpect.Equal(typeof(TestTrigger), stateMachine.TriggerType); @@ -1062,11 +1072,23 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal(typeof(int), transactionScript.ResponseType); ScenarioExpect.Equal("BuildSubmitOrder", transactionScript.FactoryName); ScenarioExpect.Equal("submit-order", transactionScript.ScriptName); + ScenarioExpect.Equal(typeof(string), serviceLayer.RequestType); + ScenarioExpect.Equal(typeof(int), serviceLayer.ResponseType); + ScenarioExpect.Equal("BuildRegisterCustomer", serviceLayer.FactoryName); + ScenarioExpect.Equal("register-customer", serviceLayer.OperationName); + ScenarioExpect.Equal("email", serviceLayerRule.Code); + ScenarioExpect.Equal("Email is required.", serviceLayerRule.Message); + ScenarioExpect.Equal(10, serviceLayerRule.Order); ScenarioExpect.Throws(() => new UnitOfWorkStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateTransactionScriptAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GenerateServiceLayerOperationAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateServiceLayerOperationAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("", "message", 1)); + ScenarioExpect.Throws(() => new ServiceLayerRuleAttribute("code", "", 1)); ScenarioExpect.IsType(new TransactionScriptHandlerAttribute()); ScenarioExpect.IsType(new TransactionScriptValidatorAttribute()); + ScenarioExpect.IsType(new ServiceLayerHandlerAttribute()); AssertEnumValues(StateMachineInvalidTriggerPolicy.Throw, StateMachineInvalidTriggerPolicy.Ignore, StateMachineInvalidTriggerPolicy.ReturnFalse); AssertEnumValues(StateMachineGuardFailurePolicy.Throw, StateMachineGuardFailurePolicy.Ignore, StateMachineGuardFailurePolicy.ReturnFalse); AssertEnumValues(HookPoint.BeforeAll, HookPoint.AfterAll, HookPoint.OnError); diff --git a/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs b/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs new file mode 100644 index 00000000..6d31c6ed --- /dev/null +++ b/test/PatternKit.Generators.Tests/ServiceLayerGeneratorTests.cs @@ -0,0 +1,83 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.ServiceLayer; +using PatternKit.Generators.ServiceLayer; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Service Layer generator")] +public sealed partial class ServiceLayerGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits service layer operation factory")] + [Fact] + public Task Generator_Emits_Service_Layer_Operation_Factory() + => Given("a valid service layer declaration", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.ServiceLayer; + namespace Demo; + public sealed record RegisterCustomer(string Email); + public sealed record CustomerReceipt(string Email); + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt), FactoryName = "Build", OperationName = "register-customer")] + public static partial class RegisterCustomerService + { + [ServiceLayerRule("tenant", "Tenant is required.", 1)] + private static bool HasTenant(RegisterCustomer request) => true; + [ServiceLayerRule("email", "Email is required.", 2)] + private static bool HasEmail(RegisterCustomer request) => !string.IsNullOrWhiteSpace(request.Email); + [ServiceLayerHandler] + private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); + } + """)) + .Then("generated source creates the operation with ordered rules and handler", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Create(\"register-customer\")", source); + ScenarioExpect.Contains("Require(\"tenant\", \"Tenant is required.\", HasTenant)", source); + ScenarioExpect.Contains("Require(\"email\", \"Email is required.\", HasEmail)", source); + ScenarioExpect.Contains(".Handle(Handle).Build()", source); + ScenarioExpect.True(source.IndexOf("HasTenant", StringComparison.Ordinal) < source.IndexOf("HasEmail", StringComparison.Ordinal)); + }) + .AssertPassed(); + + [Scenario("Generator reports invalid service layer declarations")] + [Theory] + [InlineData("public static class RegisterCustomerService { [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL001")] + [InlineData("public static partial class RegisterCustomerService;", "PKSL002")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static ValueTask One(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); [ServiceLayerHandler] private static ValueTask Two(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL002")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerHandler] private static CustomerReceipt Handle(RegisterCustomer request) => new(request.Email); }", "PKSL003")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static string HasEmail(RegisterCustomer request) => request.Email; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL004")] + [InlineData("public static partial class RegisterCustomerService { [ServiceLayerRule(\"email\", \"Email is required.\", 1)] private static bool HasEmail(RegisterCustomer request) => true; [ServiceLayerRule(\"tenant\", \"Tenant is required.\", 1)] private static bool HasTenant(RegisterCustomer request) => true; [ServiceLayerHandler] private static ValueTask Handle(RegisterCustomer request, CancellationToken cancellationToken) => new(new CustomerReceipt(request.Email)); }", "PKSL005")] + public Task Generator_Reports_Invalid_Service_Layer_Declarations(string declaration, string diagnosticId) + => Given("an invalid service layer declaration", () => Compile($$""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.ServiceLayer; + public sealed record RegisterCustomer(string Email); + public sealed record CustomerReceipt(string Email); + [GenerateServiceLayerOperation(typeof(RegisterCustomer), typeof(CustomerReceipt))] + {{declaration}} + """)) + .Then("the expected diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId)) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "ServiceLayerGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(ServiceLayerOperation<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new ServiceLayerOperationGenerator(), out var run, out _); + var result = run.Results.Single(); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources); +} diff --git a/test/PatternKit.Tests/Application/ServiceLayer/ServiceLayerTests.cs b/test/PatternKit.Tests/Application/ServiceLayer/ServiceLayerTests.cs new file mode 100644 index 00000000..c91ebedd --- /dev/null +++ b/test/PatternKit.Tests/Application/ServiceLayer/ServiceLayerTests.cs @@ -0,0 +1,101 @@ +using PatternKit.Application.ServiceLayer; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.ServiceLayer; + +[Feature("Service Layer")] +public sealed partial class ServiceLayerTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Service Layer completes a valid application operation")] + [Fact] + public Task Service_Layer_Completes_A_Valid_Application_Operation() + => Given("a service operation", () => ServiceLayerOperation.Create("register-customer") + .Require("email", "Email is required.", static request => !string.IsNullOrWhiteSpace(request.Email)) + .Handle(static (request, _) => new ValueTask(new CustomerReceipt(request.Email))) + .Build()) + .When("a valid request is executed", (Func, ValueTask>>)(async operation => + await operation.ExecuteAsync(new RegisterCustomer("buyer@example.com")))) + .Then("the operation returns a completed result", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal(ServiceLayerStatus.Completed, result.Status); + ScenarioExpect.Equal("buyer@example.com", result.Response!.Email); + }) + .AssertPassed(); + + [Scenario("Service Layer rejects failed preconditions before the handler runs")] + [Fact] + public Task Service_Layer_Rejects_Failed_Preconditions_Before_The_Handler_Runs() + => Given("a service operation with a rule", () => + { + var handled = false; + var operation = ServiceLayerOperation.Create("register-customer") + .Require("email", "Email is required.", static request => !string.IsNullOrWhiteSpace(request.Email)) + .Handle((request, _) => + { + handled = true; + return new ValueTask(new CustomerReceipt(request.Email)); + }) + .Build(); + return new RejectionContext(operation, () => handled); + }) + .When("an invalid request is executed", (Func>)(async ctx => + new RejectedRegistration(await ctx.Operation.ExecuteAsync(new RegisterCustomer("")), ctx.WasHandled))) + .Then("the handler is skipped", ctx => + { + ScenarioExpect.Equal(ServiceLayerStatus.Rejected, ctx.Result.Status); + ScenarioExpect.False(ctx.Result.Succeeded); + ScenarioExpect.Equal("email", ctx.Result.Code); + ScenarioExpect.False(ctx.WasHandled()); + }) + .AssertPassed(); + + [Scenario("Service Layer reports handler failures")] + [Fact] + public Task Service_Layer_Reports_Handler_Failures() + => Given("a service operation with a failing handler", () => ServiceLayerOperation.Create("register-customer") + .Handle(static (_, _) => throw new InvalidOperationException("crm unavailable")) + .Build()) + .When("the request is executed", (Func, ValueTask>>)(async operation => + await operation.ExecuteAsync(new RegisterCustomer("buyer@example.com")))) + .Then("the failure is returned", result => + { + ScenarioExpect.Equal(ServiceLayerStatus.Failed, result.Status); + ScenarioExpect.False(result.Succeeded); + ScenarioExpect.IsType(result.Exception); + }) + .AssertPassed(); + + [Scenario("Service Layer validates required configuration")] + [Fact] + public Task Service_Layer_Validates_Required_Configuration() + => Given("service layer builders", () => true) + .Then("invalid arguments are rejected", _ => + { + ScenarioExpect.Throws(() => ServiceLayerOperation.Create("")); + ScenarioExpect.Throws(() => ServiceLayerOperation.Create("register").Require("email", "Email is required.", null!)); + ScenarioExpect.Throws(() => ServiceLayerOperation.Create("register").Handle(null!)); + ScenarioExpect.Throws(() => ServiceLayerOperation.Create("register").Build()); + ScenarioExpect.Throws(() => ServiceLayerOperation.Create("register") + .Handle(static (request, _) => new ValueTask(new CustomerReceipt(request.Email))) + .Build() + .ExecuteAsync(null!).AsTask().GetAwaiter().GetResult()); + ScenarioExpect.Throws(() => new ServiceLayerRule("", "message", static _ => true)); + ScenarioExpect.Throws(() => new ServiceLayerRule("code", "", static _ => true)); + ScenarioExpect.Throws(() => new ServiceLayerRule("code", "message", null!)); + ScenarioExpect.Throws(() => ServiceLayerResult.Rejected("", "message")); + ScenarioExpect.Throws(() => ServiceLayerResult.Rejected("code", "")); + ScenarioExpect.Throws(() => ServiceLayerResult.Failed(null!)); + }) + .AssertPassed(); + + private sealed record RegisterCustomer(string Email); + + private sealed record CustomerReceipt(string Email); + + private sealed record RejectionContext(IServiceOperation Operation, Func WasHandled); + + private sealed record RejectedRegistration(ServiceLayerResult Result, Func WasHandled); +}