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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`Railway.Create()` — two-phase builder factory** that enforces a clear separation between the
guard/validation phase and the step execution phase at the type-system level
- `Railway.Create(factory, selector, guards, steps)` — with guards
- `Railway.Create(factory, selector, steps)` — without guards (convenience overload)
- Parameterless variants (`Func<TPayload>` factory) for railways with no request input
- New `RailwayGuardBuilder<...>` — only exposes `Guard()` and `Validate()`
- New `RailwayStepsBuilder<...>` — only exposes `Do()`, `DoIf()`, `DoAll()`, `Group()`,
`WithContext()`, `Detach()`, `Parallel()`, `ParallelDetached()`, `Finally()`, and `Build()`

### Fixed

- **Registration-order bug**: `Group()`, `WithContext()`, `Detach()`, `Parallel()`, and
`ParallelDetached()` steps now execute in the exact order they were registered, interleaved
correctly with `Do()` steps. Previously all `Do()` steps ran before all feature steps
regardless of registration order.

### Deprecated

- `RailwayBuilder<TRequest, TPayload, TSuccess, TError>` — use `Railway.Create()` instead
- `RailwayBuilderFactory` — use `Railway.Create()` instead

## [3.4.1] - 2026-03-21

### Changed
Expand Down
146 changes: 109 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,27 @@ dotnet add package Zooper.Bee

```csharp
// Define a simple railway
var railway = new RailwayBuilder<Request, Payload, SuccessResult, ErrorResult>(
var railway = Railway.Create<Request, Payload, SuccessResult, ErrorResult>(
// Factory function that creates the initial payload from the request
request => new Payload { Data = request.Data },
factory: request => new Payload { Data = request.Data },

// Selector function that creates the success result from the final payload
payload => new SuccessResult { ProcessedData = payload.Data }
)
.Validate(request =>
{
// Validate the request
if (string.IsNullOrEmpty(request.Data))
return Option<ErrorResult>.Some(new ErrorResult { Message = "Data is required" });
selector: payload => new SuccessResult { ProcessedData = payload.Data },

return Option<ErrorResult>.None;
})
.Do(payload =>
{
// Process the payload
payload.Data = payload.Data.ToUpper();
return Either<ErrorResult, Payload>.FromRight(payload);
})
.Build();
// Step execution phase
steps: s => s
.Validate(request =>
{
if (string.IsNullOrEmpty(request.Data))
return Option<ErrorResult>.Some(new ErrorResult { Message = "Data is required" });
return Option<ErrorResult>.None;
})
.Do(payload =>
{
payload.Data = payload.Data.ToUpper();
return Either<ErrorResult, Payload>.FromRight(payload);
})
);

// Execute the railway
var result = await railway.Execute(new Request { Data = "hello world" }, CancellationToken.None);
Expand All @@ -69,9 +68,45 @@ else

## Building Railways

Railways are created with `Railway.Create()`, which takes two separate configuration lambdas:

- **`guards`** — optional; declares guards and validations that run before the payload is created
- **`steps`** — required; declares all activities that transform the payload

This two-phase separation makes it structurally impossible to mix guard registration with step
registration. `Guard()` and `Validate()` are not available inside `steps`, and `Do()`/`Group()`/etc.
are not available inside `guards`.

```csharp
var railway = Railway.Create<Request, Payload, Success, Error>(
factory: request => new Payload(request),
selector: payload => new Success(payload.Result),
guards: g => g
.Guard(request => /* auth check */)
.Validate(request => /* input validation */),
steps: s => s
.Do(payload => /* step 1 */)
.Group(null, g => g
.Do(payload => /* step 2a */)
.Do(payload => /* step 2b */))
.Do(payload => /* step 3 */)
);
```

When no guards are needed, omit the `guards` parameter:

```csharp
var railway = Railway.Create<Request, Payload, Success, Error>(
factory: request => new Payload(request),
selector: payload => new Success(payload.Result),
steps: s => s
.Do(payload => /* ... */)
);
```

### Validation

Validates the incoming request before processing begins.
Validations run before any step and reject the request early when invalid.

```csharp
// Asynchronous validation
Expand All @@ -91,31 +126,36 @@ Validates the incoming request before processing begins.

### Guards

Guards allow you to define checks that run before a railway begins execution. They're ideal for authentication,
authorization, account validation, or any other requirement that must be satisfied before a railway can proceed.
Guards check whether the railway is allowed to execute at all — authentication,
authorization, feature flags, etc. They always run before any step, regardless of
where they appear in the `guards` lambda.

```csharp
// Asynchronous guard
.Guard(async (request, cancellationToken) =>
{
var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken);
return isAuthorized ? Option<ErrorResult>.None : Option<ErrorResult>.Some(new ErrorResult());
})

// Synchronous guard
.Guard(request =>
{
var isAuthorized = CheckAuthorization(request);
return isAuthorized ? Option<ErrorResult>.None : Option<ErrorResult>.Some(new ErrorResult());
})
guards: g => g
// Asynchronous guard
.Guard(async (request, cancellationToken) =>
{
var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken);
return isAuthorized
? Either<ErrorResult, Unit>.FromRight(Unit.Value)
: Either<ErrorResult, Unit>.FromLeft(new ErrorResult { Message = "Unauthorized" });
})
// Synchronous guard
.Guard(request =>
{
var isAuthorized = CheckAuthorization(request);
return isAuthorized
? Either<ErrorResult, Unit>.FromRight(Unit.Value)
: Either<ErrorResult, Unit>.FromLeft(new ErrorResult { Message = "Unauthorized" });
})
```

#### Benefits of Guards

- Guards run before creating the railway context, providing early validation
- They provide a clear separation between "can this railway run?" and the actual railway logic
- Guards run before the payload is created, providing the earliest possible short-circuit
- The `guards` phase is structurally separate from the `steps` phase — it is impossible to
accidentally register a guard after a step
- Common checks like authentication can be standardized and reused
- Failures short-circuit the railway, preventing unnecessary work

### Activities

Expand Down Expand Up @@ -349,6 +389,38 @@ services.AddRailways(lifetime: ServiceLifetime.Singleton);
6. Validate requests early to fail fast
7. Use contextual state to avoid passing too many parameters

## Migration from `RailwayBuilder` to `Railway.Create()`

As of the latest version, `RailwayBuilder` and `RailwayBuilderFactory` are `[Obsolete]`.
Use `Railway.Create()` instead.

### Before

```csharp
var railway = new RailwayBuilder<Request, Payload, Success, Error>(
request => new Payload(request),
payload => new Success(payload.Result))
.Guard(request => /* ... */)
.Validate(request => /* ... */)
.Do(payload => /* ... */)
.Group(null, g => g.Do(payload => /* ... */))
.Build();
```

### After

```csharp
var railway = Railway.Create<Request, Payload, Success, Error>(
factory: request => new Payload(request),
selector: payload => new Success(payload.Result),
guards: g => g
.Guard(request => /* ... */)
.Validate(request => /* ... */),
steps: s => s
.Do(payload => /* ... */)
.Group(null, g => g.Do(payload => /* ... */)));
```

## Migration from Workflow to Railway

As of the latest version, all `Workflow` classes have been renamed to `Railway` to better reflect the railway-oriented programming pattern used by the library. The old `Workflow` names are preserved as `[Obsolete]` shims for backward compatibility.
Expand Down
2 changes: 1 addition & 1 deletion Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities(

// Assert
result.IsRight.Should().BeTrue();
result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2");
result.Right.ProcessingResult.Should().Be("Initial processing -> Context 1 -> Main activity -> Context 2");
result.Right.FinalPrice.Should().Be(130.00m); // Base (100) + Context 1 (10) + Context 2 (20)
}

Expand Down
2 changes: 1 addition & 1 deletion Zooper.Bee.Tests/RailwayWithContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities(

// Assert
result.IsRight.Should().BeTrue();
result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2");
result.Right.ProcessingResult.Should().Be("Initial processing -> Context 1 -> Main activity -> Context 2");
result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20
result.Right.CustomizationDetails.Should().Be("Context 1 customization + Context 2 customization");
}
Expand Down
2 changes: 1 addition & 1 deletion Zooper.Bee.Tests/Zooper.Bee.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
6 changes: 1 addition & 5 deletions Zooper.Bee/Features/Context/ContextBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Context;
/// <typeparam name="TError">The type of the error result</typeparam>
public sealed class ContextBuilder<TRequest, TPayload, TLocalState, TSuccess, TError>
{
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
private readonly Context<TPayload, TLocalState, TError> _context;

internal ContextBuilder(
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
Context<TPayload, TLocalState, TError> context)
internal ContextBuilder(Context<TPayload, TLocalState, TError> context)
{
_workflow = workflow;
_context = context;
}

Expand Down
6 changes: 1 addition & 5 deletions Zooper.Bee/Features/Detached/DetachedBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Detached;
/// <typeparam name="TError">The type of the error result</typeparam>
public sealed class DetachedBuilder<TRequest, TPayload, TSuccess, TError>
{
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
private readonly Detached<TPayload, TError> _detached;

internal DetachedBuilder(
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
Detached<TPayload, TError> detached)
internal DetachedBuilder(Detached<TPayload, TError> detached)
{
_workflow = workflow;
_detached = detached;
}

Expand Down
6 changes: 1 addition & 5 deletions Zooper.Bee/Features/Group/GroupBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Group;
/// <typeparam name="TError">The type of the error result</typeparam>
public sealed class GroupBuilder<TRequest, TPayload, TSuccess, TError>
{
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
private readonly Group<TPayload, TError> _group;

internal GroupBuilder(
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
Group<TPayload, TError> group)
internal GroupBuilder(Group<TPayload, TError> group)
{
_workflow = workflow;
_group = group;
}

Expand Down
10 changes: 3 additions & 7 deletions Zooper.Bee/Features/Parallel/ParallelBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel;
/// <typeparam name="TError">The type of the error result</typeparam>
public sealed class ParallelBuilder<TRequest, TPayload, TSuccess, TError>
{
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
private readonly Parallel<TPayload, TError> _parallel;

internal ParallelBuilder(
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
Parallel<TPayload, TError> parallel)
internal ParallelBuilder(Parallel<TPayload, TError> parallel)
{
_workflow = workflow;
_parallel = parallel;
}

Expand All @@ -34,7 +30,7 @@ public ParallelBuilder<TRequest, TPayload, TSuccess, TError> Group(
var group = new Group<TPayload, TError>();
_parallel.Groups.Add(group);

var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, group);
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(group);
groupConfiguration(groupBuilder);

return this;
Expand All @@ -53,7 +49,7 @@ public ParallelBuilder<TRequest, TPayload, TSuccess, TError> Group(
var group = new Group<TPayload, TError>(condition);
_parallel.Groups.Add(group);

var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, group);
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(group);
groupConfiguration(groupBuilder);

return this;
Expand Down
10 changes: 3 additions & 7 deletions Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel;
/// <typeparam name="TError">The type of the error result</typeparam>
public sealed class ParallelDetachedBuilder<TRequest, TPayload, TSuccess, TError>
{
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
private readonly ParallelDetached<TPayload, TError> _parallelDetached;

internal ParallelDetachedBuilder(
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
ParallelDetached<TPayload, TError> parallelDetached)
internal ParallelDetachedBuilder(ParallelDetached<TPayload, TError> parallelDetached)
{
_workflow = workflow;
_parallelDetached = parallelDetached;
}

Expand All @@ -34,7 +30,7 @@ public ParallelDetachedBuilder<TRequest, TPayload, TSuccess, TError> Detached(
var detached = new Detached<TPayload, TError>();
_parallelDetached.DetachedGroups.Add(detached);

var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, detached);
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(detached);
detachedConfiguration(detachedBuilder);

return this;
Expand All @@ -53,7 +49,7 @@ public ParallelDetachedBuilder<TRequest, TPayload, TSuccess, TError> Detached(
var detached = new Detached<TPayload, TError>(condition);
_parallelDetached.DetachedGroups.Add(detached);

var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, detached);
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(detached);
detachedConfiguration(detachedBuilder);

return this;
Expand Down
Loading
Loading