NextStation
NextStation is a lightweight, flexible framework for building service objects (Operations) in Ruby. It provides a clean DSL to define business processes, manage state, and handle flow control.
Index
- Installation
- Getting Started
- Core Concepts
- Flow Control
- Railway Pattern & Errors
- Input Validation (dry-validation)
- Logging and Monitoring
- Dependency Injection
- Nested Operations (Operation Composition)
- Plugin System
- Advanced Usage
- License
Installation
Add this line to your application's Gemfile:
gem 'next_station'And then execute:
$ bundle install
Or install it yourself as:
$ gem install next_station
Getting Started
Define an operation by inheriting from NextStation::Operation and using the process block.
You can use result_at to specify which key from the state should be returned as the result value
require 'next_station'
class UserOnboarding < NextStation::Operation
# Define custom errors for this operation
errors do
error_type :invalid_email do
# Error can be in multiple languages, using just English for now.
message en: "Email is invalid. It must contain '@'."
end
end
# Define the steps of the operation
# this is the core of the Railway pattern
# Steps are executed in the order they are defined
process do
step :validate_email
step :send_welcome_email
step :finalize_onboarding
end
# The state[:result] will contain the value of the operation in case of success
result_at :result
# Step 1: Validate email presence of "@"
def validate_email(state)
email = state.params[:email]
unless email.to_s.include?("@")
error!(type: :invalid_email) # here we invoke the custom error defined above
end
state
end
# Step 2: Send a welcome email
def send_welcome_email(state)
# We can invoke any external service,
# ... as a pro tip, we also support Dependency Injection as shown in the "advanced" section of the docs.
EmailSender.send(state.params[:email])
# NextStation Also have a custom logger. Outputs to plaintext in Development and to JSON in Production.
publish_log :info, "Welcome email sent"
state
end
# Step 3: Finalize onboarding and set result
def finalize_onboarding(state)
state[:result] = { status: "onboarded", email: state.params[:email] }
state
end
end
# Case 1: Successful Onboarding (Valid email), all 3 steps executed and the state[:result] set as value
operation = UserOnboarding.new.call(email: "alice@example.com")
operation.success? # => true
operation.value # => { status: "onboarded", email: "alice@example.com" }
# Case 2: Invalid Email Failure, step 1 fail and the error is:invalid_email is returned instead of a result
# no further steps are executed
operation = UserOnboarding.new.call(email: "bobexample.com")
operation.success? # => false
operation.failure? # => true
operation.error.type # => :invalid_email
operation.error.message # => "Email is invalid. It must contain '@'."Core Concepts
State
Every operation execution revolves around a State object. It holds:
-
params: The initial input passed to
.call(params, context). - context: Read-only configuration or dependencies (e.g., current_user, repository).
-
data: A hash-like storage where steps can read and write data. By default, it contains a reference to
paramsunder the:paramskey.
Steps always receive the state as their only argument and MUST return it. If a step returns something else (or nil), a NextStation::StepReturnValueError will be raised.
Inside a step, you can access params in two ways:
state.params[:email] # Recommended
state[:params][:email] # Also validDirect access to params via top-level state keys (e.g., state[:email]) is NOT supported to avoid confusion between initial input and operation data.
Result
Operations return a NextStation::Result object (either a Success or Failure) which provides:
-
success?: Boolean indicating if the operation finished successfully. -
failure?: Boolean indicating if the operation failed or was halted. -
value: The data returned by the operation (forSuccess). -
error: AResult::Errorobject containingtype,message,help_url, anddetails.
Flow Control
NextStation provides powerful tools to manage complex business logic.
Step Skips
You can skip a step conditionally using skip_if:
step :send_notification, skip_if: ->(state) { state.params[:do_not_contact] }Branching
Use branch to execute a group of steps only when a condition is met:
branch ->(state) { state.params[:is_admin] } do
step :grant_admin_privileges
step :log_admin_action
endBranches can be nested for complex flows.
Resilience (Retry Logic)
Add resilience to flaky steps using retry_if, attempts, and delay:
process do
step :call_external_api,
retry_if: ->(state, exception) { exception.is_a?(Timeout::Error) },
attempts: 3,
delay: 1
endThe retry_if lambda receives both the current state and the exception (if any). It should return true if the step should be retried.
You can also retry based on the state result even if no exception was raised:
step :check_job_status,
retry_if: ->(state, _exception) { state[:job_status] == "pending" },
attempts: 5,
delay: 2Inside a step, you can check the current attempt number using state.step_attempt:
def call_external_api(state)
puts "Executing attempt number: #{state.step_attempt}"
# ...
state
endRailway Pattern & Errors
NextStation supports the Railway pattern, allowing you to explicitly handle success and failure paths using a structured error DSL.
Defining Errors
Use the errors block to define possible error types:
class CreateUser < NextStation::Operation
errors do
error_type :email_taken do
message en: "Email %{email} is taken"
message sp: "El correo %{email} ya existe"
help_url "http://example.com/support/email-taken"
end
end
endExternal Errors
You can also pass an existing NextStation::Errors class to errors.
class MyExternalErrors < NextStation::Errors
error_type :invalid_token do
message en: "Invalid token"
message sp: "Token inválido"
end
end
class GetUser < NextStation::Operation
errors MyExternalErrors
# ...
endShared Errors
You can define shared error collections by inheriting from NextStation::Errors. This allows you to reuse common error
definitions across multiple operations.
class MySharedErrors < NextStation::Errors
error_type :not_found do
message en: "Resource not found", sp: "Recurso no encontrado"
end
error_type :unauthorized do
message en: "You are not authorized to perform this action"
end
end
class GetUser < NextStation::Operation
# Pass the class directly to errors
errors MySharedErrors
# You can still add operation-specific errors or override shared ones
errors do
error_type :user_inactive do
message en: "User is inactive"
end
error_type :not_found do
message en: "User with ID %{id} not found"
end
end
endHalting Execution
Use error! within a step to stop the operation immediately and return a failure result:
def check_email(state)
if User.exists?(state.params[:email])
error!(
type: :email_taken,
msg_keys: { email: state.params[:email] },
details: { timestamp: Time.now }
)
end
state
endMulti-language Support
You can specify the desired language when calling the operation via the context:
result = CreateUser.new.call({ email: "taken@example.com" }, { lang: :sp })
result.error.message # => "El correo taken@example.com ya existe"If the requested language is not defined, it defaults to :en.
Input Validation (dry-validation)
NextStation integrates with dry-validation to provide powerful input guarding and coercion.
Defining a Contract
Use validate_with to define your validation rules. You can use a block to define the contract inline, or pass an
existing contract class.
Inline Contract
class CreateUser < NextStation::Operation
# Define the contract inline
validate_with do
params do
required(:email).filled(:string, format?: /@/)
required(:age).filled(:integer, gteq?: 18)
end
end
process do
step :validation # Explicitly run the validation
step :persist
end
def persist(state)
# state.params now contains COERCED values (e.g., age is an Integer)
User.create!(state.params)
state
end
endExternal Contract
You can also pass an existing Dry::Validation::Contract class.
class MyExternalContract < Dry::Validation::Contract
params do
required(:token).filled(:string)
end
end
class Authenticate < NextStation::Operation
validate_with MyExternalContract
process do
step :validation
step :authorize
end
def authorize(state)
# state.params[:token] is available here
state
end
endThe :validation Step
Validation is NOT automatic. You must explicitly add step :validation in your process block.
-
Failure: If validation fails, the operation halts immediately and returns a
Result::Failurewith type:validation. -
Details:
result.error.detailscontains the raw error hash fromdry-validation. -
Coercion: On success,
state.paramsis updated with the coerced and filtered values from the validation result.
Customizing Validation Errors
You can override the default validation error message using the errors DSL:
class UpdateProfile < NextStation::Operation
errors do
error_type :validation do
message en: "The provided data is invalid: %{errors}",
sp: "Los datos son inválidos: %{errors}"
end
end
validate_with do
# ...
end
process { step :validation }
endIf no custom message is defined, NextStation uses a default message: "One or more parameters are invalid. See validation details." (available in English and Spanish).
Localization
NextStation automatically handles localization for validation errors. It defaults to a "slim" approach using the :yaml backend, loading translations from its internal configuration.
For this gem, the locale yml file is located at lib/next_station/config/errors.yml.
The lang passed in the context (e.g., call(params, { lang: :sp })) is automatically respected.
class UpdateProfile < NextStation::Operation
validate_with do
params do
required(:name).filled(:string)
end
end
process { step :validation }
end
# Pass the desired language in the context
result = UpdateProfile.new.call({ name: "" }, { lang: :sp })
# result.error.details will contain the localized messages from dry-validation
# => { name: ["debe estar lleno"] }Validation Enforcement
By default, if you define validate_with, the validation is considered enabled.
-
force_validation!: Ensures that
step :validationis present in theprocessblock. If missing, calling the operation will raise aNextStation::ValidationError. -
skip_validation!: Disables the validation check even if
step :validationis present.
Logging and Monitoring
NextStation provides a built-in event system powered by dry-monitor to track operation lifecycle and user-defined
logs.
Bult-in Logging
Inside your operation steps, you can use publish_log to broadcast custom events. These are automatically routed to the
configured logger by default.
class CreateUser < NextStation::Operation
def persist(state)
# ... logic ...
publish_log(:info, "User persisted successfully", user_id: state[:user_id])
state
end
end- The log will automatically include the fields
trace_idandspan_idif the OpenTelemetry SDK is detected,
NextStation features an environment-aware logging configuration that works out of the box.
-
In Development: It defaults to the
Consoleformatter, providing human-readable, colorized output toSTDOUT. Example:[I][2026-03-01 20:32:54][CreateUser/persist] -- User persisted successfully {:user_id=>1} -
In Production (or any other environment): It defaults to the
Jsonformatter, which is ideal for structured logging. Example:{ "level": "INFO", "time": "2026-03-01T20:32:54.123456", "pid": 92323, "origin": { "operation": "CreateUser", "event": "log.custom", "step_name": "persist" }, "message": "User persisted successfully", "payload": { "user_id": 1 } }
Configuration
You can customize the logger, logging level, and other options:
NextStation.configure do |config|
# Use a different logger (e.g., Rails.logger)
config.logger = Rails.logger
# Manually override the formatter if needed
# config.logger.formatter = NextStation::Logging::Formatter::Json.new
# Set logging level (:debug, :info, :warn, :error, :fatal, :unknown).
# :info (default): logs everything except debug level.
# :warn: logs warn and above levels.
# :debug: logs everything including individual step start/stop events.
config.logging_level = :info
# To disable default logging subscribers:
# config.logging_enabled = false
# config.monitor = MyCustomMonitor.new
endLifecycle Events
NextStation automatically broadcasts events for every operation and step execution. You can subscribe to these events to integrate with external monitoring tools (Datadog, Prometheus, etc.):
NextStation.config.monitor.subscribe("operation.stop") do |event|
puts "Operation #{event[:operation]} finished in #{event[:duration]}ms"
end
NextStation.config.monitor.subscribe("step.retry") do |event|
puts "Step #{event[:step]} failed (attempt #{event[:attempt]}) with: #{event[:error].message}"
endAvailable Events:
-
operation.start: Triggered when an operation starts. -
operation.stop: Triggered when an operation finishes (success or failure). Includesdurationandresult. -
step.start: Triggered before a step starts. -
step.stop: Triggered after a step finishes. Includesdurationandstate. -
step.retry: Triggered when a step fails and is about to be retried.
Dependency Injection
NextStation includes a lightweight Dependency Injection (DI) system to help you decouple your operations from their external dependencies.
Declaring Dependencies
Use the depends method to declare dependencies and their defaults. Defaults can be static values or lazy lambdas:
class CreateUser < NextStation::Operation
depends mailer: -> { Mailer.new },
repository: UserRepository.new
process do
step :send_welcome_email
end
def send_welcome_email(state)
# Access dependencies using the dependency() method
dependency(:mailer).send_welcome(state.params[:email])
state
end
endInjecting Dependencies
You can override the default dependencies when instantiating the operation by passing the deps: keyword argument:
# In your tests
mock_mailer = double("Mailer")
operation = CreateUser.new(deps: { mailer: mock_mailer })
operation.call(email: "test@example.com")Inheritance
Dependencies are inherited and can be overridden in subclasses:
class BaseOp < NextStation::Operation
depends logger: Logger.new
end
class MyOp < BaseOp
depends logger: CustomLogger.new # Overrides parent dependency
endNested Operations (Operation Composition)
Operations can invoke other operations using the call_operation helper. This maintains the Railway pattern, shares context (e.g., current_user, lang), and handles error propagation automatically.
class SyncUser < NextStation::Operation
depends remote_op: -> { RemoteOp.new }
errors do
error_type :provider_error do
message en: "External Sync Failed: %{reason}"
end
end
process do
step :fetch_remote_data
step :other_step
end
def fetch_remote_data(state)
# 1. Automatically shares context (state.context)
# 2. Dynamic params via Proc (or pass a Hash directly)
# 3. Results stored in state[:remote_profile]
# 4. If RemoteOp fails with :provider_error, this step halts and
# the parent returns its own template for :provider_error.
call_operation(
state,
dependency(:remote_op),
with_params: ->(s) { { uid: s.params[:id] } },
store_result_in_key: :remote_profile
)
end
def other_step(state)
state[:remote_profile] # Access the result from the child operation
state
end
endError Propagation Rules
-
Mapped Error: If the Parent Operation has a matching
error_typedefined, it "intercepts" the failure. The resulting error uses the Parent's message template but is populated with the Child'smsg_keysanddetails. -
Transparent Error: If the Parent has NOT defined that error type, the child's
Errorobject is propagated exactly as is (including its already resolved message).
The call_operation helper triggers the internal Halt mechanism, allowing parent step controls like retry_if to function as expected.
Plugin System
NextStation features a modular Plugin System that allows extending core functionality without modifying the gem itself.
Using Plugins
Enable plugins using the plugin macro:
class CreateUser < NextStation::Operation
plugin :transactional
process do
step :validate_inputs
transaction do
step :create_user_record
end
end
endCreating Plugins
You can create your own plugins to add lifecycle hooks, DSL methods, and state helpers.
For detailed information on how to design and build plugins, please refer to the Plugin System Guide.
Advanced Usage
Result Value and result_at
Operations return a value encapsulated in the Result::Success object. You have two ways to define what this value is:
1. Default Result Key (:result)
If you don't specify anything, NextStation looks for the :result key in the state.
class MyOperation < NextStation::Operation
process do
step :do_work
end
def do_work(state)
state[:result] = { message: "All good!" }
state
end
end
result = MyOperation.new.call
result.value # => { message: "All good!" }2. Customizing with result_at
If you want to use a more descriptive key for your result, use result_at.
class MyOperation < NextStation::Operation
result_at :user_record
process do
step :find_user
end
def find_user(state)
state[:user_record] = User.find(state.params[:id])
state
end
end
result = MyOperation.new.call
result.value # => <User instance>Note: If the expected key (either
:resultor the one defined byresult_at) is missing from the state at the end of the operation, aNextStation::Errorwill be raised. This ensures that you explicitly define the output of your operations.
Output Shapes (dry-struct)
You can enforce the structure of the success result using the result_schema DSL, which leverages the dry-struct gem.
class CreateUser < NextStation::Operation
result_at :user_data
result_schema do
attribute :id, NextStation::Types::Integer
attribute :email, NextStation::Types::String
attribute :address do
attribute :city, NextStation::Types::String
attribute :street, NextStation::Types::String
end
attribute :metadata, NextStation::Types::Any
end
process do
step :set_data
end
def set_data(state)
state[:user_data] = {
id: 1,
email: "john@example.com",
address: { city: "NYC", street: "Main St" },
metadata: { foo: "bar" }
}
state
end
endLazy Validation
The result schema is applied lazily. Validation and coercion only occur when you call result.value.
op = CreateUser.new.call(params)
op.success? # => true (Operation finished without errors)
# Validation happens now:
op.value
# => #<CreateUser::ResultSchema id=1 email="john@example.com" ...>
# If the data doesn't match the schema:
# => raises NextStation::ResultShapeErrorExternal Schemas
You can also pass an existing Dry::Struct class to result_schema. This is useful for sharing schemas across multiple operations.
class MySharedSchema < Dry::Struct
attribute :id, NextStation::Types::Integer
end
class CreateUser < NextStation::Operation
result_schema MySharedSchema
endNote that result_schema accepts either a Dry::Struct class OR a block, but not both. Providing both will raise a NextStation::DoubleSchemaError.
Enabling/Disabling Enforcement
By default, enforcement is enabled if a result_schema is defined. You can explicitly control this behavior:
class CreateUser < NextStation::Operation
result_schema do
# ...
end
# Force enforcement (default if schema is present)
enforce_result_schema
# Disable enforcement (result.value will return the raw hash)
disable_result_schema
endNote: If
enforce_result_schemais enabled but noresult_schemais defined (either in the class or its ancestors), callingresult.valuewill raise aNextStation::Error.
Types
You can use all standard dry-types via NextStation::Types.
Environment Configuration
NextStation's behavior can be environment-aware.
By default, it automatically detects the environment by checking for RAILS_ENV, RACK_ENV, APP_ENV, and RUBY_ENV.
It considers development and dev as development environments, and production, prod, prd as production-like.
Simple Configuration
You can set the environment name directly:
NextStation.configure do |config|
config.environment = 'production'
# or
config.environment = ENV['MY_APP_ENV']
endAdvanced Configuration
If you need to customize which names are considered "production" or "development", or which environment variables to check, you can access the environment object properties:
NextStation.configure do |config|
# Consider 'staging' as a production-like environment
config.environment.production_names << 'staging'
endLicense
TBD