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.

The dry-rb ecosystem

Here are five I keep reaching for.

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