Skip to content

Commit b3355df

Browse files
authored
Merge pull request #12 from zooper-lib/feature/guards-fix
Guards fix
2 parents edb6a68 + ec21f7a commit b3355df

15 files changed

Lines changed: 909 additions & 175 deletions

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`Railway.Create()` — two-phase builder factory** that enforces a clear separation between the
13+
guard/validation phase and the step execution phase at the type-system level
14+
- `Railway.Create(factory, selector, guards, steps)` — with guards
15+
- `Railway.Create(factory, selector, steps)` — without guards (convenience overload)
16+
- Parameterless variants (`Func<TPayload>` factory) for railways with no request input
17+
- New `RailwayGuardBuilder<...>` — only exposes `Guard()` and `Validate()`
18+
- New `RailwayStepsBuilder<...>` — only exposes `Do()`, `DoIf()`, `DoAll()`, `Group()`,
19+
`WithContext()`, `Detach()`, `Parallel()`, `ParallelDetached()`, `Finally()`, and `Build()`
20+
21+
### Fixed
22+
23+
- **Registration-order bug**: `Group()`, `WithContext()`, `Detach()`, `Parallel()`, and
24+
`ParallelDetached()` steps now execute in the exact order they were registered, interleaved
25+
correctly with `Do()` steps. Previously all `Do()` steps ran before all feature steps
26+
regardless of registration order.
27+
28+
### Deprecated
29+
30+
- `RailwayBuilder<TRequest, TPayload, TSuccess, TError>` — use `Railway.Create()` instead
31+
- `RailwayBuilderFactory` — use `Railway.Create()` instead
32+
1033
## [3.4.1] - 2026-03-21
1134

1235
### Changed

README.md

Lines changed: 109 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,27 @@ dotnet add package Zooper.Bee
3232

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

3939
// Selector function that creates the success result from the final payload
40-
payload => new SuccessResult { ProcessedData = payload.Data }
41-
)
42-
.Validate(request =>
43-
{
44-
// Validate the request
45-
if (string.IsNullOrEmpty(request.Data))
46-
return Option<ErrorResult>.Some(new ErrorResult { Message = "Data is required" });
40+
selector: payload => new SuccessResult { ProcessedData = payload.Data },
4741

48-
return Option<ErrorResult>.None;
49-
})
50-
.Do(payload =>
51-
{
52-
// Process the payload
53-
payload.Data = payload.Data.ToUpper();
54-
return Either<ErrorResult, Payload>.FromRight(payload);
55-
})
56-
.Build();
42+
// Step execution phase
43+
steps: s => s
44+
.Validate(request =>
45+
{
46+
if (string.IsNullOrEmpty(request.Data))
47+
return Option<ErrorResult>.Some(new ErrorResult { Message = "Data is required" });
48+
return Option<ErrorResult>.None;
49+
})
50+
.Do(payload =>
51+
{
52+
payload.Data = payload.Data.ToUpper();
53+
return Either<ErrorResult, Payload>.FromRight(payload);
54+
})
55+
);
5756

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

7069
## Building Railways
7170

71+
Railways are created with `Railway.Create()`, which takes two separate configuration lambdas:
72+
73+
- **`guards`** — optional; declares guards and validations that run before the payload is created
74+
- **`steps`** — required; declares all activities that transform the payload
75+
76+
This two-phase separation makes it structurally impossible to mix guard registration with step
77+
registration. `Guard()` and `Validate()` are not available inside `steps`, and `Do()`/`Group()`/etc.
78+
are not available inside `guards`.
79+
80+
```csharp
81+
var railway = Railway.Create<Request, Payload, Success, Error>(
82+
factory: request => new Payload(request),
83+
selector: payload => new Success(payload.Result),
84+
guards: g => g
85+
.Guard(request => /* auth check */)
86+
.Validate(request => /* input validation */),
87+
steps: s => s
88+
.Do(payload => /* step 1 */)
89+
.Group(null, g => g
90+
.Do(payload => /* step 2a */)
91+
.Do(payload => /* step 2b */))
92+
.Do(payload => /* step 3 */)
93+
);
94+
```
95+
96+
When no guards are needed, omit the `guards` parameter:
97+
98+
```csharp
99+
var railway = Railway.Create<Request, Payload, Success, Error>(
100+
factory: request => new Payload(request),
101+
selector: payload => new Success(payload.Result),
102+
steps: s => s
103+
.Do(payload => /* ... */)
104+
);
105+
```
106+
72107
### Validation
73108

74-
Validates the incoming request before processing begins.
109+
Validations run before any step and reject the request early when invalid.
75110

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

92127
### Guards
93128

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

97133
```csharp
98-
// Asynchronous guard
99-
.Guard(async (request, cancellationToken) =>
100-
{
101-
var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken);
102-
return isAuthorized ? Option<ErrorResult>.None : Option<ErrorResult>.Some(new ErrorResult());
103-
})
104-
105-
// Synchronous guard
106-
.Guard(request =>
107-
{
108-
var isAuthorized = CheckAuthorization(request);
109-
return isAuthorized ? Option<ErrorResult>.None : Option<ErrorResult>.Some(new ErrorResult());
110-
})
134+
guards: g => g
135+
// Asynchronous guard
136+
.Guard(async (request, cancellationToken) =>
137+
{
138+
var isAuthorized = await CheckAuthorizationAsync(request, cancellationToken);
139+
return isAuthorized
140+
? Either<ErrorResult, Unit>.FromRight(Unit.Value)
141+
: Either<ErrorResult, Unit>.FromLeft(new ErrorResult { Message = "Unauthorized" });
142+
})
143+
// Synchronous guard
144+
.Guard(request =>
145+
{
146+
var isAuthorized = CheckAuthorization(request);
147+
return isAuthorized
148+
? Either<ErrorResult, Unit>.FromRight(Unit.Value)
149+
: Either<ErrorResult, Unit>.FromLeft(new ErrorResult { Message = "Unauthorized" });
150+
})
111151
```
112152

113153
#### Benefits of Guards
114154

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

120160
### Activities
121161

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

392+
## Migration from `RailwayBuilder` to `Railway.Create()`
393+
394+
As of the latest version, `RailwayBuilder` and `RailwayBuilderFactory` are `[Obsolete]`.
395+
Use `Railway.Create()` instead.
396+
397+
### Before
398+
399+
```csharp
400+
var railway = new RailwayBuilder<Request, Payload, Success, Error>(
401+
request => new Payload(request),
402+
payload => new Success(payload.Result))
403+
.Guard(request => /* ... */)
404+
.Validate(request => /* ... */)
405+
.Do(payload => /* ... */)
406+
.Group(null, g => g.Do(payload => /* ... */))
407+
.Build();
408+
```
409+
410+
### After
411+
412+
```csharp
413+
var railway = Railway.Create<Request, Payload, Success, Error>(
414+
factory: request => new Payload(request),
415+
selector: payload => new Success(payload.Result),
416+
guards: g => g
417+
.Guard(request => /* ... */)
418+
.Validate(request => /* ... */),
419+
steps: s => s
420+
.Do(payload => /* ... */)
421+
.Group(null, g => g.Do(payload => /* ... */)));
422+
```
423+
352424
## Migration from Workflow to Railway
353425

354426
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.

Zooper.Bee.Tests/BranchWithLocalPayloadTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities(
196196

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

Zooper.Bee.Tests/RailwayWithContextTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ public async Task WithContext_LocalPayloadIsolated_NotAffectedByOtherActivities(
225225

226226
// Assert
227227
result.IsRight.Should().BeTrue();
228-
result.Right.ProcessingResult.Should().Be("Initial processing -> Main activity -> Context 1 -> Context 2");
228+
result.Right.ProcessingResult.Should().Be("Initial processing -> Context 1 -> Main activity -> Context 2");
229229
result.Right.FinalPrice.Should().Be(130.00m); // 100 + 10 + 20
230230
result.Right.CustomizationDetails.Should().Be("Context 1 customization + Context 2 customization");
231231
}

Zooper.Bee.Tests/Zooper.Bee.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFramework>net10.0</TargetFramework>
55
<LangVersion>latest</LangVersion>
66
<Nullable>enable</Nullable>
77
<IsPackable>false</IsPackable>

Zooper.Bee/Features/Context/ContextBuilder.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Context;
1515
/// <typeparam name="TError">The type of the error result</typeparam>
1616
public sealed class ContextBuilder<TRequest, TPayload, TLocalState, TSuccess, TError>
1717
{
18-
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
1918
private readonly Context<TPayload, TLocalState, TError> _context;
2019

21-
internal ContextBuilder(
22-
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
23-
Context<TPayload, TLocalState, TError> context)
20+
internal ContextBuilder(Context<TPayload, TLocalState, TError> context)
2421
{
25-
_workflow = workflow;
2622
_context = context;
2723
}
2824

Zooper.Bee/Features/Detached/DetachedBuilder.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Detached;
1515
/// <typeparam name="TError">The type of the error result</typeparam>
1616
public sealed class DetachedBuilder<TRequest, TPayload, TSuccess, TError>
1717
{
18-
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
1918
private readonly Detached<TPayload, TError> _detached;
2019

21-
internal DetachedBuilder(
22-
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
23-
Detached<TPayload, TError> detached)
20+
internal DetachedBuilder(Detached<TPayload, TError> detached)
2421
{
25-
_workflow = workflow;
2622
_detached = detached;
2723
}
2824

Zooper.Bee/Features/Group/GroupBuilder.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@ namespace Zooper.Bee.Features.Group;
1515
/// <typeparam name="TError">The type of the error result</typeparam>
1616
public sealed class GroupBuilder<TRequest, TPayload, TSuccess, TError>
1717
{
18-
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
1918
private readonly Group<TPayload, TError> _group;
2019

21-
internal GroupBuilder(
22-
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
23-
Group<TPayload, TError> group)
20+
internal GroupBuilder(Group<TPayload, TError> group)
2421
{
25-
_workflow = workflow;
2622
_group = group;
2723
}
2824

Zooper.Bee/Features/Parallel/ParallelBuilder.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel;
1212
/// <typeparam name="TError">The type of the error result</typeparam>
1313
public sealed class ParallelBuilder<TRequest, TPayload, TSuccess, TError>
1414
{
15-
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
1615
private readonly Parallel<TPayload, TError> _parallel;
1716

18-
internal ParallelBuilder(
19-
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
20-
Parallel<TPayload, TError> parallel)
17+
internal ParallelBuilder(Parallel<TPayload, TError> parallel)
2118
{
22-
_workflow = workflow;
2319
_parallel = parallel;
2420
}
2521

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

37-
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, group);
33+
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(group);
3834
groupConfiguration(groupBuilder);
3935

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

56-
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, group);
52+
var groupBuilder = new GroupBuilder<TRequest, TPayload, TSuccess, TError>(group);
5753
groupConfiguration(groupBuilder);
5854

5955
return this;

Zooper.Bee/Features/Parallel/ParallelDetachedBuilder.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@ namespace Zooper.Bee.Features.Parallel;
1212
/// <typeparam name="TError">The type of the error result</typeparam>
1313
public sealed class ParallelDetachedBuilder<TRequest, TPayload, TSuccess, TError>
1414
{
15-
private readonly RailwayBuilder<TRequest, TPayload, TSuccess, TError> _workflow;
1615
private readonly ParallelDetached<TPayload, TError> _parallelDetached;
1716

18-
internal ParallelDetachedBuilder(
19-
RailwayBuilder<TRequest, TPayload, TSuccess, TError> workflow,
20-
ParallelDetached<TPayload, TError> parallelDetached)
17+
internal ParallelDetachedBuilder(ParallelDetached<TPayload, TError> parallelDetached)
2118
{
22-
_workflow = workflow;
2319
_parallelDetached = parallelDetached;
2420
}
2521

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

37-
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, detached);
33+
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(detached);
3834
detachedConfiguration(detachedBuilder);
3935

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

56-
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(_workflow, detached);
52+
var detachedBuilder = new DetachedBuilder<TRequest, TPayload, TSuccess, TError>(detached);
5753
detachedConfiguration(detachedBuilder);
5854

5955
return this;

0 commit comments

Comments
 (0)