diff --git a/README.md b/README.md index 6aa5465..7415f4a 100644 --- a/README.md +++ b/README.md @@ -13,107 +13,7 @@ PipeForge is a lightweight, composable pipeline framework for .NET. It makes ste PipeForge is available on [NuGet.org](https://www.nuget.org/packages/PipeForge/) and can be installed using a NuGet package manager or the .NET CLI. -## Usage - -Pipelines are designed to operate on specific class, referred to as the **context**. Multiple pipeline steps are created in code to operate on that context. Steps are annotated with an attribute indicating the order in which they should be executed. Finally, the pipeline runner is given an instance of the context to run against. - -The following example uses dependency injection, and is the recommended approach to using PipeForge. For more advanced scenarios, see the full [documentation](https://scottoffen.github.io/pipeforge). - -> [!NOTE] -> I'm suffixing my context class with the word `Context`, and my steps with the word `Step` for demonstration purposes only. - -### Create Your Context - -```csharp -public class SampleContext -{ - private readonly List _steps = new(); - - public void AddStep(string stepName) - { - if (string.IsNullOrWhiteSpace(stepName)) - { - throw new ArgumentException("Step name cannot be null or whitespace.", nameof(stepName)); - } - - _steps.Add(stepName); - } - - public int StepCount => _steps.Count; - - public override string ToString() - { - return string.Join("", _steps); - } -} -``` - -### Create Your Steps - -```csharp -[PipelineStep(1)] -public class HelloStep : PipelineStep -{ - public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) - { - context.AddStep("Hello"); - await next(context, cancellationToken); - } -} - -[PipelineStep(2)] -public class WorldStep : PipelineStep -{ - public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) - { - context.AddStep("World"); - await next(context, cancellationToken); - } -} - -[PipelineStep(3)] -public class PunctuationStep : PipelineStep -{ - public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) - { - context.AddStep("1"); - await next(context, cancellationToken); - } -} -``` - -### Use Dependency Injection - -The extension method will discover and register all steps for the given `T` context, as well as register an instance of `IPipelineRunner` that can be injected into services. - -```csharp -services.AddPipelineFor(); -``` - -### Execute Pipeline - -Get an instance of `IPipelineRunnere` from your dependency injection container. - -```csharp -public class SampleService -{ - private readonly IPipelineRunner _pipelineRunner; - - public SampleService(IPipelineRunner pipelineRunner) - { - _pipelineRunner = pipelineRunner; - } - - public async Task RunPipeline(SampleContext context, CancellationToken? token = default) - { - await _pipelineRunner.ExecuteAsync(context, token); - var result = context.ToString(); - // result = "HelloWorld!" - } -} -``` - -## Support +## Usage and Support - Check out the project documentation https://scottoffen.github.io/pipeforge. @@ -123,6 +23,21 @@ public class SampleService - **Issues created to ask "how to" questions will be closed.** +## Use Cases + +While PipeForge is fundamentally a pipeline framework, it can also serve as the foundation for higher-level workflows. These workflows are built by composing individual pipeline steps that handle branching, retries, fallbacks, and decision logic - making it ideal for orchestrating complex processes like AI chains, data enrichment, or multi-stage validation. + +| | | +|-|-| +| Game Loop and Simulation Ticks | Model turn-based or tick-based game logic using structured steps for input handling, state updates, AI, and rule enforcement. Ideal for simulations, server-side logic, or deterministic turn resolution. | +| Middleware-Style Request Processing | Build lightweight, modular request pipelines similar to ASP.NET middleware, without requiring a full web host. | +| DevOps and Automation Pipelines | Express deployment checks, file transforms, and system hooks as repeatable, testable steps. | +| Security and Auditing Pipelines | Enforce policies, redact sensitive data, and log events in a structured, traceable flow. | +| ETL and Data Processing Pipelines | Break down validation, transformation, and persistence into clean, maintainable processing steps. | +| LLM and AI Workflows | Orchestrate prompt generation, model calls, fallback handling, and response parsing using composable pipelines. | +| Business Logic and Domain Orchestration | Replace brittle `if` chains and nested logic with clearly structured rule execution and orchestration flows. | + + ## Contributing We welcome contributions from the community! In order to ensure the best experience for everyone, before creating an issue or submitting a pull request, please see the [contributing guidelines](CONTRIBUTING.md) and the [code of conduct](CODE_OF_CONDUCT.md). Failure to adhere to these guidelines can result in significant delays in getting your contributions included in the project. diff --git a/docs/docs/describe-pipelines.md b/docs/docs/describe-pipelines.md index e4f129d..b92c3cc 100644 --- a/docs/docs/describe-pipelines.md +++ b/docs/docs/describe-pipelines.md @@ -1,25 +1,31 @@ --- -sidebar_position: 6 +sidebar_position: 8 title: Describing Pipelines --- -# Describing Pipelines +PipeForge provides built-in support for inspecting pipeline structure and behavior at runtime. This is especially useful for observability, testing, documentation, and UI tooling. -The `Describe()` method on `IPipelineRunner` is used to inspect and document the pipeline configuration at runtime. It returns a JSON string describing each registered pipeline step. +## Describe() -This method is useful for diagnostics, tooling, and dynamically displaying pipeline behavior in user interfaces or logs - but only if you've taken the time to add the necessary metadata to your step classes. +The `Describe()` method on `IPipelineRunner` outputs a JSON array representing each registered step. It includes key metadata such as name, order, and short-circuit configuration. -## Output Format +:::warning[Side Effects] -The JSON output contains an array of objects, each representing a pipeline step with the following fields: +This method **instantiates all steps**, triggering constructor injection and service resolution. Use it only when such side effects are acceptable. -* `Order`: The zero-based order in which the step appears in the pipeline -* `Name`: The value of the step's `Name` property -* `Description`: The step's `Description`, if defined -* `MayShortCircuit`: A boolean indicating whether the step might short-circuit execution -* `ShortCircuitCondition`: The value of the step's `ShortCircuitCondition`, if any +::: + +### Output Format -Example output: +| Field | Description | +| ----------------------- | ------------------------------------------------------- | +| `Order` | Step's position in the execution sequence | +| `Name` | Value of the step’s `Name` property | +| `Description` | Value of the step’s `Description`, if provided | +| `MayShortCircuit` | Indicates if the step may halt pipeline execution | +| `ShortCircuitCondition` | Explanation of the short-circuit trigger, if applicable | + +### Example Output ```json [ @@ -40,26 +46,59 @@ Example output: ] ``` -:::warning Warning - -The `Order` value in the JSON output represents the execution order of the steps. This value is assigned based on the order in which the steps will be executed, and it may differ from the `Order` specified in each step's `PipelineStep` attribute. +:::info[A Note About Order] -For example, if you have only two steps with `PipelineStep(Order = 3)` and `PipelineStep(Order = 4)`, the JSON output will show `Order` values of `0` and `1`, respectively - reflecting their relative execution sequence, not their original attribute values. +The `Order` shown in the output reflects the **actual runtime execution sequence**, not the numeric value assigned via the `[PipelineStep]` attribute. For example, two steps with attributes `Order = 3` and `Order = 4` will appear in the output as `Order = 0` and `Order = 1` if they are the only steps registered. ::: -## Instantiation Behavior +### Use Cases -Calling `Describe()` **will instantiate all steps** in the pipeline by accessing their `Lazy` wrappers. This may result in constructor injection or other side effects associated with instantiating the step class. Use this method only when you are prepared for that overhead. +* Logging pipeline structure for observability +* Generating runtime or admin UI documentation +* Verifying step order and metadata in unit tests -## Use Cases +If you need to inspect step configuration without triggering instantiation, consider capturing step metadata during registration or design time. -* Logging pipeline structure for observability -* Generating runtime documentation or UI -* Verifying step order and metadata during tests or builds +## DescribeSchema() + +The `DescribeSchema()` method returns a [JSON Schema v7](https://json-schema.org/specification.html) document that describes the metadata shape of a pipeline step. This is ideal for tools that visualize or validate pipeline structures. + +### When to Use -If you need to inspect step configuration without triggering instantiation, consider maintaining parallel metadata or restricting usage of `Describe()` to controlled environments. +* Exposing step definitions through APIs or dashboards +* Powering UI editors or metadata-driven configuration +* Documenting expected step shape for developers +* Validating configuration or orchestration input -## Conclusion +### Schema Fields + +| Property | Type | Description | +| ----------------------- | ------- | --------------------------------------------------- | +| `Order` | integer | Execution order of the step (inferred at runtime) | +| `Name` | string | Display name of the step | +| `Description` | string | Optional summary of the step’s purpose | +| `MayShortCircuit` | boolean | Indicates if the step may halt execution early | +| `ShortCircuitCondition` | string | Optional explanation of the short-circuit condition | + +Only `Order` is required. All other fields are optional and serve documentation, inspection, or visualization purposes. + +### Example Output + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PipelineStep", + "type": "object", + "properties": { + "Order": { "type": "integer", "description": "Execution order of the step (inferred)" }, + "Name": { "type": "string", "description": "Display name of the step" }, + "Description": { "type": "string", "description": "Optional description of the step" }, + "MayShortCircuit": { "type": "boolean", "description": "Whether the step may halt pipeline execution early" }, + "ShortCircuitCondition": { "type": "string", "description": "Explanation of the short-circuit condition, if any" } + }, + "required": ["Order"] +} +``` -Use `Describe()` to introspect your pipeline at runtime, but be aware that it will force full resolution of every registered step. +Use this schema to build validation layers, generate dynamic forms, or publish step definitions in API documentation. diff --git a/docs/docs/diagnostics.md b/docs/docs/diagnostics.md index a3db7fa..9464f83 100644 --- a/docs/docs/diagnostics.md +++ b/docs/docs/diagnostics.md @@ -1,5 +1,5 @@ --- -sidebar_position: 8 +sidebar_position: 9 title: Diagnostics --- @@ -43,7 +43,3 @@ listener.Subscribe(new Observer((name, payload) => * You can use this mechanism to generate timing metrics, debug issues, or visualize step execution. * Diagnostic events are low-overhead and safe to leave enabled in production. * Combine this with the `Describe()` method for a full picture of pipeline structure and execution behavior. - -## Conclusion - -PipeForge diagnostics give you deep visibility into pipeline execution with minimal effort. Whether you're debugging a failing step or building runtime instrumentation, the diagnostics hooks are ready to help. diff --git a/docs/docs/index.md b/docs/docs/index.md index 5e73de7..f88af36 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,15 +1,15 @@ --- -sidebar_position: 1 +sidebar_position: 0 title: Getting Started --- # Welcome to PipeForge -PipeForge is a lightweight, composable, lazy-instantiation pipeline framework for .NET. It simplifies step-based processing while remaining discoverable and testable. Inspired by middleware pipelines and modern dependency injection patterns, PipeForge gives you structured control over sequential logic flows - without the ceremony. +PipeForge is a lightweight, composable pipeline framework for .NET that makes step-based workflows easy to build, test, and maintain. With lazy instantiation and modern dependency injection, it gives you structured control over execution flow - without the heavy scaffolding of base classes, rigid lifecycles, or tightly coupled framework logic. Inspired by the simplicity of middleware pipelines, PipeForge favors clear, minimal structure over hidden complexity. -Pipelines operate on a specific class known as the **context**. Each pipeline step is a discrete unit of work, written in code and annotated with metadata indicating its order and (optional) filter. These steps are lazily instantiated and executed in sequence by the pipeline runner. +Each pipeline operates on a specific class called the **context**, which flows through each step in sequence. Steps are discrete units of work, written as regular code and annotated with metadata to define their order and optional filters. They’re lazily instantiated - only created when needed - and executed by the pipeline runner. -At any point, the pipeline can **short-circuit**, halting execution - and preventing the instantiation of any remaining steps. +At any point, a step can **short-circuit** the pipeline, halting further execution and preventing the instantiation of remaining steps. ## Sample Context @@ -18,7 +18,7 @@ For the purposes of this documentation, the following sample context will be use ```csharp title="SampleContext.cs" public class SampleContext { - private readonly List _steps = new(); + public readonly List Steps = new(); public void AddStep(string stepName) { @@ -27,22 +27,22 @@ public class SampleContext throw new ArgumentException("Step name cannot be null or whitespace.", nameof(stepName)); } - _steps.Add(stepName); + Steps.Add(stepName); } - public int StepCount => _steps.Count; + public int StepCount => Steps.Count; public override string ToString() { - return string.Join(",", _steps); + return string.Join(",", Steps); } } ``` This context allows us to: - Track pipeline progress via `AddStep()` +- Evaluate the order and number of step executions - Print step execution history using `ToString()` -- Assert how many steps ran using `StepCount` - Simulate errors by passing null or empty step names ## Installation diff --git a/docs/docs/manual-composition.md b/docs/docs/manual-composition.md index a782e61..71013ff 100644 --- a/docs/docs/manual-composition.md +++ b/docs/docs/manual-composition.md @@ -1,90 +1,98 @@ --- -sidebar_position: 7 +sidebar_position: 6 title: Manual Composition --- -# Manual Composition +PipeForge is designed to integrate seamlessly with dependency injection, but it's flexible enough to support manual composition. This is useful in testing scenarios, minimal environments, or when you need complete control over how the pipeline is configured and executed. -PipeForge is designed to integrate seamlessly with dependency injection, but it's flexible enough to support manual composition. This can be useful in testing scenarios, minimal environments, or when you need full control over pipeline configuration. - -The main drawbacks of this approach are that you'll be responsible for manually instantiating all dependencies for each step, and the resulting pipeline may not accurately reflect the behavior of your production configuration. +One tradeoff with this approach is that it bypasses automatic step discovery and attribute-based filtering. As a result, you'll need to manage step order and registration logic explicitly, which may lead to inconsistencies if your production pipeline uses assembly scanning. ## Creating a Pipeline -Manual pipeline composition is straightforward using the fluent API exposed by `PipelineBuilder`, which is returned from the static method `Pipeline.CreateFor()`. You can optionally pass an `ILoggerFactory` to enable logging during execution by the resulting `PipelineRunner`. +Manual composition is handled through the fluent API exposed by `PipelineBuilder`, which you can create via: -Add steps to the pipeline in the desired execution order using the fluent, chainable methods: +```csharp +var builder = Pipeline.CreateFor(); +``` -- `Add()` - for steps with a parameterless constructor -- `AddStep(Func factory)` - for steps that require constructor parameters +Steps are added in the desired execution order using the following chainable methods: -```csharp title="Steps Used" -private abstract class TestStep : PipelineStep -{ - public override async Task InvokeAsync( - SampleContext context, - PipelineDelegate next, - CancellationToken cancellationToken = default) - { - context.AddStep(Name); - await next(context, cancellationToken); - } -} +| Method | Description | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `WithStep()` | Registers a class that implements `IPipelineStep` | +| `WithStep(Func, CancellationToken, Task>)` | Registers an inline delegate step | -private class StepA : TestStep -{ - public StepA() => Name = "A"; -} +If your steps have dependencies, you can register them using `ConfigureServices(Action configure)`. This allows you to take full advantage of constructor injection even in a manually composed pipeline, using a lightweight internal service container managed by the builder. -private class StepB : TestStep -{ - public StepB() => Name = "B"; -} +### Example -private class StepC : TestStep -{ - public StepC() => Name = "C"; -} +The following example demonstrates how to manually build and execute a pipeline using a mix of class-based and inline delegate steps. It also shows how to register services required by steps through the builder's internal service container. Class definitions for the steps and services appear below. + +```csharp +var builder = Pipeline.CreateFor(); -private class StepD : TestStep +builder.ConfigureServices(services => { - public StepD(string name) => Name = name; -} -``` + services.AddSingleton(); +}); -Note that `StepD` lacks a parameterless constructor, which means it can't be added using `Add()`. Instead, you'll need to use `AddStep(Func)` to manually supply its dependencies - for example, if the step requires a configuration value, a logger, or a service instance. +builder.WithStep() + .WithStep() + .WithStep((context, next, cancellationToken) => + { + context.AddStep("InlineStep"); + return next(context, cancellationToken); + }); -```csharp title="Manual Pipeline Setup" -var stepName = "Hello"; -var pipeline = Pipeline.CreateFor() - .WithStep() - .WithStep() - .WithStep() - .WithStep(() => new StepD(stepName)) - .Build(); +var runner = builder.Build(); var context = new SampleContext(); -await pipeline.ExecuteAsync(context); +await runner.ExecuteAsync(context); -Console.WriteLine(context); -// Should be: -// A,B,C,Hello -``` +Console.WriteLine(context); // Outputs step history -## Advanced Scenarios -In more advanced scenarios, the factory delegate can create a scope and resolve dependencies from the scoped service provider before constructing the step. This is especially useful when the step relies on services with scoped lifetimes, such as per-request context or transient infrastructure components. For example: +public interface IMyDependency +{ + void DoSomething(); +} -``` -builder.WithStep(() => +public class MyDependency : IMyDependency { - using var scope = serviceProvider.CreateScope(); - var scopedProvider = scope.ServiceProvider; - var name = scopedProvider.GetRequiredService>().Value.Name; - return new StepD(name); -}); -``` + public void DoSomething() => Console.WriteLine("Dependency invoked"); +} + +public class StepA : PipelineStep +{ + private readonly IMyDependency _dependency; + + public StepA(IMyDependency dependency) + { + _dependency = dependency; + Name = "StepA"; + } -## Conclusion + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + _dependency.DoSomething(); + context.AddStep(Name); + await next(context, cancellationToken); + } +} + +public class StepB : PipelineStep +{ + public StepB() + { + Name = "StepB"; + } + + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep(Name); + await next(context, cancellationToken); + } +} +``` -Manual composition allows you to build and run a pipeline without relying on a DI container. While this approach is less common for production use, it provides maximum control for configuration, **testing** and minimal-host scenarios. \ No newline at end of file +This example shows how to compose a simple pipeline with both concrete and delegate-based steps, and then execute it manually. diff --git a/docs/docs/pipeline-registration.md b/docs/docs/pipeline-registration.md new file mode 100644 index 0000000..03ba708 --- /dev/null +++ b/docs/docs/pipeline-registration.md @@ -0,0 +1,98 @@ +--- +sidebar_position: 4 +title: Pipeline Registration +--- + +# Pipeline Registration + +PipeForge uses dependency injection to resolve both pipeline steps and their runners, and leverages lazy instantiation to avoid constructing steps that will never be executed - either due to short-circuiting, cancellation tokens, or runtime exceptions. + +This section describes how to register pipelines using the built-in extension methods provided by PipeForge. + +## Register a Pipeline + +The most common way to register a pipeline is by using the `AddPipeline` extension methods on `IServiceCollection`. These methods automatically discover and register all pipeline steps for a given context and step interface by scanning the provided assemblies. An appropriate runner is also registered, allowing you to resolve and execute the pipeline easily. + +### Method Signature + +```csharp +public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + string[]? filters) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner +{ } +``` + +### Parameters + +| Parameter | Required |Description | +| ------------------ | :------: |----------------------------------------------------------------------------------------------------------- | +| `TContext` | ✅ | The context class shared across all steps in the pipeline. | +| `TStepInterface` | | The interface used to identify pipeline steps. Defaults to `IPipelineStep`. | +| `TRunnerInterface` | | The interface used to resolve the pipeline runner. Defaults to `IPipelineRunner`.| +| `assemblies` | | Assemblies to scan for steps. If not provided, `AppDomain.CurrentDomain` is used. | +| `lifetime` | | The DI lifetime for steps and the runner. Defaults to `ServiceLifetime.Transient`. | +| `filters` | | Optional filters to limit which steps are registered. | + + +### Examples + +Here are some examples of how to use the various overloads of `AddPipeline`: + +``` csharp +// Minimal registration using default step and runner interfaces +services.AddPipeline(); + +// Register with a custom step interface +services.AddPipeline(); + +// Register with a custom step and custom runner interface +services.AddPipeline(); + +// Register with specific assemblies and a scoped lifetime +services.AddPipeline( + new[] { typeof(MyStep).Assembly }, + ServiceLifetime.Scoped); + +// Register with multiple filters +services.AddPipeline( + new[] { typeof(MyStep).Assembly }, + new[] { "Development", "Testing" }); +``` + +## Register a Single Step + +If you prefer to manually register individual steps, or want fine-grained control over registration, use the `AddPipelineStep` extension method. + +:::danger[Warning] + +When registering steps individually: +- The `PipelineStep` attribute on the step is neither required nor used. +- No pipeline runner is registered. You will need to register it yourself. + +::: + +### Method Signature + +```csharp +public static IServiceCollection AddPipelineStep( + this IServiceCollection services, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TStep : class, TStepInterface + where TStepInterface : class, IPipelineStep +{ } +``` + +### Parameters + +| Parameter | Description | Required | +| ---------------- | ----------------------------------------------------------------------------------------------- | -------- | +| `TStep` | The concrete step class to register. | ✅ | +| `TStepInterface` | The interface used to register the step. Defaults to `IPipelineStep` if not provided. | ❌ | +| `lifetime` | The DI lifetime for the step. Defaults to `ServiceLifetime.Transient`. | ❌ | + +If `TStepInterface` is not provided, the method will attempt to infer the context type from `TStep`. diff --git a/docs/docs/pipeline-runners.md b/docs/docs/pipeline-runners.md new file mode 100644 index 0000000..590ef47 --- /dev/null +++ b/docs/docs/pipeline-runners.md @@ -0,0 +1,41 @@ +--- +sidebar_position: 3 +title: Pipeline Runners +--- + +A pipeline runner is responsible for executing a pipeline by resolving and invoking each registered step in order. It handles lazy instantiation, short-circuiting, cancellation, and exceptions during execution. + +## Runner Interfaces and Defaults + +All runners implement the `IPipelineRunner` interface, where `TStepInterface` must implement `IPipelineStep`. In most cases, `TStepInterface` is a custom interface used to identify which steps belong to a specific pipeline. + +If you're not using a custom step interface, you can use the convenience interface `IPipelineRunner`. This is just a shorthand for `IPipelineRunner>`. + +```csharp title="IPipelineRunner.cs" +IPipelineRunner + where T : class + where TStepInterface : IPipelineStep +{ } + +IPipelineRunner : IPipelineRunner> + where TContext : class +{ } +``` + +PipeForge provides default runner implementations for `IPipelineRunner` and `IPipelineRunner`, so you don't need to write a custom runner unless you want to customize behavior or manage dependency resolution differently. + +If you're using custom step interfaces (e.g. to support multiple pipelines), it can be helpful to define a matching custom runner. PipeForge provides a base class, `PipelineRunner`, to make this easy: + +```csharp +public interface ISampleContextRunner + : IPipelineRunner { } + +public class SampleContextRunner + : PipelineRunner, ISampleContextRunner { } +``` + +This approach allows you to resolve your runner via `ISampleContextRunner`, rather than repeating the full generic signature. When using PipeForge's registration extensions, your custom runner will be registered automatically - no manual wiring required. + +## Custom Runners + +If you need to extend the default behavior (e.g. to add logging, diagnostics, or custom instrumentation), you can create your own runner by implementing `IPipelineRunner` directly, and optionally extending `PipelineRunner`. \ No newline at end of file diff --git a/docs/docs/pipeline-steps.md b/docs/docs/pipeline-steps.md index e72e2c5..d428758 100644 --- a/docs/docs/pipeline-steps.md +++ b/docs/docs/pipeline-steps.md @@ -3,43 +3,57 @@ sidebar_position: 2 title: Pipeline Steps --- -# Pipeline Steps +Pipeline steps are the building blocks of a PipeForge pipeline. Each step performs a discrete unit of work and passes control to the next step in the sequence. Steps operate on a shared **context** object - a strongly typed class that carries data and state throughout the pipeline. -Each pipeline step operates on a strongly typed context, specified via a generic parameter. +PipeForge treats each step as an independent, discoverable component. You write them as plain classes, define their order and optional filters via attributes, and rely on constructor injection to bring in any needed services. -A pipeline step consists of a single method and four optional metadata properties. +Each step must implement `IPipelineStep`, which consists of one method and four metadata properties: -| Property / Method | Type | Description | -| ---------------------------- | -------- | ---------------------------------------------------------------------------------------- | -| `Name` | `string` | A human-readable name for the step. Useful for logging, diagnostics, and documentation. | -| `Description` | `string` | A brief description of what the step does. Primarily used for documentation and tooling. | -| `MayShortCircuit` | `bool` | Indicates whether this step may choose to stop pipeline execution early. | -| `ShortCircuitCondition` | `string` | Describes the condition under which this step may short-circuit. For documentation only. | -| `InvokeAsync(context, next)` | `Task` | Executes the step's logic. To continue execution, call the `next` delegate. | +| Member | Type | Description | +| --------------------------- | -------- | ----------------------------------------------------------------------------------------------- | +| `Name` | `string` | A human-readable name for the step. Useful for logging, diagnostics, and documentation. | +| `Description` | `string` | A brief description of what the step does. Useful for tooling and developer insight. | +| `MayShortCircuit` | `bool` | Indicates whether this step may intentionally stop pipeline execution early. | +| `ShortCircuitCondition` | `string` | Describes the condition under which this step might short-circuit. Used for documentation only. | +| `InvokeAsync(context, next)`| `Task` | Executes the step's logic. To continue the pipeline, call the `next` delegate. | -The optional metadata properties do not impact step registration or execution, but can be used to provide developer documentation about the step, and aide in troubleshooting. +The optional metadata properties do not affect execution - they exist solely to aid in diagnostics and documentation. :::tip[Tip] +Pipeline steps are just regular classes. You can inject dependencies into their constructors - making them fully compatible with your existing services, business logic, and data access layers. +::: -Because pipeline steps are plain classes, you can **inject dependencies** through their constructors - enabling full integration with your existing services, business logic, and data access layers. +## Extending `PipelineStep` -::: +While you can implement `IPipelineStep` directly, the recommended approach is to derive from the `PipelineStep` base class. This abstract class provides default implementations for the optional metadata properties (`Name`, `Description`, etc.), allowing you to override only what you need. +```csharp title="PipeForge.PipelineStep.cs" +public abstract class PipelineStep : IPipelineStep + where TContext : class +{ + public virtual string? Description { get; set; } = null; -## Extending `PipelineStep` + public virtual bool MayShortCircuit { get; set; } + + public virtual string Name { get; set; } = string.Empty; + + public virtual string? ShortCircuitCondition { get; set; } = null; -Using the `PipelineStep` abstract class that provides default implementations for the optional metadata properties of `IPipelineStep` is the recommended approach to creating pipeline steps. You can optionally set the metadata properties in the constructor. + public abstract Task InvokeAsync(TContext context, PipelineDelegate next, CancellationToken cancellationToken = default); +} +``` + +When extending this class, you can optionally assign metadata values in the constructor or directly in the property initializers. ```csharp title="AddToContextStep.cs" -public class AddToContextStep : PipelineStep +public class AddToContextStep : PipelineStep { public AddToContextStep() { - // Sets the Name property Name = "AddToContextStep"; } - public override async Task InvokeAsync(StepContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) { context.AddStep(Name); await next(context, cancellationToken); @@ -47,68 +61,147 @@ public class AddToContextStep : PipelineStep } ``` +Using a base class keeps your steps clean and focused, makes metadata easy to inspect at runtime, and improves testability. If you prefer, you can always define your own base class. + ## Short Circuiting Execution -Pipeline steps can short-circuit the pipeline execution by not calling the `next()` delegate that is passed to the method. +Pipeline steps can halt execution early by choosing *not* to call the `next()` delegate. This is known as **short-circuiting** - it prevents remaining steps from being instantiated or executed. + +Short-circuiting is useful when: +- A failure or validation check occurs and further processing should stop. +- A decision point is reached where later steps are unnecessary. +- Performance optimizations are needed for conditional flows. + +The `MayShortCircuit` property can be set to indicate that a step *may* short-circuit, and `ShortCircuitCondition` can be used to describe when and why - these are for documentation only. ```csharp title="ShortCircuitStep.cs" -public class ShortCircuitStep : PipelineStep +public class ShortCircuitStep : PipelineStep { public ShortCircuitStep() { - // Sets the Name property Name = "ShortCircuitStep"; + Description = "Stops execution if fewer than two steps have run."; + MayShortCircuit = true; + ShortCircuitCondition = "StepCount < 2"; } - public override async Task InvokeAsync(StepContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) { context.AddStep(Name); - // If context.Steps is less than 2, the pipeline won't continue execution. - if (context.Steps >= 2) - await next(context, cancellationToken); + // Stop execution early if not enough steps have run + if (context.StepCount < 2) return; + + await next(context, cancellationToken); } } ``` +In this example, the pipeline stops early unless at least two steps have been recorded in the context. Because steps are lazily instantiated, any remaining steps in the pipeline will not even be created. + ## Adding the `[PipelineStep]` Attribute -To be discoverable, each pipeline step must be decorated with the `[PipelineStep]` attribute. This attribute defines the order in which pipeline steps should execute. +To be automatically discovered, each pipeline step must be decorated with the `[PipelineStep]` attribute. This attribute defines the order in which the step should execute. + +Steps with lower order values run earlier in the pipeline. If two steps share the same order, their relative execution is undefined unless explicitly ordered another way. ```csharp title="Step1.cs" [PipelineStep(1)] public class Step1 : PipelineStep { - // This is the first step that will execute in the pipeline + public Step1() + { + Name = "Step1"; + Description = "This is the first step in the pipeline."; + } + + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep(Name); + await next(context, cancellationToken); + } } ``` :::tip[Tip] -- Multiple steps that are **not** using the same context will not be impacted by using the same order value. -- Multiple steps that **are** using the same context and have the same order value will be ordered arbitrarily. +- Steps that use different context types can reuse the same order value - ordering only applies within the same pipeline. +- Steps using the same context type and the same order value will be executed in arbitrary order. ::: -### Adding a Step Filter +### Optional Step Filters + +You can limit when a pipeline step is registered by adding one or more filter values to the [PipelineStep] attribute. Filters allow you to exclude steps from registration unless a matching filter is explicitly provided during discovery. -You can limit when a step will be registered by adding the `Filter` parameter to the `PipelineStep` attribute. This is useful to ensure that some steps will only be registered when the filter is applied during step discovery and registration. +This is useful for environment-based behavior (e.g. only include dev/test steps), tenant-specific logic, or feature flags. ```csharp title="Step2.cs" -[PipelineStep(2, "Development")] +[PipelineStep(2, "Development", "Testing")] public class Step2 : PipelineStep { - // This is the second step that will execute in the pipeline + public Step2() + { + Name = "Step2"; + Description = "This step only runs in development or testing filters."; + } + + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep(Name); + await next(context, cancellationToken); + } } ``` :::danger[Caution] - Steps **without** a `Filter` parameter will always be registered during the step discovery and registration process. -- Steps **with** `Filter` parameter will only be registered if the same value is passed to the [registration extension method](./step-discovery.md). +- Steps **with** `Filter` parameter will only be registered if a matching filter is passed to the [registration extension method](./pipeline-registration.md). + +::: + + +## Creating Custom Step Interfaces + +When building multiple pipelines (especially for the same context), it can be helpful to define a custom step interface for each one. This allows you to isolate steps to a specific pipeline, organize your code more clearly, and register and resolve only the relevant steps using dependency injection. + +To do this, you create a new interface that inherits from `IPipelineStep` and use it as the marker type for step registration and execution. + +```csharp title="ISampleContextStep.cs" +// Custom interface for steps in the SampleContext pipeline +public interface ISampleContextStep : IPipelineStep { } +``` + +Then, have your step class implement the custom interface: + +```csharp title="SampleContextStepA" +[PipelineStep(1)] +public class SampleContextStepA : PipelineStep, ISampleContextStep +{ + public SampleContextStepA() + { + Name = "A"; + Description = "A step in the SampleContext pipeline."; + } + + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep(Name); + await next(context, cancellationToken); + } +} +``` + +:::tip[I'm still extending `PipelineStep`] + +Extending `PipelineStep` is optional, but helpful - it lets you avoid implementing the entire interface manually. Pipeline discovery and registration will still work even if you inherit from a different base class or none at all. ::: -## Conclusion +When registering or resolving the pipeline, we'll use the custom interface as the step type. This pattern helps: +- Prevent cross-contamination between unrelated pipelines +- Simplify discovery and debugging +- Maintain clear architectural boundaries -Now that you've defined your pipeline steps, the next step is to discover and register them. \ No newline at end of file +You can reuse the same `SampleContext` type across different pipelines if needed - just define separate step interfaces to control which steps apply to each one, and then use the appropriate [registration extension method](./pipeline-registration.md). diff --git a/docs/docs/running-pipelines.md b/docs/docs/running-pipelines.md index 34150a3..f786d39 100644 --- a/docs/docs/running-pipelines.md +++ b/docs/docs/running-pipelines.md @@ -1,21 +1,25 @@ --- -sidebar_position: 4 +sidebar_position: 5 title: Running Pipelines --- -# Running Pipelines +Each runner executes the steps associated with a specific context type, in the order they were registered. If you registered steps individually, execution order follows registration order. If you used pipeline discovery, step order is determined by the `[PipelineStep]` attribute. -Once your pipeline steps are discovered and registered, you can run the pipeline by resolving and invoking an `IPipelineRunner` from the dependency injection container. +Once your pipeline has been registered, you can execute it by resolving a suitable `IPipelineRunner` from the dependency injection container. The type of runner you resolve depends on how the pipeline was registered: -Each `IPipelineRunner` executes the pipeline steps for a specific context type `T`, in the order defined by their `[PipelineStep]` attribute. +| Registration Method | Runner to Resolve | +| ----------------------------------------- | ------------------------------------------- | +| Registered with default step interface | `IPipelineRunner` | +| Registered with custom step interface | `IPipelineRunner` | +| Registered with a custom runner interface | e.g. `ISampleContextRunner` | ## Example Usage ```csharp title="Executing a Pipeline" [PipelineStep(1)] -public class StepA : PipelineStep +public class StepA : PipelineStep { - public override async Task InvokeAsync(StepContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) { context.AddStep("A1"); await next(context, cancellationToken); @@ -23,9 +27,9 @@ public class StepA : PipelineStep } [PipelineStep(2)] -public class StepB : PipelineStep +public class StepB : PipelineStep { - public override async Task InvokeAsync(StepContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) { context.AddStep("B2"); await next(context, cancellationToken); @@ -33,22 +37,22 @@ public class StepB : PipelineStep } [PipelineStep(3)] -public class StepC : PipelineStep +public class StepC : PipelineStep { - public override async Task InvokeAsync(StepContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) { context.AddStep("C3"); await next(context, cancellationToken); } } -var context = new SampleContext(); var services = new ServiceCollection(); -services.AddPipelineFor(); +services.AddPipeline(); var provider = services.BuildServiceProvider(); var runner = provider.GetRequiredService>(); +var context = new SampleContext(); await runner.ExecuteAsync(context); Console.WriteLine(context.ToString()); // e.g. "A1,B2,C3" @@ -61,11 +65,11 @@ Each registered pipeline step for `SampleContext` will be executed in order, unl - The pipeline is lazily instantiated. Only the steps needed for the current run will be resolved from the container. - If a step short-circuits (by not calling `next()`), remaining steps will **not** be resolved or executed. - Exceptions thrown in any step will bubble up unless explicitly handled inside the step. -- Unhandled exceptions during pipeline execution will be wrapped in a `PipelineExecutionException`, which preserves the original exception as an inner exception. +- Unhandled exceptions during pipeline execution will be wrapped in a `PipelineExecutionException`, which preserves the original exception as an inner exception. ## Stateless Runners -Pipeline runners are stateless and safe to reuse. You can execute the same runner multiple times with different contexts: +Pipeline runners are stateless and safe to reuse. Steps are resolved fresh from the container for each run, to honor service lifetime. You can execute the same runner multiple times with different contexts: ```csharp var first = new SampleContext(); @@ -74,7 +78,3 @@ await runner.ExecuteAsync(first); var second = new SampleContext(); await runner.ExecuteAsync(second); ``` - -## Conclusion - -Your pipeline is now fully operational. In the next section, you'll learn how to test pipelines and steps independently. diff --git a/docs/docs/step-discovery.md b/docs/docs/step-discovery.md deleted file mode 100644 index e9be8f4..0000000 --- a/docs/docs/step-discovery.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -sidebar_position: 3 -title: Step Discovery ---- - -# Step Discovery - -In order to ensure that pipeline steps are both registered correctly and registered in the correct order with the dependency injection container, use the provided extension method. This method scans for implementations of `IPipelineStep` that are decorated with the `[PipelineStep]` attribute. In addition to steps, this method will also register a pipeline runner of type `IPipelineRunner`. - -## Using the Extension Method - -When the extension method is called using only the type parameter `T`, it will scan all assemblies in the `AppDomain` for classes that implement `IPipelineStep` and have a `PipelineStep` attribute that does not specify a filter. It will then register all matching classes in the dependency injection container - along with an instance of `IPipelineRunner` - with a transient service lifetime. - -```csharp -services.AddPipelineFor(); -``` - -The extension method takes optional parameters to change the service lifetime or to include steps for a particular filter. - -```csharp -// Add services using a scoped service lifetime -services.AddPipelineFor(ServiceLifetime.Scoped); - -// Include steps with the "Development" filter -service.AddPipelineFor("Development"); - -// Use a scoped lifetime and include steps with the "Development" filter -service.AddPipelineFor(ServiceLifetime.Scoped, "Development"); -``` - -Using this service method will relieve you of the need to register each step individually. - -## Register Individual Steps - -For advanced scenarios, you can register individual pipeline steps directly. This method takes the step type (not the context type) as the generic parameter, and has an optional parameter for the desired service lifetime. If unspecified, the service lifetime will be transient. - -```csharp -public class ManuallyRegisterStep : PipelineStep -{ - // implementation -} - -services.AddPipelineStep(ServiceLifetime.Scoped); -``` - -:::tip Note - -Using this extension method will **not** register a corresponding instance of `IPipelineRunner`. - -::: - -## Conclusion - -With your steps discovered and registered, you're ready to run the pipeline. \ No newline at end of file diff --git a/docs/docs/testing-pipelines.md b/docs/docs/testing-pipelines.md index 559339f..65f72d2 100644 --- a/docs/docs/testing-pipelines.md +++ b/docs/docs/testing-pipelines.md @@ -1,5 +1,5 @@ --- -sidebar_position: 5 +sidebar_position: 7 title: Testing Pipelines --- @@ -71,54 +71,10 @@ public class ShortCircuitTest ## Testing the Full Pipeline -You can test the full pipeline by registering steps with a service provider and using `IPipelineRunner`. This is especially useful for verifying composition, ordering, and side effects. - -```csharp title="PipelineIntegrationTest.cs" -public class PipelineIntegrationTest -{ - private class StepA : PipelineStep - { - public StepA() => Name = "A"; - - public override Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) - { - context.AddStep(Name); - return next(context, cancellationToken); - } - } - - private class StepB : PipelineStep - { - public StepB() => Name = "B"; - - public override Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) - { - context.AddStep(Name); - return next(context, cancellationToken); - } - } - - [Fact] - public async Task PipelineExecutesStepsInOrder() - { - var services = new ServiceCollection(); - services.AddPipelineStep(); - services.AddPipelineStep(); - services.AddTransient, PipelineRunner>(); - - var provider = services.BuildServiceProvider(); - var runner = provider.GetRequiredService>(); - - var context = new SampleContext(); - await runner.ExecuteAsync(context); - - Assert.Equal("A,B", context.ToString()); - } -} -``` +You can test the full pipeline using [`PipelineBuilder`](./manual-composition.md). ## Summary * Steps are just regular classes and can be tested independently. * You control the `next` delegate to test full, partial, or short-circuited runs. -* The DI container can be configured in tests to simulate realistic pipeline composition. +* Use `PipelineBuilder` simulate realistic pipeline composition. diff --git a/docs/docs/use-cases.md b/docs/docs/use-cases.md new file mode 100644 index 0000000..2f06cde --- /dev/null +++ b/docs/docs/use-cases.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 1 +title: PipeForge Use Cases +--- + +PipeForge pipelines are lightweight, flexible, and built to support a wide range of structured workflows - far beyond traditional request processing. Here are some of the most common and powerful ways to use them. + +## 🎮 Game Loop and Simulation Ticks + +Structure turn-based or tick-based game logic using ordered steps for input handling, state updates, AI decisions, and rule enforcement. Each tick runs as a pipeline, keeping logic deterministic and cleanly separated. Great for simulations, turn-based games, and event-driven systems. + +## 🧩 Middleware-Style Request Processing + +Build modular request pipelines similar to ASP.NET middleware - without needing a full web host. Use PipeForge steps to handle routing, authorization, validation, transformation, and response formatting in console apps, background services, or minimal APIs. + +## 🛠️ DevOps and Automation Pipelines + +Automate builds, deployments, and environment setup using step-driven workflows. Each step can perform checks, run scripts, transform files, or trigger rollbacks. You get clear control flow, built-in error handling, and testable, reusable logic. + +## 🔐 Security and Auditing Pipelines + +Apply policies, redact data, and emit audit logs with structured steps. Keep security logic isolated from business concerns, and use filters to enable or disable behaviors based on environment or role. Steps can be reused across pipelines to ensure consistent enforcement. + +## 📊 ETL and Data Processing Pipelines + +Break ETL flows into clean steps for validation, transformation, and persistence. Pipelines are easy to test and extend with instrumentation, branching, and conditional logic - perfect for data-driven systems that need clarity and control. + +## 🤖 LLM and AI Workflows + +Orchestrate complex AI interactions with steps for prompt prep, model execution, fallback logic, and response parsing. PipeForge's lazy resolution and short-circuiting make it ideal for chaining models, retry logic, and structured output handling. + +## 🗂️ Business Logic and Domain Orchestration + +Replace brittle `if`/`else` trees with pipelines that express business behavior clearly and predictably. Run rules, apply policies, trigger side effects, or moderate content in well-defined steps. Ideal for decision trees, policy enforcement, and modular domain workflows. + +## 📬 Command and Event Handling + +Handle application commands or domain events with pipelines that validate input, apply rules, mutate state, or trigger additional actions. Works well with CQRS or event-driven systems. Use filters to target handlers based on event type or source. + +--- + +Whatever you're building, PipeForge helps you organize logic into pipelines that are clean, testable, and built to adapt. diff --git a/src/PipeForge.Tests/PipelineBuilderTests.cs b/src/PipeForge.Tests/PipelineBuilderTests.cs index 73efc75..a5a879d 100644 --- a/src/PipeForge.Tests/PipelineBuilderTests.cs +++ b/src/PipeForge.Tests/PipelineBuilderTests.cs @@ -49,15 +49,11 @@ public override async Task InvokeAsync(SampleContext context, PipelineDelegate(loggerFactory); + var builder = Pipeline.CreateFor(); // Test adding a service to the pipeline builder.ConfigureServices(services => diff --git a/src/PipeForge/Pipeline.cs b/src/PipeForge/Pipeline.cs index 2bb8a98..fee9e6b 100644 --- a/src/PipeForge/Pipeline.cs +++ b/src/PipeForge/Pipeline.cs @@ -16,7 +16,7 @@ public static class Pipeline /// /// /// - public static PipelineBuilder CreateFor(ILoggerFactory? loggerFactory = null) + public static PipelineBuilder CreateFor() where TContext : class { var contextType = typeof(TContext); diff --git a/src/PipeForge/PipelineBuilder.cs b/src/PipeForge/PipelineBuilder.cs index b4fc1ad..18aafdf 100644 --- a/src/PipeForge/PipelineBuilder.cs +++ b/src/PipeForge/PipelineBuilder.cs @@ -40,9 +40,10 @@ public PipelineBuilder ConfigureServices(Action co /// /// /// - public PipelineBuilder WithStep() where TStep : class, IPipelineStep + public PipelineBuilder WithStep(ServiceLifetime lifetime = ServiceLifetime.Transient) + where TStep : class, IPipelineStep { - _services.AddPipelineStep(); + _services.AddPipelineStep(lifetime); return this; } diff --git a/src/PipeForge/PipelineExecutionException.cs b/src/PipeForge/PipelineExecutionException.cs index 628068a..1ee2479 100644 --- a/src/PipeForge/PipelineExecutionException.cs +++ b/src/PipeForge/PipelineExecutionException.cs @@ -5,9 +5,9 @@ namespace PipeForge; /// /// Represents an exception thrown during the execution of a pipeline step for the specified context type /// -/// The type of context used in the pipeline +/// The type of context used in the pipeline [DebuggerDisplay("StepName = {StepName}, StepOrder = {StepOrder}, Message = {Message}")] -public class PipelineExecutionException : Exception +public class PipelineExecutionException : Exception { /// /// The name of the step where the exception occurred @@ -46,6 +46,6 @@ public PipelineExecutionException(string stepName, int stepOrder, string message Data["PipelineStepName"] = stepName; Data["PipelineStepOrder"] = stepOrder; - Data["PipelineContextType"] = typeof(T).FullName; + Data["PipelineContextType"] = typeof(TContext).FullName; } } diff --git a/src/PipeForge/PipelineRegistration.cs b/src/PipeForge/PipelineRegistration.cs index 10640bb..70929d8 100644 --- a/src/PipeForge/PipelineRegistration.cs +++ b/src/PipeForge/PipelineRegistration.cs @@ -110,7 +110,7 @@ public static bool RegisterRunner( return true; } - // 4. No implementation found, and the default is not valid — throw + // 4. Throw an exception when no implementation found, and the default is not valid logger?.LogWarning(MessageRunnerImplementationNotFound, runnerType.GetTypeName()); throw new InvalidOperationException(string.Format(MessageRunnerImplementationNotFound, runnerType.GetTypeName())); } diff --git a/src/PipeForge/PipelineStep.cs b/src/PipeForge/PipelineStep.cs index 78f98b6..17c9977 100644 --- a/src/PipeForge/PipelineStep.cs +++ b/src/PipeForge/PipelineStep.cs @@ -6,13 +6,13 @@ namespace PipeForge; public abstract class PipelineStep : IPipelineStep where TContext : class { - public string? Description { get; set; } = null; + public virtual string? Description { get; set; } = null; - public bool MayShortCircuit { get; set; } + public virtual bool MayShortCircuit { get; set; } - public string Name { get; set; } = string.Empty; + public virtual string Name { get; set; } = string.Empty; - public string? ShortCircuitCondition { get; set; } = null; + public virtual string? ShortCircuitCondition { get; set; } = null; public abstract Task InvokeAsync(TContext context, PipelineDelegate next, CancellationToken cancellationToken = default); } diff --git a/src/PipeForge/README.md b/src/PipeForge/README.md index d4a385c..70c9352 100644 --- a/src/PipeForge/README.md +++ b/src/PipeForge/README.md @@ -1,3 +1,20 @@ # PipeForge -PipeForge is a lightweight, composable pipeline framework for .NET. It makes step-based processing simple, discoverable, and testable. Inspired by middleware pipelines and modern dependency injection patterns, PipeForge gives you structured control over sequential logic flows - without the ceremony. \ No newline at end of file +PipeForge is a lightweight, composable pipeline framework for .NET. It makes step-based processing simple, discoverable, and testable. Inspired by middleware pipelines and modern dependency injection patterns, PipeForge gives you structured control over sequential logic flows - without the ceremony. + +Built on the [Chain of Responsibility pattern](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern), PipeForge promotes loosely coupled design by letting each step decide whether to handle, modify, pass along, or halt execution. This enables flexible control flow, simplified overrides, and easier extension points throughout your processing logic. + +[**Documentation**](https://scottoffen.github.io/pipeforge/) | [**Source Code**](https://github.com/scottoffen/pipeforge) + +## Features + +| | | +|-|-| +| **Dependency Injection** | Steps and services are registered via convenient discovery and registration extension to `IServiceCollection`, allowing resolution through `IServiceProvider`, enabling clean, testable, and composable pipelines. | +| **Asynchronous** | Steps are executed asynchronously with `CancellationToken` support for responsive and scalable operations. | +| **Minimal Config** | Define and run pipelines with minimal boilerplate - ideal for microservices, scripting, and modular business logic. | +| **Fluent Builder** | Configure services, add steps, and customize execution using a fluent API with strong IntelliSense support and minimal boilerplate. | +| **Multiple Pipelines** | Register and execute distinct pipelines that operate on the same context type, each with independent configuration and steps. | +| **Order and Filter** | Steps declare execution order and optional filter tags for conditional inclusion in specific pipelines. | +| **Short-Circuit** | Steps can choose to skip the remainder of the pipeline by not invoking the `next` delegate, enabling conditional flow control and performance optimization. | +| **Lazy Instantiation** | Steps are fetched from the container fresh for each pipeline run and only instantiated if execution reaches them. This avoids unnecessary allocations, honors scoped lifetimes, and improves performance - especially when pipelines exit early. | diff --git a/src/PipeForge/version.json b/src/PipeForge/version.json index 94e8a89..071e666 100644 --- a/src/PipeForge/version.json +++ b/src/PipeForge/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0-beta", + "version": "2.0.0", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"