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
36 changes: 36 additions & 0 deletions docs/examples/loan-approval-specifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Loan Approval Specifications

This example demonstrates a production-style Specification pattern for loan approvals. It includes a fluent registry, a source-generated registry, TinyBDD tests, and an `IServiceCollection` extension.

## Import

```csharp
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Examples.SpecificationDemo;

var services = new ServiceCollection();
services.AddLoanApprovalSpecifications();

using var provider = services.BuildServiceProvider(validateScopes: true);
var service = provider.GetRequiredService<LoanApprovalService>();
```

## Evaluate

```csharp
var application = LoanApprovalSpecificationDemo.CreatePrimeApplication();
var decision = service.Evaluate(application);
```

The service uses a generated `SpecificationRegistry<LoanApplication>` so importing applications can replace, decorate, or inspect the named rule registry through standard .NET IoC tooling.

## Rules

- `verified-identity`
- `clear-fraud`
- `prime-credit`
- `stable-income`
- `affordable`
- `approval-ready`

The fluent and generated paths are tested against the same loan applications so consumers can choose either style without changing application behavior.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
- name: CQRS Dispatcher
href: cqrs-dispatcher.md

- name: Loan Approval Specifications
href: loan-approval-specifications.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 @@ -58,6 +58,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Observer**](observer.md) | Event hubs and observer dispatch | `[ObserverHub]` |
| [**State Machine**](state-machine.md) | Deterministic finite state machines | `[StateMachine]` |
| [**Strategy**](strategy.md) | Predicate-based dispatch with fluent builder | `[GenerateStrategy]` |
| [**Specification**](specification.md) | Named business-rule registries | `[GenerateSpecificationRegistry]` |
| [**Template Method**](template-method-generator.md) | Template method skeletons with hook points | `[Template]` |
| [**Visitor**](visitor-generator.md) | Type-safe visitor implementations | `[GenerateVisitor]` |

Expand Down
52 changes: 52 additions & 0 deletions docs/generators/specification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Specification Generator

The Specification generator turns annotated static rule methods into a named `SpecificationRegistry<TCandidate>` factory.

## Usage

```csharp
using PatternKit.Generators.Specification;

[GenerateSpecificationRegistry(typeof(LoanApplication), FactoryMethodName = "Build")]
public static partial class LoanApprovalRules
{
[SpecificationRule("verified-identity")]
private static bool VerifiedIdentity(LoanApplication application)
=> application.HasVerifiedIdentity;
}
```

Generated output:

```csharp
var registry = LoanApprovalRules.Build();
var approved = registry.IsSatisfiedBy("verified-identity", application);
```

## Rule Shape

Specification rules must be static methods with this shape:

```csharp
static bool Rule(TCandidate candidate)
```

Rule names must be unique within the generated registry.

## Diagnostics

| ID | Meaning |
|---|---|
| `PKSPEC001` | Host type must be `partial`. |
| `PKSPEC002` | Host type has no `[SpecificationRule]` methods. |
| `PKSPEC003` | Rule method signature is invalid. |
| `PKSPEC004` | Rule name is duplicated. |

## Dependency Injection

```csharp
services.AddSingleton(_ => LoanApprovalRules.Build());
services.AddSingleton<LoanApprovalService>();
```

Use generated registries when teams want a stable, named rule surface that can be injected into existing ASP.NET Core or Generic Host applications.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
- name: Singleton
href: singleton.md

- name: Specification
href: specification.md

- name: State Machine
href: state-machine.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 @@ -58,6 +58,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Request-Reply | Messaging backplane facade example | Backplane topology generator |
| Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Backplane topology generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |

## Research Baselines

Expand Down
52 changes: 52 additions & 0 deletions docs/patterns/application/specification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Specification

Specification packages business rules as reusable predicates that can be composed, named, tested, and registered in dependency injection.

Use it when a domain decision has several independently meaningful rules, such as approval checks, eligibility gates, routing policies, or validation criteria.

## Fluent Path

```csharp
using PatternKit.Application.Specification;

var verified = Specification<LoanApplication>
.Where("verified-identity", application => application.HasVerifiedIdentity);

var clearFraud = Specification<LoanApplication>
.Where("clear-fraud", application => !application.HasFraudHold);

var approval = verified.And(clearFraud, "approval-ready");

var registry = SpecificationRegistry<LoanApplication>.Create()
.Add(verified.Name, verified)
.Add(clearFraud.Name, clearFraud)
.Add(approval.Name, approval)
.Build();
```

`SpecificationRegistry<T>` is the production integration point. It gives application services a stable named rule set without forcing them to know how each rule was composed.

## Generated Path

```csharp
using PatternKit.Generators.Specification;

[GenerateSpecificationRegistry(typeof(LoanApplication))]
public static partial class LoanApprovalRules
{
[SpecificationRule("prime-credit")]
private static bool PrimeCredit(LoanApplication application)
=> application.CreditScore >= 700;
}
```

The generator emits a static factory returning `SpecificationRegistry<LoanApplication>`. Generated registries are useful when rule names and method signatures should be compile-time checked.

## IoC Usage

```csharp
services.AddSingleton(_ => LoanApprovalRules.Create());
services.AddSingleton<LoanApprovalService>();
```

The example in `docs/examples/loan-approval-specifications.md` shows a complete importable `IServiceCollection` integration.
122 changes: 122 additions & 0 deletions src/PatternKit.Core/Application/Specification/Specification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
namespace PatternKit.Application.Specification;

/// <summary>
/// Represents a business rule that can decide whether a candidate satisfies a named condition.
/// </summary>
/// <typeparam name="T">Candidate type evaluated by the rule.</typeparam>
public interface ISpecification<in T>
{
/// <summary>Evaluates the candidate against the rule.</summary>
bool IsSatisfiedBy(T candidate);
}

/// <summary>
/// Fluent specification implementation with composition helpers for domain rules.
/// </summary>
/// <typeparam name="T">Candidate type evaluated by the rule.</typeparam>
public sealed class Specification<T> : ISpecification<T>
{
private readonly Func<T, bool> _predicate;

private Specification(string name, Func<T, bool> predicate)
{
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Specification name is required.", nameof(name))
: name;
_predicate = predicate ?? throw new ArgumentNullException(nameof(predicate));
}

/// <summary>Human-readable rule name used by registries and diagnostics.</summary>
public string Name { get; }

/// <summary>Creates a specification from a predicate.</summary>
public static Specification<T> Where(string name, Func<T, bool> predicate) => new(name, predicate);

/// <summary>Creates a specification that accepts every candidate.</summary>
public static Specification<T> All(string name = "all") => new(name, static _ => true);

/// <summary>Creates a specification that rejects every candidate.</summary>
public static Specification<T> None(string name = "none") => new(name, static _ => false);

/// <inheritdoc />
public bool IsSatisfiedBy(T candidate) => _predicate(candidate);

/// <summary>Returns this specification as a predicate delegate.</summary>
public Func<T, bool> ToPredicate() => _predicate;

/// <summary>Composes this specification with another specification using logical AND.</summary>
public Specification<T> And(ISpecification<T> other, string? name = null)
{
if (other is null)
throw new ArgumentNullException(nameof(other));

return new Specification<T>(name ?? $"{Name}.and", candidate => IsSatisfiedBy(candidate) && other.IsSatisfiedBy(candidate));
}

/// <summary>Composes this specification with another specification using logical OR.</summary>
public Specification<T> Or(ISpecification<T> other, string? name = null)
{
if (other is null)
throw new ArgumentNullException(nameof(other));

return new Specification<T>(name ?? $"{Name}.or", candidate => IsSatisfiedBy(candidate) || other.IsSatisfiedBy(candidate));
}

/// <summary>Negates this specification.</summary>
public Specification<T> Not(string? name = null)
=> new(name ?? $"{Name}.not", candidate => !IsSatisfiedBy(candidate));
}

/// <summary>
/// Named collection of specifications for application services and IoC registrations.
/// </summary>
/// <typeparam name="T">Candidate type evaluated by the registered rules.</typeparam>
public sealed class SpecificationRegistry<T>
{
private readonly IReadOnlyDictionary<string, ISpecification<T>> _specifications;

private SpecificationRegistry(IReadOnlyDictionary<string, ISpecification<T>> specifications)
=> _specifications = specifications;

/// <summary>Registered specification names.</summary>
public IReadOnlyCollection<string> Names => _specifications.Keys.ToArray();
Comment on lines +77 to +82

/// <summary>Creates a fluent registry builder.</summary>
public static Builder Create() => new();

/// <summary>Gets a specification by name.</summary>
public ISpecification<T> Get(string name)
{
if (!_specifications.TryGetValue(name, out var specification))
throw new KeyNotFoundException($"Specification '{name}' is not registered.");

return specification;
}

/// <summary>Evaluates a named specification against a candidate.</summary>
public bool IsSatisfiedBy(string name, T candidate) => Get(name).IsSatisfiedBy(candidate);

/// <summary>Builds a named specification registry.</summary>
public sealed class Builder
{
private readonly Dictionary<string, ISpecification<T>> _specifications = new(StringComparer.Ordinal);

/// <summary>Adds a specification to the registry.</summary>
public Builder Add(string name, ISpecification<T> specification)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Specification name is required.", nameof(name));

_specifications[name] = specification ?? throw new ArgumentNullException(nameof(specification));
return this;
}

/// <summary>Adds a predicate-backed specification to the registry.</summary>
public Builder Add(string name, Func<T, bool> predicate)
=> Add(name, Specification<T>.Where(name, predicate));

/// <summary>Builds an immutable registry snapshot.</summary>
public SpecificationRegistry<T> Build()
=> new(new Dictionary<string, ISpecification<T>>(_specifications, StringComparer.Ordinal));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PatternKit.Application.Specification;
using PatternKit.Behavioral.Chain;
using PatternKit.Behavioral.Interpreter;
using PatternKit.Behavioral.Strategy;
Expand All @@ -26,6 +27,7 @@
using PatternKit.Examples.PrototypeDemo;
using PatternKit.Examples.ProxyDemo;
using PatternKit.Examples.Singleton;
using PatternKit.Examples.SpecificationDemo;
using PatternKit.Examples.Strategies.Coercion;
using PatternKit.Examples.Strategies.Composed;
using PatternKit.Examples.TemplateDemo;
Expand Down Expand Up @@ -101,6 +103,7 @@ public sealed record GeneratedReliabilityPipelineExample(ReliabilityExampleRunne
public sealed record ResilientCheckoutMailboxesExample(Func<CheckoutRequest, CheckoutServices, CheckoutResult> Run);
public sealed record MessagingBackplaneFacadeExample(Func<CancellationToken, ValueTask<BackplaneDemoSummary>> RunAsync);
public sealed record GeneratedInterpreterRulesExample(Interpreter<InterpreterRulesDemo.PricingContext, decimal> Pricing, Interpreter<InterpreterRulesDemo.PricingContext, bool> Eligibility);
public sealed record LoanApprovalSpecificationsExample(SpecificationRegistry<LoanApprovalSpecificationDemo.LoanApplication> Registry, LoanApprovalService Service);
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 @@ -147,6 +150,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddResilientCheckoutMailboxesExample()
.AddMessagingBackplaneFacadeExample()
.AddGeneratedInterpreterRulesExample()
.AddLoanApprovalSpecificationsExample()
.AddPrototypeGameCharacterFactoryExample()
.AddProxyPatternDemonstrationsExample()
.AddFlyweightGlyphCacheExample()
Expand Down Expand Up @@ -437,6 +441,15 @@ public static IServiceCollection AddGeneratedInterpreterRulesExample(this IServi
return services.RegisterExample<GeneratedInterpreterRulesExample>("Generated Interpreter Rules", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddLoanApprovalSpecificationsExample(this IServiceCollection services)
{
services.AddLoanApprovalSpecifications();
services.AddSingleton<LoanApprovalSpecificationsExample>(sp => new(
sp.GetRequiredService<SpecificationRegistry<LoanApprovalSpecificationDemo.LoanApplication>>(),
sp.GetRequiredService<LoanApprovalService>()));
return services.RegisterExample<LoanApprovalSpecificationsExample>("Loan Approval Specifications", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

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 @@ -264,6 +264,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.GenericHost,
["Mediator", "Dispatcher", "CQRS"],
["command/query separation", "source-generated dispatcher", "DI composition"]),
Descriptor(
"Loan Approval Specifications",
"src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs",
"test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs",
"docs/examples/loan-approval-specifications.md",
ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection,
["Specification"],
["composable business rules", "source-generated registry", "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 @@ -543,7 +543,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"docs/examples/cqrs-dispatcher.md",
"src/PatternKit.Examples/Messaging/CqrsPatternExample.cs",
"test/PatternKit.Examples.Tests/Messaging/CqrsPatternExampleTests.cs",
["fluent mediator command/query separation", "generated dispatcher", "DI-importable CQRS example"])
["fluent mediator command/query separation", "generated dispatcher", "DI-importable CQRS example"]),
Pattern("Specification", PatternFamily.ApplicationArchitecture,
"docs/patterns/application/specification.md",
"src/PatternKit.Core/Application/Specification/Specification.cs",
"test/PatternKit.Tests/Application/Specification/SpecificationTests.cs",
"docs/generators/specification.md",
"src/PatternKit.Generators/Specification/SpecificationGenerator.cs",
"test/PatternKit.Generators.Tests/SpecificationGeneratorTests.cs",
null,
"docs/examples/loan-approval-specifications.md",
"src/PatternKit.Examples/SpecificationDemo/LoanApprovalSpecificationDemo.cs",
"test/PatternKit.Examples.Tests/SpecificationDemo/LoanApprovalSpecificationDemoTests.cs",
["fluent specification composition", "generated specification registry", "DI-importable loan approval example"])
];

public IReadOnlyList<PatternCoverageDescriptor> Patterns => Items;
Expand Down
Loading
Loading