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:
- Conditional validations conflict. When
validates :emailneeds different rules for sign-up vs. admin-edit vs. API-import, you end up withif:/unless:chains that are hard to reason about. - Validations run on every save. You cannot persist a partial record without
save(validate: false), which defeats the purpose. - Microservices break the model. If two services share a
Userconcept but have different schema requirements, extracting ActiveRecord models into a shared gem contradicts service boundaries. - 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.
SignUpContractonly cares about sign-up input. A separateProfileUpdateContracthandles 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::Stringstrips 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, andUUID. We keep a sharedTypesmodule per project. - dry-monads for service objects and operation chaining.
Resultmonads (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 plainStructcovers 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: trueon 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:
- Data integrity requires discipline. With model validations, every
savecall is protected automatically. With contracts, a developer who callsUser.create!directly without running a contract first bypasses all checks. Database-level constraints (NOT NULL,CHECK, unique indexes) become your safety net. - 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.
- 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.