The dry-rb ecosystem is a collection of small, focused Ruby libraries, each solving one problem well. They share a philosophy: immutability, explicit boundaries, and doing one thing rather than everything.

Here are five I keep reaching for.
- dry-validation: schema plus business rules for validating input.
- dry-types: defining and enforcing data types.
- dry-struct: immutable, typed value objects.
- dry-configurable: clean configuration for an app or a library.
- dry-monads: composing business logic with explicit success and failure.
1. dry-validation: input handling with rules
Validating input tends to sprawl. dry-validation keeps it declarative
through a contract: a schema that checks types and presence, plus rules for
the logic a schema can’t express.
require "dry/validation"
class UserContract < Dry::Validation::Contract
params do
required(:name).filled(:string)
required(:email).filled(:string, format?: /\A.+@.+\..+\z/)
optional(:age).maybe(:integer)
end
rule(:age) do
key.failure("must be over 18") if value && value <= 18
end
end
result = UserContract.new.call(name: "Nico", email: "nico@example.com", age: 20)
if result.success?
puts "Valid input: #{result.to_h}"
else
puts "Errors: #{result.errors.to_h}"
end
The schema handles shape and types; the rule block handles everything else.
Clearer than a pile of hand-rolled conditionals.
2. dry-types: enforce data integrity
With dry-types you define custom types and constrain them, so invalid data
is rejected at the boundary instead of leaking through your program.
require "dry-types"
module Types
include Dry.Types()
Email = Types::String.constrained(format: /\A.+@.+\..+\z/)
Age = Types::Integer.constrained(gt: 18)
end
Types::Email["nico@example.com"] # => "nico@example.com"
Types::Age[20] # => 20
Types::Age[15] # => raises Dry::Types::ConstraintError
These custom types then compose into structs.
3. dry-struct: immutable, typed objects
Need value objects with strict typing and no accidental mutation?
dry-struct builds on dry-types for exactly that.
require "dry-struct"
module Types
include Dry.Types()
end
class User < Dry::Struct
attribute :name, Types::String
attribute :email, Types::String
attribute :age, Types::Integer.optional
end
user = User.new(name: "Nico", email: "nico@example.com", age: 30)
user.name # => "Nico"
user.name = "Bob" # => raises NoMethodError (dry-struct defines no writers)
A Dry::Struct has no attribute setters at all, so there’s no way to mutate
one after it’s built. To “change” a value you derive a new struct with new.
4. dry-configurable: configuration without ceremony
dry-configurable gives an app or a library clear, thread-safe settings.
require "dry-configurable"
class AppConfig
extend Dry::Configurable
setting :database_url, default: "postgres://localhost/mydb"
setting :log_level, default: :info
end
AppConfig.configure do |config|
config.database_url = "postgres://localhost/production"
config.log_level = :debug
end
AppConfig.config.database_url # => "postgres://localhost/production"
AppConfig.config.log_level # => :debug
5. dry-monads: composable business logic
For multi-step logic that can fail, dry-monads gives you a Result — a
Success or a Failure — and do-notation, where yield unwraps a success
and short-circuits on the first failure.
(If you’ve used the older dry-transaction, this is its successor:
dry-transaction was deprecated in 2024 in favour of do-notation and
dry-operation.)
require "dry/monads"
class SignupUser
include Dry::Monads[:result, :do]
def call(input)
values = yield validate(input)
user = yield persist(values)
send_welcome_email(user)
Success(user)
end
private
def validate(input)
return Failure("Invalid email") unless input[:email] =~ /\A.+@.+\..+\z/
Success(input)
end
def persist(input)
Success(input.merge(id: 1))
end
def send_welcome_email(user)
puts "Welcome email sent to #{user[:email]}"
end
end
result = SignupUser.new.call(email: "user@example.com")
if result.success?
puts "Signup successful: #{result.value!.inspect}"
else
puts "Signup failed: #{result.failure}"
end
Each step returns Success or Failure, yield threads the happy path, and
the caller checks success? before reading the value. No exceptions for
control flow, no nil to second-guess.
Why dry-rb
The common thread across these gems:
- Immutability, so state doesn’t change under you.
- Explicitness, so the code says what it does.
- Small scope, so each gem does one job and composes with the rest.
You don’t have to adopt the whole suite. Start with one — dry-validation for
an unruly form, or dry-monads for a tangled service — and add others as they
earn their place.
Have comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht