Rails model validations work fine until they don’t. Around the 20-model mark, conditional validations start fighting each other, callbacks fire validations you didn’t expect, and ModuleWithAdditionalValidations becomes a euphemism for “nobody wants to touch this.”

The dry-rb ecosystem offers an alternative: decouple validation from persistence entirely. This article walks through replacing ActiveRecord validations with dry-validation (1.10+), dry-schema (1.13+), and dry-types (1.7+), with real before/after code.

Why ActiveRecord Validations Break Down

Consider a typical User model:

class User < ApplicationRecord
  include ModuleWithAdditionalValidations

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :email, presence: true, uniqueness: true, format: { with: Const::EMAIL_RE }

  validates :terms_of_service, acceptance: true
end

For a hobby project, this is fine. For a production app with 40+ models, several problems compound:

  1. Conditional validations conflict. When validates :email needs different rules for sign-up vs. admin-edit vs. API-import, you end up with if: / unless: chains that are hard to reason about.
  2. Validations run on every save. You cannot persist a partial record without save(validate: false), which defeats the purpose.
  3. Microservices break the model. If two services share a User concept but have different schema requirements, extracting ActiveRecord models into a shared gem contradicts service boundaries.
  4. Testing gets slow. Model specs that touch the database just to test input format rules are doing unnecessary work.

The fix is a design shift: validate data at the boundary (when the request arrives), not at the persistence layer.

How dry-validation Contracts Work

A contract defines what valid input looks like for a specific request, not a specific model. Here is a SignUpContract using dry-validation 1.10:

class SignUpContract < Dry::Validation::Contract
  params do
    optional(:id).filled(Types::ResourceID)
    optional(:first_name).filled(Types::Coercible::String)
    optional(:last_name).filled(Types::Coercible::String)
    required(:email).filled(Types::Email)
    required(:password).filled(Types::Password)
    required(:terms_of_service).filled(Types::Bool, eql?: true)
  end
end

Key differences from ActiveRecord validations:

  • Request-scoped. SignUpContract only cares about sign-up input. A separate ProfileUpdateContract handles profile edits with its own rules.
  • No database dependency. Contracts are plain Ruby objects. You can test them without ActiveRecord or a database connection.
  • Explicit coercion. Types::Coercible::String strips and converts input. No silent type casting buried in ActiveRecord.

Building Custom Types with dry-types

The Types::Email and Types::Password references above come from a custom types module built on top of dry-types:

module Types
  include Dry::Types()

  DowncasedStrippedString = String.constructor(->(s) { s.strip.downcase })

  ResourceID = String.constrained(format: Const::UUID_RE)

  Email = DowncasedStrippedString.constrained(format: Const::EMAIL_RE)
  Password = String.constrained(format: Const::PASSWORD_RE)
end

This is where dry-types pays off. Instead of repeating format: { with: EMAIL_RE } in every model, you define the type once and reuse it across all contracts. The Email type is not just a string with a regex check. It is a composition: strip whitespace, downcase, then validate format. That pipeline runs automatically whenever the type is applied.

You can find the full dry-types DSL at dry-rb.org/gems/dry-types.

Before and After: The Model

Before:

class User < ApplicationRecord
  include ModuleWithAdditionalValidations

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :email, presence: true, uniqueness: true, format: { with: Const::EMAIL_RE }
  validates :terms_of_service, acceptance: true

  # Plus callbacks, scopes, associations, methods...
end

After:

class User < ApplicationRecord
  # Associations, scopes, and query methods only.
  # No validations, no callbacks.
end

The model becomes a thin persistence wrapper. Validation, authorization, and business logic live in dedicated objects.

Wiring Contracts into a Rails Controller

Here is one way to connect contracts to a standard Rails controller:

class AuthenticationController < ApplicationController
  def sign_up(contract: SignUpContract.new)
    # ...

    user =
      validate_params_with(contract) do |user_data|
        User.create!(user_data)
      end

    # ...
  end

  private

  def validate_params_with(contract)
    validation_result = contract.call(params.to_unsafe_h)

    if validation_result.failure?
      raise SomeValidationError, validation_result.errors.to_h
    end

    block_given? ? yield(validation_result.to_h) : validation_result.to_h
  end
end

This is a minimal example to show the pattern. In a production codebase, you would likely extract validate_params_with into a base controller concern or, better yet, move the entire flow into a service object (dry-monads works well here, covered in our follow-up article on replacing callbacks with dry-rb).

USEO’s Take: What We Actually Use

After applying dry-rb across multiple Rails projects (ranging from 30k to 200k+ LOC), here is what our team has landed on:

We use heavily:

  • dry-validation + dry-schema for all input validation. Every controller action that accepts data gets a contract. This is non-negotiable on our projects.
  • dry-types for domain-specific types like Email, Money, Slug, and UUID. We keep a shared Types module per project.
  • dry-monads for service objects and operation chaining. Result monads (Success/Failure) replaced most of our custom error handling. This is covered in the callbacks article.

We skip:

  • dry-system and dry-container for auto-injection. In a Rails app with Zeitwerk autoloading, adding a parallel dependency injection system creates confusion. The cognitive overhead is not worth it unless you are building a non-Rails Ruby application.
  • dry-struct in most cases. Data.define (Ruby 3.2+) or plain Struct covers 90% of value object needs without adding a dependency.

When dry-rb is overkill:

  • CRUD-heavy admin panels with simple models. If your validations are presence: true on five fields and nothing else, a contract adds ceremony without value.
  • Prototypes and MVPs under 10 models. ActiveRecord validations are genuinely faster to write when you are exploring product-market fit.
  • Scripts and one-off rake tasks. Inline validation logic is fine for throwaway code.

The rule of thumb: if you are reaching for validates ... if: with a lambda, it is time to extract a contract.

Trade-offs You Should Know

Moving validations out of models is not free. Here are the costs:

  1. Data integrity requires discipline. With model validations, every save call is protected automatically. With contracts, a developer who calls User.create! directly without running a contract first bypasses all checks. Database-level constraints (NOT NULL, CHECK, unique indexes) become your safety net.
  2. More files to maintain. Each endpoint that accepts data needs a contract. For a typical REST resource, that is 2-3 contracts (create, update, maybe a specialized one). This is more explicit but also more verbose.
  3. Team onboarding. Developers coming from standard Rails need to learn the dry-validation DSL. The learning curve is about a week for the basics, longer for advanced features like custom predicates and external dependencies.

Summary

  • Model validations couple input rules to persistence. This coupling causes pain as applications grow.
  • dry-validation contracts validate data at the request boundary, before it reaches the model.
  • dry-types lets you define reusable domain types (Email, Password, ResourceID) instead of repeating regex checks.
  • Request-scoped validation works across architectures: monoliths, microservices, event-driven systems.
  • The trade-off is stricter discipline around data integrity and more files to manage.
  • Explore the full dry-rb ecosystem at dry-rb.org.