This document provides detailed performance analysis of the Trellis library using BenchmarkDotNet.
- Overview
- Key Findings
- Benchmark Results
- Railway-Oriented Programming vs Imperative Style
- Bind Operations
- Map Operations
- Tap Operations
- Ensure Operations
- Match Operations
- MapOnFailure Operations
- Combine Operations
- Recover Operations
- Async Operations
- Maybe Operations
- Error Handling
- Value Object Operations
- Money Operations
- Specification Operations
- Actor / Authorization
- Running Benchmarks
- Interpreting Results
The Trellis library is designed with performance in mind. All benchmarks are run using BenchmarkDotNet with memory diagnostics enabled to track both execution time and memory allocations.
Test Environment:
- CPU: AMD Ryzen 9 9900X 4.40GHz (12 physical / 24 logical cores)
- OS: Windows 11 (25H2)
- .NET: 10.0.3 (SDK 10.0.103)
- BenchmarkDotNet: v0.15.8
- Job: ShortRun (3 iterations, 1 launch, 3 warmup)
- Memory Diagnostics: Enabled (Gen0, Gen1, Gen2, Allocations)
Railway-oriented programming adds ~4-5 nanoseconds overhead compared to imperative style, which is negligible in real-world applications.
Both ROP and imperative styles allocate the same amount of memory for equivalent operations, showing no additional allocation overhead from the abstraction.
Success path operations are highly optimized with minimal allocations and fast execution times. Most operations (Map, Tap, Bind) allocate zero bytes on success paths.
Error paths are also efficient, with proper error aggregation not causing significant performance degradation. Failure paths often have identical or better performance than success paths due to short-circuit optimizations.
Maybe<T> operations (HasValue, equality, GetHashCode) are effectively free — the JIT inlines them to < 1 ns with zero allocations. Actor.IsOwner is similarly inlined to ~0.01 ns.
Comparison of ROP style vs traditional if-style code for the same logic.
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|---|---|---|---|---|---|
| RopStyleHappy | 98.32 ns | 105.691 ns | 5.793 ns | 0.0176 | 296 B |
| IfStyleHappy | 93.86 ns | 58.829 ns | 3.225 ns | 0.0176 | 296 B |
| RopStyleSad | 65.63 ns | 5.942 ns | 0.326 ns | 0.0200 | 336 B |
| IfStyleSad | 75.08 ns | 40.925 ns | 2.243 ns | 0.0200 | 336 B |
| RopSample1 | 635.27 ns | 173.344 ns | 9.502 ns | 0.2298 | 3848 B |
| IfSample1 | 630.80 ns | 211.416 ns | 11.588 ns | 0.2298 | 3848 B |
Analysis:
- ROP adds ~4 ns overhead on happy path (~5% slower than imperative)
- Sad path is effectively equal — ROP is actually slightly faster in this run
- Memory allocations are identical between ROP and imperative styles (296 B happy, 336 B sad)
- Larger pipelines (RopSample1): ~4 ns overhead on ~635 ns total — effectively zero overhead
- The overhead is negligible compared to typical I/O operations (database, HTTP, etc.)
Example Code:
// ROP Style
FirstName.TryCreate("Xavier")
.Combine(EmailAddress.TryCreate("xavier@somewhere.com"))
.Finally(
ok => ok.Item1 + " " + ok.Item2,
error => error.Detail
);
// Imperative Style (equivalent logic)
var rFirstName = FirstName.TryCreate("Xavier");
var rEmailAddress = EmailAddress.TryCreate("xavier@somewhere.com");
if (rFirstName.IsSuccess && rEmailAddress.IsSuccess)
return rFirstName.Value + " " + rEmailAddress.Value;
Error? error = null;
if (rFirstName.IsFailure)
error = rFirstName.Error;
if (rEmailAddress.IsFailure)
error = error is null ? rEmailAddress.Error : error.Combine(rEmailAddress.Error);
return error!.Detail;Testing sequential operations with transformations.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Bind_SingleChain_Success | 4.85 ns | - | - |
| Bind_SingleChain_Failure | 3.75 ns | - | - |
| Bind_ThreeChains_AllSuccess | 14.79 ns | - | - |
| Bind_ThreeChains_FailAtFirst | 12.25 ns | - | - |
| Bind_ThreeChains_FailAtSecond | 34.65 ns | 0.0091 | 152 B |
| Bind_FiveChains_Success | 33.84 ns | - | - |
| Bind_TypeTransformation | 18.28 ns | 0.0024 | 40 B |
| Bind_WithComplexOperation_Success | 16.47 ns | - | - |
| Bind_WithComplexOperation_Failure | 12.40 ns | - | - |
Key Insights:
- Single Bind: Only ~5 ns overhead with zero allocations
- Linear scaling: ~5-7 ns per additional Bind operation
- Short-circuit on failure: Failed results propagate immediately without executing downstream functions
- Chaining efficiency: 5 sequential Binds take only 34 ns total
- Type transformations: Minimal overhead (18 ns) for int→string conversion
- Memory allocations only occur when creating error objects or boxing values
Testing value transformations without changing the Result context.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Map_SingleTransformation_Success | 3.24 ns | - | - |
| Map_SingleTransformation_Failure | 3.69 ns | - | - |
| Map_ThreeTransformations_Success | 12.13 ns | - | - |
| Map_ThreeTransformations_Failure | 12.01 ns | - | - |
| Map_TypeConversion_IntToString | 5.00 ns | - | - |
| Map_ComplexTransformation | 21.48 ns | 0.0048 | 80 B |
| Map_MathematicalOperations | 12.08 ns | - | - |
| Map_FiveTransformations_Success | 28.74 ns | - | - |
| Map_StringManipulation | 24.43 ns | 0.0048 | 80 B |
| Map_WithComplexCalculation | 13.59 ns | - | - |
| Map_ToComplexObject | 27.10 ns | 0.0086 | 144 B |
Key Insights:
- Extremely fast: Single transformation baseline at 3.2 ns with zero allocations
- Linear scaling: ~4-6 ns per additional map operation (3 maps = 12 ns, 5 maps = 29 ns)
- Type conversions minimal: Int→String adds only ~2 ns overhead (5.0 ns total)
- Complex transformations: Object creation adds allocations (80-144 B) but stays under 28 ns
- Most operations zero-allocation: Success paths typically allocate nothing
- Ideal for type conversions and simple transformations in hot paths
Testing side effects without changing the Result.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Tap_SingleAction_Success | 2.88 ns | - | - |
| Tap_SingleAction_Failure | 3.74 ns | - | - |
| Tap_ThreeActions_Success | 14.84 ns | 0.0038 | 64 B |
| Tap_ThreeActions_Failure | 14.45 ns | 0.0038 | 64 B |
| Tap_WithLogging_Success | 33.03 ns | 0.0038 | 64 B |
| TapError_OnFailure | 10.73 ns | - | - |
| TapError_OnSuccess | 11.82 ns | - | - |
| Tap_MixedWithMap_Success | 27.55 ns | - | - |
| Tap_ComplexSideEffect_Success | 16.15 ns | 0.0038 | 64 B |
| Tap_FiveActions_Success | 23.90 ns | 0.0076 | 128 B |
| Tap_WithBind_Success | 21.81 ns | - | - |
Key Insights:
- Near-zero overhead: Single action baseline at 2.9 ns with zero allocations
- Failure path fast: Failure is a no-op at 3.7 ns (slightly slower than success but still negligible)
- Low allocation: Tap chains allocate only 64 B for list-backed side effects
- Perfect for logging/auditing: 33 ns for realistic logging scenario is negligible
- Composes well: Mixed with Map (28 ns) and Bind (22 ns) efficiently
- Ideal for debugging, logging, caching, and notification scenarios without impacting main flow
Use Case:
await GetUserAsync(userId)
.TapAsync(user => _logger.LogInformation("Retrieved user {UserId}", user.Id))
.TapAsync(user => _cache.SetAsync(user.Id, user))
.TapErrorAsync(error => _logger.LogError("Failed to get user: {Error}", error));Testing validation/guard clauses.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Ensure_SinglePredicate_Pass | 12.06 ns | 0.0091 | 152 B |
| Ensure_SinglePredicate_Fail | 11.98 ns | 0.0091 | 152 B |
| Ensure_SinglePredicate_OnFailureResult | 11.62 ns | 0.0091 | 152 B |
| Ensure_ThreePredicates_AllPass | 54.83 ns | 0.0272 | 456 B |
| Ensure_ThreePredicates_FailAtSecond | 53.91 ns | 0.0272 | 456 B |
| Ensure_ComplexPredicate_Pass | 12.02 ns | 0.0091 | 152 B |
| Ensure_ComplexPredicate_Fail | 12.01 ns | 0.0091 | 152 B |
| Ensure_WithExpensiveValidation_Pass | 37.92 ns | 0.0091 | 152 B |
| Ensure_WithExpensiveValidation_Fail | 37.83 ns | 0.0091 | 152 B |
| Ensure_ComplexObject_MultipleRules | 47.71 ns | 0.0296 | 496 B |
| Ensure_FivePredicates_AllPass | 106.16 ns | 0.0454 | 760 B |
| Ensure_MixedWithMapAndBind | 39.66 ns | 0.0181 | 304 B |
Key Insights:
- Single validation: 12 ns with 152 B allocation for error object
- Pass vs Fail identical: Passing and failing have the same performance (12 ns each)
- Linear scaling: ~20 ns per additional predicate (3 = 55 ns, 5 = 106 ns)
- Complex predicates: Only ~0 ns overhead for complex business rules (12 ns vs 12 ns)
- Composes well: Mixed with Map and Bind at 40 ns (304 B allocation)
- Short-circuit optimization: Failed validations don't execute subsequent predicates
Use Case:
customer.CanBePromoted()
.Ensure(c => c.TotalPurchases > 1000, Error.Validation("Minimum purchase requirement"))
.Ensure(c => c.AccountAge > TimeSpan.FromDays(90), Error.Validation("Account age requirement"))
.Tap(c => c.Promote());Testing pattern matching on success/failure paths.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Match_Success | 3.32 ns | - | - |
| Match_Failure | 2.36 ns | - | - |
| Match_Success_TypePreserved | 2.50 ns | - | - |
| Match_Failure_TypePreserved | 2.50 ns | - | - |
| Switch_Success | 2.34 ns | - | - |
| Switch_Failure | 2.53 ns | - | - |
| Match_AfterPipeline_Success | 34.32 ns | 0.0114 | 192 B |
| Match_AfterPipeline_Failure | 25.85 ns | 0.0124 | 208 B |
Key Insights:
- Ultra-fast: Match at 2.4-3.3 ns with zero allocations for simple cases
- Switch even faster: 2.3 ns success path — likely JIT-inlined
- Pipeline integration: 26-34 ns when used at the end of a multi-step pipeline
- Zero-allocation: All simple Match/Switch operations allocate nothing
- Ideal for final result consumption at the end of ROP pipelines
Testing error transformation on the failure track.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| MapOnFailure_OnSuccess | 2.89 ns | - | - |
| MapOnFailure_OnFailure | 8.39 ns | 0.0072 | 120 B |
| MapOnFailure_ChangeErrorType | 8.03 ns | 0.0067 | 112 B |
| MapOnFailure_ChainedOnSuccess | 7.05 ns | - | - |
| MapOnFailure_ChainedOnFailure | 21.22 ns | 0.0158 | 264 B |
| MapOnFailure_InPipeline_Success | 20.31 ns | 0.0091 | 152 B |
| MapOnFailure_InPipeline_Failure | 30.62 ns | 0.0172 | 288 B |
Key Insights:
- No-op on success: 2.9 ns with zero allocation when result is successful
- Error transformation: 8 ns to transform an error with 120 B allocation for the new error
- Chaining: Two MapOnFailure calls on failure path take 21 ns (264 B)
- Perfect for translating domain errors to API-layer errors at composition boundaries
Testing parallel result aggregation for validation scenarios.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Combine_TwoResults_BothSuccess | 7.18 ns | - | - |
| Combine_TwoResults_FirstFailure | 6.08 ns | - | - |
| Combine_TwoResults_SecondFailure | 6.07 ns | - | - |
| Combine_TwoResults_BothFailure | 10.57 ns | 0.0019 | 32 B |
| Combine_ThreeResults_AllSuccess | 11.44 ns | - | - |
| Combine_ThreeResults_OneFailure | 11.16 ns | - | - |
| Combine_ThreeResults_TwoFailures | 14.09 ns | 0.0019 | 32 B |
| Combine_DifferentTypes_BothSuccess | 10.97 ns | - | - |
| Combine_DifferentTypes_OneFailure | 7.52 ns | - | - |
| Combine_FiveResults_AllSuccess | 40.53 ns | - | - |
| Combine_FiveResults_OneFailure | 53.98 ns | 0.0091 | 152 B |
| Combine_FiveResults_MultipleFailures | 288.52 ns | 0.1512 | 2536 B |
| Combine_ValueObjects_AllValid | 143.04 ns | 0.0286 | 480 B |
| Combine_ValueObjects_OneInvalid | 116.85 ns | 0.0219 | 368 B |
| Combine_ValueObjects_AllInvalid | 436.59 ns | 0.2160 | 3616 B |
| Combine_WithBind_AllSuccess | 27.17 ns | 0.0033 | 56 B |
| Combine_WithBind_OneFailure | 12.93 ns | - | - |
| Combine_WithUnit_Success | 12.44 ns | - | - |
| CombineAsync_TwoResults_BothSuccess | 24.63 ns | 0.0153 | 256 B |
| CombineAsync_ThreeResults_AllSuccess | 41.35 ns | 0.0206 | 344 B |
| Combine_ComplexObject_AllValid | 160.00 ns | 0.0286 | 480 B |
| Combine_ComplexObject_WithValidation | 196.16 ns | 0.0377 | 632 B |
Key Insights:
- Extremely fast: Two results combine in ~7 ns (success path)
- Linear scaling: ~8-10 ns per additional result
- Error aggregation overhead: ~4-7 ns when combining errors
- Value object validation: 143 ns for complete user validation (firstName, lastName, email)
- Async overhead: ~17 ns additional for async operations (dominated by Task machinery)
- Unit support:
CombinewithUnitat 12 ns — efficient for void-returning operations - Memory allocations only occur on failure paths for error aggregation
Use Case:
// Validate user registration
FirstName.TryCreate(request.FirstName)
.Combine(LastName.TryCreate(request.LastName))
.Combine(EmailAddress.TryCreate(request.Email))
.Combine(Password.TryCreate(request.Password))
.Bind((first, last, email, pwd) =>
User.TryCreate(first, last, email, pwd));Testing error recovery patterns.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| RecoverOnFailure_OnSuccess | 3.00 ns | - | - |
| RecoverOnFailure_OnFailure | 3.75 ns | - | - |
| RecoverOnFailure_OnFailure_WithErrorAccess | 3.77 ns | - | - |
| RecoverOnFailure_WithPredicate_Match | 4.10 ns | - | - |
| RecoverOnFailure_WithPredicate_NoMatch | 3.43 ns | - | - |
| RecoverOnFailure_WithPredicate_AndErrorAccess_Match | 4.22 ns | - | - |
| RecoverOnFailure_Chain_TwoLevels | 12.08 ns | 0.0024 | 40 B |
| RecoverOnFailure_Chain_ThreeLevels | 30.82 ns | 0.0114 | 192 B |
| RecoverOnFailure_WithComplexRecovery | 5.01 ns | - | - |
| RecoverOnFailure_Multiple_DifferentErrorTypes | 17.67 ns | - | - |
| RecoverOnFailure_WithExpensiveRecovery | 25.13 ns | - | - |
| RecoverOnFailure_MixedWithBind_Success | 13.45 ns | - | - |
| RecoverOnFailure_MixedWithBind_Failure | 17.40 ns | - | - |
| RecoverOnFailure_WithDefaultValue | 3.67 ns | - | - |
| RecoverOnFailure_TypeTransformation | 6.81 ns | 0.0024 | 40 B |
| RecoverOnFailure_NestedPredicates | 9.34 ns | - | - |
Key Insights:
- No-op on success: 3.0 ns with zero allocation — skips recovery entirely
- Fast recovery: Simple recovery at 3.8 ns, even with error access at 3.8 ns
- Predicate-based recovery: 4.1 ns when predicate matches, 3.4 ns when it doesn't
- Mostly zero-allocation: Only chained recovery and type transformations allocate
- Selective recovery: Filter by error type at 18 ns for multi-type dispatch
- Ideal for graceful degradation and fallback patterns
Testing asynchronous operation performance.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| BindAsync_SingleChain_Success | 17.49 ns | 0.0143 | 240 B |
| BindAsync_SingleChain_Failure | 14.71 ns | 0.0095 | 160 B |
| BindAsync_ThreeChains_Success | 55.15 ns | 0.0430 | 720 B |
| BindAsync_ThreeChains_FailAtSecond | 68.38 ns | 0.0473 | 792 B |
| BindAsync_FiveChains_Success | 96.04 ns | 0.0716 | 1200 B |
| BindAsync_TypeTransformation | 46.51 ns | 0.0339 | 568 B |
| MapAsync_SingleTransformation_Success | 16.33 ns | 0.0139 | 232 B |
| MapAsync_ThreeTransformations_Success | 51.44 ns | 0.0416 | 696 B |
| TapAsync_SingleAction_Success | 742.52 ns | 0.0286 | 482 B |
| TapAsync_ThreeActions_Success | 2,086.09 ns | 0.0839 | 1456 B |
| EnsureAsync_SinglePredicate_Pass | 24.27 ns | 0.0186 | 312 B |
| EnsureAsync_ThreePredicates_AllPass | 69.22 ns | 0.0464 | 776 B |
| Mixed_AsyncOperations_Success | 679.97 ns | 0.0687 | 1161 B |
| TaskResult_BindAsync_Success | 45.51 ns | 0.0382 | 640 B |
| RecoverOnFailureAsync_OnFailure | 17.08 ns | 0.0143 | 240 B |
| FinallyAsync_OnSuccess | 9.23 ns | 0.0086 | 144 B |
| BindAsync_WithDelay_Success | 15,608,179 ns | - | 848 B |
Key Insights:
- BindAsync/MapAsync: 16-17 ns for single operations (~12-13 ns overhead from async machinery)
- Linear scaling: Async chains scale at ~20 ns per operation (5 binds = 96 ns)
- TapAsync: Higher overhead (743 ns) due to awaitable side-effect patterns
- FinallyAsync: Very efficient at 9 ns for result consumption
- Real I/O dominates: The 15.6 ms
BindAsync_WithDelayshows async overhead is invisible vs real I/O - Overhead is dominated by async/await Task machinery, not by ROP abstractions
Use Case:
await GetStudentAsync(studentId)
.ParallelAsync(GetGradesAsync(studentId))
.ParallelAsync(GetAttendanceAsync(studentId))
.WhenAllAsync()
.BindAsync((student, grades, attendance) =>
GenerateReportAsync(student, grades, attendance));Testing optional value handling — Maybe<T> is a readonly struct.
| Method | Mean | Allocated |
|---|---|---|
| HasValue_WithValue | 0.003 ns | - |
| HasValue_Empty | 0.002 ns | - |
| HasNoValue_WithValue | 0.003 ns | - |
| HasNoValue_Empty | 0.005 ns | - |
| GetValueOrDefault_WithValue | 0.006 ns | - |
| GetValueOrDefault_Empty | 0.004 ns | - |
| TryGetValue_WithValue | 0.004 ns | - |
| TryGetValue_Empty | 0.007 ns | - |
| From_Value | 0.000 ns | - |
| None_Creation | 0.000 ns | - |
| ImplicitConversion_FromValue | 0.000 ns | - |
| ImplicitConversion_FromNull | 0.000 ns | - |
| Equality_BothWithSameValue | 0.001 ns | - |
| Equality_BothEmpty | 0.002 ns | - |
| Equality_OneEmptyOneWithValue | 0.012 ns | - |
| GetHashCode_WithValue | 0.012 ns | - |
| GetHashCode_Empty | 0.004 ns | - |
| ToString_WithValue | 0.000 ns | - |
| ToString_Empty | 0.178 ns | - |
| ToResult_WithValue | 2.49 ns | 40 B |
| ToResult_Empty | 2.92 ns | 40 B |
| Optional_WithNullValue | 0.000 ns | - |
| Optional_WithValue | 0.000 ns | - |
| Equals_WithObject | 0.010 ns | - |
| Equals_WithSameValue | 0.009 ns | - |
| CreateComplexMaybe | 19.83 ns | 40 B |
Key Insights:
- Effectively free: Most operations are < 1 ns — JIT-inlined to near-zero cost
- True zero-allocation: All basic operations (HasValue, equality, GetHashCode) allocate nothing
- readonly struct advantage: No boxing, no heap allocation, pure value-type semantics
- ToResult bridges: Only allocation point (40 B) when converting Maybe→Result
- Complex creation: Even
CreateComplexMaybewith string parsing is only 20 ns Maybe<T>is a genuine zero-cost abstraction for domain-level optionality
Testing error creation and aggregation.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| CreateValidationError_Simple | 10.50 ns | 0.0091 | 152 B |
| CreateNotFoundError | 1.72 ns | 0.0024 | 40 B |
| CreateUnauthorizedError | 1.75 ns | 0.0024 | 40 B |
| CreateConflictError | 1.77 ns | 0.0024 | 40 B |
| CreateUnexpectedError | 1.75 ns | 0.0024 | 40 B |
| CreateValidationError_MultipleFields | 319.10 ns | 0.1922 | 3216 B |
| CreateValidationError_SingleFieldMultipleMessages | 239.23 ns | 0.1373 | 2304 B |
| CombineErrors_TwoValidationErrors | 154.82 ns | 0.0989 | 1656 B |
| CombineErrors_ValidationAndNotFound | 25.64 ns | 0.0196 | 328 B |
| CombineErrors_ThreeValidationErrors | 319.82 ns | 0.2041 | 3416 B |
| CombineErrors_FiveErrors | 219.57 ns | 0.1347 | 2256 B |
| MergeValidationErrors_DifferentFields | 146.86 ns | 0.0966 | 1616 B |
| MergeValidationErrors_SameField | 125.93 ns | 0.0796 | 1336 B |
| MergeValidationErrors_Complex | 483.89 ns | 0.2794 | 4688 B |
| ErrorEquality_SameCode | 0.005 ns | - | - |
| ErrorEquality_DifferentCode | 0.193 ns | - | - |
| GetHashCode_ValidationError | 21.31 ns | - | - |
| GetHashCode_ComplexValidationError | 46.62 ns | - | - |
| ToString_SimpleError | 55.31 ns | 0.0396 | 664 B |
| ToString_ComplexValidationError | 94.33 ns | 0.0736 | 1232 B |
| ValidationError_WithFieldErrors | 30.49 ns | 0.0258 | 432 B |
| CreateErrorFromException | 4.75 ns | 0.0096 | 160 B |
| IsErrorType_Validation | 0.009 ns | - | - |
| IsErrorType_NotFound | 0.013 ns | - | - |
| CreateErrorChain_WithDetails | 9.69 ns | 0.0091 | 152 B |
Key Insights:
- Simple errors fast: NotFound/Unauthorized/Conflict at 1.7-1.8 ns (40 B)
- Validation errors heavier: 11 ns / 152 B for simple, 319 ns / 3216 B for multi-field
- Error equality free: Identity comparison at ~0.005 ns with zero allocations
- Error type checking free:
IsErrorTypeat 0.009-0.013 ns — JIT-inlined - Error aggregation: Combining 2 errors at 155 ns (1656 B), 5 errors at 220 ns (2256 B)
- Complex merge: Multi-field validation merge at 484 ns — still fast for validation scenarios
- Error paths are inherently "cold" — these allocations are acceptable for error reporting
Testing DDD ValueObject comparison and sorting.
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| CompareTo_Equal | 30.37 ns | 0.0048 | 80 B |
| CompareTo_Different | 20.84 ns | 0.0048 | 80 B |
| Sort_100_ValueObjects | 18,032 ns | 3.1433 | 53,064 B |
Key Insights:
- ValueObject.CompareTo: 30 ns with 80 B allocation (enumerator-based zip comparison)
- Early-exit on difference: Different values at 21 ns vs equal values at 30 ns
- Sorting 100 objects: ~18 µs (180 ns per comparison average)
- The v3 implementation is 2.1-2.5x faster and uses 2.4x fewer allocations than the previous
.ToArray()approach
Testing Money value object arithmetic (multi-currency with ISO 4217).
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| Create_Valid | 32.01 ns | 0.0067 | 112 B |
| Create_ZeroDecimalCurrency | 31.10 ns | 0.0067 | 112 B |
| Add_SameCurrency | 53.09 ns | 0.0114 | 192 B |
| Subtract_SameCurrency | 64.05 ns | 0.0114 | 192 B |
| Multiply_Decimal | 36.67 ns | 0.0067 | 112 B |
| Multiply_Integer | 32.14 ns | 0.0067 | 112 B |
| Divide_Decimal | 58.36 ns | 0.0067 | 112 B |
| Divide_Integer | 34.67 ns | 0.0067 | 112 B |
| Add_DifferentCurrency_Fails | 56.51 ns | 0.0181 | 304 B |
| Subtract_DifferentCurrency_Fails | 57.13 ns | 0.0186 | 312 B |
| IsGreaterThan | 21.64 ns | 0.0048 | 80 B |
| IsLessThan | 21.18 ns | 0.0048 | 80 B |
| Allocate_ThreeWay | 69.72 ns | 0.0138 | 232 B |
| Allocate_EvenSplit | 128.22 ns | 0.0167 | 280 B |
| ArithmeticPipeline | 142.44 ns | 0.0248 | 416 B |
Key Insights:
- Creation: 32 ns / 112 B for creating a Money value (includes currency validation)
- Arithmetic: 32-64 ns for math operations — same-currency adds are ~53 ns
- Currency safety: Cross-currency operations fail fast at 57 ns (304 B for error)
- Integer multiply faster: 32 ns vs 37 ns for decimal multiply
- Comparison: 21-22 ns / 80 B for greater-than / less-than checks
- Allocation: Banker's rounding three-way split at 70 ns, even split at 128 ns
- Full pipeline: Create → multiply → add → subtract at 142 ns (416 B)
Testing DDD Specification pattern (expression trees, composition).
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| IsSatisfiedBy_Simple_Pass | 328,558 ns | - | 5,768 B |
| IsSatisfiedBy_Simple_Fail | 340,006 ns | - | 5,768 B |
| IsSatisfiedBy_And_Pass | 420,440 ns | 0.4883 | 12,976 B |
| IsSatisfiedBy_And_Fail | 414,361 ns | 0.4883 | 12,976 B |
| IsSatisfiedBy_Or_Pass | 434,916 ns | 0.4883 | 12,976 B |
| IsSatisfiedBy_Or_Fail | 413,791 ns | 0.4883 | 12,976 B |
| IsSatisfiedBy_Not | 354,674 ns | 0.4883 | 8,456 B |
| IsSatisfiedBy_Complex | 643,375 ns | 0.9766 | 30,119 B |
| ToExpression_Simple | 268 ns | 0.0496 | 840 B |
| ToExpression_And | 863 ns | 0.1488 | 2,536 B |
| ToExpression_Complex | 2,056 ns | 0.3510 | 6,032 B |
| Filter_100_Orders | 350,095 ns | - | 6,650 B |
| Filter_100_Orders_Composed | 415,073 ns | 0.4883 | 13,192 B |
Key Insights:
IsSatisfiedByis expensive: 329 µs for a simple spec because it callsToExpression().Compile()every time- Composed specs: And/Or compositions at 414-435 µs (1.3x simple due to expression tree merging)
ToExpressionis fast: 268 ns for simple, 2.1 µs for complex 4-level compositions- Filtering: 350 µs for 100 records with simple spec — compilation dominates, not iteration
- Optimization opportunity: Cache compiled delegates if calling
IsSatisfiedByin hot loops - EF Core path is fast: When using
ToExpression()directly with LINQ, the expression tree is passed to EF Core without compilation — only the 268 ns–2.1 µs expression build cost applies
Testing Actor permission checking (HashSet-backed).
| Method | Mean | Gen0 | Allocated |
|---|---|---|---|
| HasPermission_Found | 4.80 ns | - | - |
| HasPermission_NotFound | 4.01 ns | - | - |
| HasPermission_Forbidden | 4.59 ns | - | - |
| HasPermission_Scoped | 15.34 ns | 0.0038 | 64 B |
| HasAllPermissions_AllPresent | 15.29 ns | - | - |
| HasAllPermissions_SomeMissing | 15.49 ns | 0.0043 | 72 B |
| HasAnyPermission_OnePresent | 10.91 ns | 0.0043 | 72 B |
| HasAnyPermission_NonePresent | 14.44 ns | 0.0043 | 72 B |
| HasPermission_LargeSet_50_Found | 6.30 ns | - | - |
| HasPermission_LargeSet_500_Found | 6.39 ns | - | - |
| HasPermission_LargeSet_500_NotFound | 3.34 ns | - | - |
| IsOwner_Match | 0.009 ns | - | - |
| IsOwner_NoMatch | 0.019 ns | - | - |
| Create_Simple | 16.85 ns | 0.0134 | 224 B |
Key Insights:
- O(1) permission lookup: 4.8 ns found, 4.0 ns not found — backed by HashSet
- Scales perfectly: 500 permissions still 6.4 ns — no degradation with set size
- IsOwner JIT-inlined: 0.009 ns — effectively a no-op (GUID comparison)
- Scoped permissions: 15 ns with 64 B allocation for string interpolation
- HasAll/HasAny: 11-15 ns — efficient for batch permission checking
- Actor creation: 17 ns / 224 B — lightweight for per-request construction
- Zero allocations for all simple permission checks
To run the benchmarks yourself:
# Run all benchmarks
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release
# Run specific benchmark
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release -- --filter *Combine*
# Run with specific options
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release -- --filter *ROP* --memoryBenchmark Classes:
BenchmarkROP— Core ROP vs imperative comparisonsBindBenchmarks— Sequential transformationsMapBenchmarks— Value transformationsTapBenchmarks— Side effectsEnsureBenchmarks— Validation operationsMatchBenchmarks— Pattern matchingMapOnFailureBenchmarks— Error transformationCombineBenchmarks— Result aggregationRecoverBenchmarks— Error recovery patternsAsyncBenchmarks— Asynchronous operationsMaybeBenchmarks— Optional value handlingErrorBenchmarks— Error creation and aggregationValueObjectBenchmarks— DDD value object comparisonMoneyBenchmarks— Multi-currency arithmeticSpecificationBenchmarks— DDD specification patternActorBenchmarks— Authorization permission checking
Execution Time:
- < 100 ns: Excellent — negligible overhead
- 100-500 ns: Very good — minimal impact
- 500-1000 ns: Good — reasonable for most scenarios
- > 1000 ns: Context-dependent — compare to your I/O operations
Memory Allocations:
- < 100 B: Excellent — minimal heap pressure
- 100-500 B: Very good — acceptable for most operations
- 500-1000 B: Good — watch for high-frequency operations
- > 1000 B: Context-dependent — consider pooling for hot paths
Typical Operation Costs:
- Database query: 1,000,000-10,000,000 ns (1-10 ms)
- HTTP request: 10,000,000-100,000,000 ns (10-100 ms)
- File I/O: 100,000-1,000,000 ns (0.1-1 ms)
- ROP overhead: 3-34 ns (0.000003-0.000034 ms)
Conclusion: The overhead from ROP is < 0.001% of typical I/O operations, making it negligible in real-world applications while providing significant benefits in code clarity, testability, and error handling.
- Use
Combinefor parallel validation — More efficient than sequential checks - Leverage short-circuiting — Failed results don't execute subsequent operations
- Prefer
MapoverBind— When you don't need to change the Result context - Use
ParallelAsync— For independent async operations - Cache compiled specs — If calling
IsSatisfiedByin hot loops, cache the compiled delegate - Don't over-optimize — Focus on I/O and business logic optimization first
The Trellis library provides negligible performance overhead while offering significant improvements in:
- Code clarity — Railway-oriented style is more readable
- Error handling — Explicit error propagation and aggregation
- Testability — Pure functions are easier to test
- Maintainability — Composable operations reduce complexity
The ~4-5 nanosecond overhead is insignificant compared to typical application operations (database, HTTP, file I/O), making Trellis an excellent choice for building robust, maintainable applications without sacrificing performance.
Performance Summary by Operation:
| Category | Range | Allocations |
|---|---|---|
| Map | 3-28 ns | Zero on success |
| Match | 2-3 ns | Zero |
| Tap | 3-33 ns | Zero (simple) |
| Bind | 4-34 ns | Zero on success |
| Combine | 7-41 ns (success) | Zero on success |
| Ensure | 12-106 ns | 152 B per predicate |
| Recover | 3-25 ns | Zero (simple) |
| MapOnFailure | 3-8 ns | Zero on success |
| Maybe | < 1 ns | Zero |
| Actor | 4-15 ns | Zero (simple lookups) |
| Money | 32-142 ns | 112-416 B |
| Specification | 268 ns-643 µs | 840 B-30 KB |
| Errors | 2-319 ns | 40-3216 B |
Last Updated: March 2025 Benchmark Tool: BenchmarkDotNet v0.15.8 Environment: .NET 10.0.3, Release Configuration, AMD Ryzen 9 9900X, Windows 11 (25H2)