gradis 2.0.0
gradis: ^2.0.0 copied to clipboard
A railway-oriented programming package for Dart providing strongly-typed, declarative workflow orchestration with guards and steps.
Gradis #
A railway-oriented programming package for Dart providing strongly-typed, declarative workflow orchestration with guards and steps.
Overview #
Gradis implements a railway pattern for application-layer workflows in Clean Architecture/DDD systems. It separates validation (guards) from state mutation (steps) while maintaining predictable error handling and type safety.
Features #
- Strongly-typed context propagation through workflow pipelines
- Separate guard and step abstractions for validation vs mutation
- Immutable builder pattern for composable railway definitions
- Automatic short-circuiting on first error
- Single unified error type per railway (no runtime casting)
- Transaction-agnostic execution
- Compensation/rollback for failed workflows (Saga pattern)
- Conditional branching with predicate-based execution
- Switch pattern for exclusive choice routing
Installation #
Add to your pubspec.yaml:
dependencies:
gradis: ^2.0.0
Core Concepts #
Type Parameter Convention #
Important: Gradis v2.0.0 uses the <E, C> type parameter order:
Railway<E, C> // E = Error type (Left), C = Context type (Right)
RailwayGuard<E, C>
RailwayStep<E, C>
This matches the Either<L, R> convention from the either_dart package, making Railway → Either conversions more intuitive:
Railway<MyError, MyContext> → Either<MyError, MyContext>
Migration from v1.x: All type parameters must be reversed. See the Migration Guide below.
Railway #
A Railway<E, C> orchestrates a workflow by composing guards and steps into a pipeline:
E: The unified error type for the workflow (Left channel)C: The context type that flows through the pipeline (Right channel)
Guards #
Guards perform read-only validation without mutating the context:
class EmailGuard implements RailwayGuard<CreateUserError, CreateUserContext> {
@override
Future<Either<CreateUserError, void>> check(CreateUserContext context) async {
if (!context.email.contains('@')) {
return Left(CreateUserError.invalidEmail);
}
return const Right(null);
}
}
Guards return Right(null) on success or Left(error) on validation failure.
Steps #
Steps perform state mutation and return an updated context:
class CreateUserStep extends RailwayStep<CreateUserError, CreateUserContext> {
@override
Future<Either<CreateUserError, CreateUserContext>> run(CreateUserContext context) async {
final userId = await _repository.createUser(context.email);
return Right(context.copyWith(userId: userId));
}
@override
Future<void> compensate(CreateUserContext context) async {
// Optional: cleanup/rollback on downstream failure
await _repository.deleteUser(context.userId!);
}
}
Steps return Right(updated context) on success or Left(error) on failure. Optionally override compensate() to handle rollback when downstream steps fail.
Context #
Context is an immutable object that travels through the railway:
final class CreateUserContext {
final String email;
final String? userId;
const CreateUserContext({required this.email, this.userId});
CreateUserContext copyWith({String? userId}) {
return CreateUserContext(email: email, userId: userId ?? this.userId);
}
}
Use the copyWith pattern to update context immutably in steps.
Usage Examples #
Basic Railway Composition #
final railway = Railway<CreateUserError, CreateUserContext>()
.guard(EmailGuard())
.guard(PasswordGuard())
.step(CreateUserStep())
.step(SendVerificationStep());
final context = CreateUserContext(email: 'user@example.com', password: 'secure');
final result = await railway.run(context);
result.fold(
(error) => print('Error: $error'),
(ctx) => print('Success! User ID: ${ctx.userId}'),
);
Error Mapping Pattern #
Guards and steps are responsible for mapping internal errors to the railway error type:
class CreateUserStep implements RailwayStep<CreateUserError, CreateUserContext> {
final UserRepository repository;
@override
Future<Either<CreateUserError, CreateUserContext>> run(CreateUserContext context) async {
// Repository returns its own error type
final result = await repository.create(context.email);
// Map repository error to workflow error
return result.fold(
(repositoryError) {
if (repositoryError == RepositoryError.alreadyExists) {
return Left(CreateUserError.userExists);
}
return Left(CreateUserError.saveFailed);
},
(userId) => Right(context.copyWith(userId: userId)),
);
}
}
This keeps error mapping localized and the railway free of error-handling logic.
Compensation Pattern (Saga) #
When a step fails, Gradis automatically executes compensation functions in reverse order for all previously executed steps:
class ReserveInventoryStep extends RailwayStep<OrderError, OrderContext> {
@override
Future<Either<OrderError, OrderContext>> run(OrderContext context) async {
await _inventory.reserve(context.productId, context.quantity);
return Right(context.copyWith(inventoryReserved: true));
}
@override
Future<void> compensate(OrderContext context) async {
// Rollback: release the reservation
await _inventory.release(context.productId, context.quantity);
}
}
class ProcessPaymentStep extends RailwayStep<OrderError, OrderContext> {
@override
Future<Either<OrderError, OrderContext>> run(OrderContext context) async {
final result = await _payment.charge(context.amount);
if (result.failed) return Left(OrderError.paymentFailed);
return Right(context.copyWith(paymentId: result.id));
}
@override
Future<void> compensate(OrderContext context) async {
// Rollback: refund the charge
await _payment.refund(context.paymentId!);
}
}
final railway = Railway<OrderError, OrderContext>()
.step(ReserveInventoryStep()) // If payment fails...
.step(ProcessPaymentStep()); // ...inventory is auto-released
Compensations are best-effort - errors during compensation are logged but don't fail the workflow.
Branching Pattern #
Add conditional logic to your railway with branch():
final railway = Railway<OrderError, OrderContext>()
.step(ValidateOrderStep())
.branch(
(ctx) => ctx.isPremiumUser,
(r) => r.step(ApplyPremiumDiscountStep()),
)
.step(ProcessPaymentStep());
The predicate is evaluated once, and the branch only executes if true. Branch steps participate in the compensation chain.
Switch Pattern #
Use switchOn() for exclusive choice routing based on a selector value. This is ideal for workflows that need different execution paths based on state:
Value Matching with when()
final railway = Railway<OrderError, OrderContext>()
.step(ValidateOrderStep())
.switchOn<OrderStatus>((ctx) => ctx.status)
.when(OrderStatus.draft, (r) => r.step(ValidateDraftStep()))
.when(OrderStatus.pending, (r) => r.step(ProcessPendingStep()))
.when(OrderStatus.approved, (r) => r.step(FulfillOrderStep()))
.otherwise((r) => r.step(HandleUnknownStatusStep()))
.step(LogOrderStep());
Predicate Matching with whenMatch()
For conditional matching rather than exact values:
final railway = Railway<UserError, UserContext>()
.step(CreateUserStep())
.switchOn<String>((ctx) => ctx.email.split('@').last)
.whenMatch(
(domain) => domain.endsWith('.gov') || domain.endsWith('.edu'),
(r) => r.step(GrantAdminPermissionsStep()),
)
.whenMatch(
(domain) => domain.contains('example'),
(r) => r.step(CreateGuestSessionStep()),
)
.otherwise((r) => r.step(SetupUserDashboardStep()))
.step(SendVerificationEmailStep());
Switch Execution Rules
- The selector function is evaluated once when the switch is reached
- Cases are checked in order until the first match
- Only the first matching case executes (short-circuit evaluation)
- Use
otherwise()for a fallback when no cases match - Use
end()instead ofotherwise()if no fallback is needed (no-op) - All operations from the matched case are added to the parent railway's operation list
- Switch operations participate in the compensation chain if downstream steps fail
See example/main.dart for complete switch pattern examples.
Context Immutability Pattern #
Always use immutable context updates:
// ✅ Good - immutable update
class IncrementStep extends RailwayStep<CounterError, CounterContext> {
@override
Future<Either<CounterError, CounterContext>> run(CounterContext context) async {
return Right(context.copyWith(count: context.count + 1));
}
}
// ❌ Bad - mutable update (don't do this)
class BadStep extends RailwayStep<CounterError, CounterContext> {
@override
Future<Either<CounterError, CounterContext>> run(CounterContext context) async {
context.count++; // This won't compile if context is properly immutable
return Right(context);
}
}
Transaction Boundaries #
Railways don't manage transactions - that's the caller's responsibility:
// Wrap railway execution in a transaction
await transactionRunner.run(() async {
final railway = Railway<CreateUserError, CreateUserContext>()
.step(CreateUserStep())
.step(CreateAccountStep());
return await railway.run(context);
});
Design Principles #
- Builder Pattern: Each
guard()andstep()call returns a new railway instance - Immutable Context: Context flows through the pipeline without mutation
- Single Error Type: Each workflow defines exactly one error type
- Guards vs Steps: Clear separation between validation and mutation
- No Runtime Casting: Type safety enforced at compile time
- Declarative Workflows: Railway definitions remain clean and readable
Complete Example #
See example/main.dart for a complete working example.
License #
See LICENSE file.