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
Installation
Add to your pubspec.yaml:
dependencies:
gradis: ^0.1.0
Core Concepts
Railway
A Railway<C, E> orchestrates a workflow by composing guards and steps into a pipeline:
C: The context type that flows through the pipelineE: The unified error type for the workflow
Guards
Guards perform read-only validation without mutating the context:
class EmailGuard implements RailwayGuard<CreateUserContext, CreateUserError> {
@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 implements RailwayStep<CreateUserContext, CreateUserError> {
@override
Future<Either<CreateUserError, CreateUserContext>> run(CreateUserContext context) async {
final userId = await _repository.createUser(context.email);
return Right(context.copyWith(userId: userId));
}
}
Steps return Right(updated context) on success or Left(error) on failure.
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<CreateUserContext, CreateUserError>()
.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<CreateUserContext, CreateUserError> {
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.
Context Immutability Pattern
Always use immutable context updates:
// ✅ Good - immutable update
class IncrementStep implements RailwayStep<CounterContext, CounterError> {
@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 implements RailwayStep<CounterContext, CounterError> {
@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<CreateUserContext, CreateUserError>()
.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.
Libraries
- gradis
- A railway-oriented programming package for Dart providing strongly-typed, declarative workflow orchestration with guards and steps.