Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/examples/customer-service-layer-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Customer Service Layer Pattern

This production-shaped example shows customer registration as a Service Layer operation.

It demonstrates:

- fluent `ServiceLayerOperation<RegisterCustomerRequest,CustomerRegistrationReceipt>` construction
- generated operation factory with `[GenerateServiceLayerOperation]`
- repository coordination inside the operation handler
- scoped `IServiceOperation<RegisterCustomerRequest,CustomerRegistrationReceipt>` 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<CustomerServiceLayerWorkflow>();
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`
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` |

Expand Down
37 changes: 37 additions & 0 deletions docs/generators/service-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Service Layer Generator

`GenerateServiceLayerOperationAttribute` creates a typed `ServiceLayerOperation<TRequest,TResponse>` 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<CustomerRegistrationReceipt> Handle(RegisterCustomerRequest request, CancellationToken cancellationToken)
=> new(new CustomerRegistrationReceipt(request.CustomerId, request.Email));
}
```

The generated factory is equivalent to:

```csharp
ServiceLayerOperation<RegisterCustomerRequest, CustomerRegistrationReceipt>
.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<TResponse>` from `(TRequest, CancellationToken)`.
- `PKSL004`: rule must be static and return `bool` from `TRequest`.
- `PKSL005`: rule order values must be unique.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Application Architecture | Data Mapper | `DataMapper<TDomain,TData>` | Data Mapper generator |
| Application Architecture | Identity Map | `IdentityMap<TEntity,TKey>` | Identity Map generator |
| Application Architecture | Transaction Script | `TransactionScript<TRequest,TResponse>` | Transaction Script generator |
| Application Architecture | Service Layer | `IServiceOperation<TRequest,TResponse>` and `ServiceLayerOperation<TRequest,TResponse>` | Service Layer generator |
| Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer<TExternal, TDomain>` | Anti-Corruption Layer generator |

## Research Baselines
Expand Down
28 changes: 28 additions & 0 deletions docs/patterns/application/service-layer.md
Original file line number Diff line number Diff line change
@@ -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<TRequest,TResponse>` and `ServiceLayerOperation<TRequest,TResponse>` in `PatternKit.Application.ServiceLayer`.

```csharp
var operation = ServiceLayerOperation<RegisterCustomerRequest, CustomerRegistrationReceipt>
.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<TRequest,TResponse>` 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)
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions src/PatternKit.Core/Application/ServiceLayer/ServiceLayer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
namespace PatternKit.Application.ServiceLayer;

/// <summary>Application service operation exposed by a Service Layer boundary.</summary>
public interface IServiceOperation<TRequest, TResponse>
{
string Name { get; }

ValueTask<ServiceLayerResult<TResponse>> ExecuteAsync(TRequest request, CancellationToken cancellationToken = default);
}

/// <summary>Fluent Service Layer operation with preconditions and a typed handler.</summary>
public sealed class ServiceLayerOperation<TRequest, TResponse> : IServiceOperation<TRequest, TResponse>
{
private readonly IReadOnlyList<ServiceLayerRule<TRequest>> _rules;
private readonly Func<TRequest, CancellationToken, ValueTask<TResponse>> _handler;

private ServiceLayerOperation(
string name,
IReadOnlyList<ServiceLayerRule<TRequest>> rules,
Func<TRequest, CancellationToken, ValueTask<TResponse>> handler)
{
Name = name;
_rules = rules;
_handler = handler;
}

public string Name { get; }

public static Builder Create(string name)
=> new(name);

public async ValueTask<ServiceLayerResult<TResponse>> 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<TResponse>.Rejected(rule.Code, rule.Message);
}

try
{
return ServiceLayerResult<TResponse>.Completed(await _handler(request, cancellationToken).ConfigureAwait(false));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return ServiceLayerResult<TResponse>.Failed(ex);
}
}

public sealed class Builder
{
private readonly string _name;
private readonly List<ServiceLayerRule<TRequest>> _rules = new();
private Func<TRequest, CancellationToken, ValueTask<TResponse>>? _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<TRequest, bool> predicate)
{
_rules.Add(new ServiceLayerRule<TRequest>(code, message, predicate));
return this;
}

public Builder Handle(Func<TRequest, CancellationToken, ValueTask<TResponse>> handler)
{
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
return this;
}

public ServiceLayerOperation<TRequest, TResponse> Build()
=> new(_name, _rules.ToArray(), _handler ?? throw new InvalidOperationException("Service Layer operation handler is required."));
}
}

/// <summary>Precondition rule used by a Service Layer operation.</summary>
public sealed class ServiceLayerRule<TRequest>
{
public ServiceLayerRule(string code, string message, Func<TRequest, bool> 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<TRequest, bool> Predicate { get; }
}

/// <summary>Result returned by a Service Layer operation.</summary>
public sealed class ServiceLayerResult<TResponse>
{
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<TResponse> Completed(TResponse response)
=> new(response, ServiceLayerStatus.Completed, null, null, null);

public static ServiceLayerResult<TResponse> Rejected(string code, string message)
=> new(default, ServiceLayerStatus.Rejected, Validate(code, nameof(code)), Validate(message, nameof(message)), null);

public static ServiceLayerResult<TResponse> 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;
}

/// <summary>Execution status for a Service Layer operation.</summary>
public enum ServiceLayerStatus
{
Completed,
Rejected,
Failed
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, PrototypeDemo.PrototypeDemo.GameCharacter> Factory);
public sealed record ProxyPatternDemonstrationsExample(Proxy<int, string> RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy);
public sealed record FlyweightGlyphCacheExample(Func<string, IReadOnlyList<(FlyweightDemo.FlyweightDemo.Glyph Glyph, int X)>> RenderSentence);
Expand Down Expand Up @@ -191,6 +193,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddOrderDataMapperPatternExample()
.AddOrderIdentityMapPatternExample()
.AddOrderTransactionScriptPatternExample()
.AddCustomerServiceLayerPatternExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -564,6 +567,13 @@ public static IServiceCollection AddOrderTransactionScriptPatternExample(this IS
return services.RegisterExample<OrderTransactionScriptPatternExample>("Order Transaction Script Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddCustomerServiceLayerPatternExample(this IServiceCollection services)
{
services.AddCustomerServiceLayerDemo();
services.AddSingleton<CustomerServiceLayerPatternExample>(sp => new(sp.GetRequiredService<CustomerServiceLayerDemoRunner>()));
return services.RegisterExample<CustomerServiceLayerPatternExample>("Customer Service Layer Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost);
}

public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services)
{
services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading