DRY validation in Ruby: dry-validation gem guide

Validation logic tends to rot inside Rails models: callbacks, custom validators, and conditional presence checks pile up until nobody trusts the model's boundaries anymore. dry-validation fixes this by splitting structural checks from business rules into a Contract class, giving you type-safe schemas, composable rule blocks, and a monadic Result object instead of scattered errors.add calls.

This guide covers where dry-validation ends and dry-schema begins, how to wire it into Rails, and how to write Contracts that hold up under real production load.

Dry-validation vs dry-schema: What each One actually does

Dry-validation and dry-schema split one job Rails developers used to bundle into a single validation call. dry-schema handles structure: required keys, types, and coercion on raw params, using its own schema processor types (Params, JSON, Schema) to decide how values get filled in before anything else runs. dry-validation builds on top of that layer, a Contract class wraps a schema and adds rule blocks for logic a schema can't express, like uniqueness checks or cross-field comparisons, then returns a Result object, a dry-monads-flavored value you pattern-match on instead of checking a boolean.

Both libraries are part of the broader dry-rb family of tools, which offers additional tools for improving service architecture and code quality beyond validation alone.

Teams migrating off ActiveModel::Validations regularly hit this split the hard way: model callbacks mixed structural checks and business rules into one class, and untangling them is most of the migration work. According to Ruby Toolbox's dry-validation project page, the gem sees several million downloads, which tracks with how often production Rails apps hit this exact refactor once schemas outgrow ActiveModel.

This pattern often surfaces alongside other refactors, like moving presentation logic out of models using the Draper gem, as teams clean up bloated ActiveModel classes.

The dry-rb changelog on GitHub documents, version by version, which parts of the API moved into dry-schema and which stayed rule-only, worth checking before you pin a Gemfile entry. The rest of this guide covers setup, custom macro registration, and a real Rails migration.

What is dry-validation used for?

Dry-validation checks incoming params against business rules that Rails' ActiveModel never cleanly separated from formatting checks. Where scattered model validations bury conditional logic across callbacks and custom validator classes, a Contract class keeps predicate methods and rule blocks in one place, run against an already-coerced schema.

This clean separation contrasts with Rails' own dynamic method-generation techniques, which lean heavily on Ruby's metaprogramming to define validators and attributes on the fly.

Predicate methods handle single-value checks: filled?, int?, included_in?, the kind of thing a Rails validates line used to do. Rule blocks exist for logic spanning more than one field: comparing two keys, calling a custom macro for a uniqueness check, or gating a rule on a dependency injected into the contract.

Each evaluation returns a Result object. Teams using dry-monads pattern-match on Success or Failure instead of branching on.valid?.

A common refactor pattern looks like this: a team migrating a set of ActiveModel classes moves validation logic into dry-validation contracts, replacing presence and format checks copy-pasted across models with one shared rule block. Another frequent trigger is a growing codebase where a homegrown validation library quietly duplicates logic that already exists elsewhere in the app.

Dry-validation is one of the more widely adopted gems in the dry-rb family, with RubyGems.org reporting roughly 90 million total downloads, a figure that tracks closely with how often teams run into this exact duplication problem.

Dry-validation vs dry-schema: Which One do you need?

Dry-schema handles structure and coercion only: required keys, type checks, and value transformation, with no concept of a rule or a Contract class. Reach for dry-schema alone when you're validating an internal API payload or a config object with no cross-field logic and no business rules tied to a database lookup.

Reach for dry-validation when checks depend on more than one field, or on state outside the params hash: user age against a stored subscription tier, an email against an existing record. Under the hood, a Contract class wraps a dry-schema instance as its schema and defines rule blocks separately; the schema runs first, and rule blocks only fire on keys that already passed coercion.

dry-schema dry-validation
Handles structure, types, coercion schema + cross-field rules
Core object schema processor types (Params, JSON, Strict) Contract class
Business logic none rule blocks, custom macros
Result schema result Result object via dry-monads

The dry-rb GitHub changelog documents this split explicitly since the 1.0 release in 2019, when rule composition moved out of dry-schema entirely. If your Gemfile only needs one gem, pull dry-schema; if rules touch external state, add dry-validation on top.

Installing and configuring dry-validation in a rails app

Add gem "dry-validation" to your Gemfile and run bundle install; the gem carries an MIT license, same as the rest of the dry-rb family, so there's no legal review needed before you ship it. That roughly 90 million download count is a sign the toolset Hanami popularized has moved past niche adoption into mainstream Rails use.

If you need extra Rails development expertise to integrate dry-validation smoothly into a larger application, partnering with an experienced team can speed up adoption.

Rails has no generator for this, so convention matters more than in ActiveModel. We put contract classes under app/contracts/, mirroring the app/models/ structure: app/contracts/new_user_contract.rb defines NewUserContract, one file per Contract class, one Contract per form object or service input.

Skip a global initializer unless you're registering shared custom macros or injecting dependencies into rule blocks across contracts. If you are, drop that setup in config/initializers/dry_validation.rb and require it once. Each Contract class then declares its own schema block for structure and coercion, and its own rule blocks for cross-field logic, keeping both concerns visible in one file rather than scattered across concerns and callbacks.

Writing your first contract: Schema and rules

A Contract class is where the schema/rules split actually shows up in code. The schema block (built on dry-schema under the hood) owns structure, required keys, and type coercion. Rule blocks run only after the schema passes, and they own cross-field logic, database lookups, and anything that isn't purely structural.

class NewUserContract < Dry::Validation::Contract
 params do
 required(:email).filled(:string)
 required(:age).filled(:integer)
 optional(:referral_code).maybe(:string)
 end

 rule(:age) do
 key.failure("must be 18 or older") if values[:age] < 18
 end
end

filled(:string) and filled(:integer) do double duty: they coerce incoming params to the declared type and reject blank values in the same step. Call NewUserContract.new.call(params) and you get back a Result object, not a boolean, pattern match on it with dry-monads' Success/Failure instead of checking.valid? in an if/else chain, and downstream code stays type-safe.

Custom macros are how you stop copy-pasting the same rule block across contracts. Register one with register_macro, inject dependencies through option, and every contract that includes it gets the same age check or key-format check without redefining it.

Handling nested schemas and arrays of hashes

Nested schemas validate hashes inside hashes, and dry-validation coerces types at every level, not just the top one. An addresses field that holds an array of hashes looks like this:

params do
 required(:addresses).array(:hash) do
 required(:street).filled(:string)
 required(:zip).filled(:string)
 end
end

Each hash in the array runs through its own schema before any rule block sees it, so a rule referencing values[:addresses] can assume every entry already has a valid zip, no manual re-checking. According to the dry-validation changelog on GitHub, version 1.10 extended key-scoped macro registration to nested array schemas, which removed a chunk of boilerplate teams had been writing by hand.

Validating an array of hashes

Nested schemas handle a single hash inside a hash; an array of hashes needs the each macro, which dry-schema applies to every element and reports per-index failures back on the Result object.

params do
 required(:line_items).array(:hash) do
 required(:sku).filled(:string)
 required(:quantity).filled(:integer)
 end
end

Under the hood, array(:hash) composes the each macro with a nested schema definition, so type coercion runs element-by-element before any rule block sees the params. For cross-field checks per line item, for example validating quantity against a stock lookup injected into the contract, drop the logic into a rule block scoped with rule(:line_items).each do |item|... end rather than the schema.

That keeps structural validation and business validation on separate sides of the split, which is the same boundary the dry-rb changelog has preserved across major versions.

Handling validation errors and result objects

A dry-validation Contract never raises on invalid input. Calling .call(params) always returns a Result object, and everything downstream, success branching, error rendering, logging, should pattern-match against that object rather than rescue an exception.

result = NewUserContract.new.call(params)

case result
in Dry::Validation::Result(success?: true)
 UserCreator.call(result.to_h)
in Dry::Validation::Result(success?: false)
 render json: { errors: result.errors.to_h }, status: 422
end

Result.errors.to_h gives a nested hash keyed by field path, which is what you traverse for API responses, result.errors.to_h[:address][:zip] for a nested schema failure, result.errors.to_h[:line_items][0][:sku] for the array case from the previous section. result.errors(full: true) returns human-readable sentences instead, useful for flash messages.

Rule blocks can go further with dry-monads directly. If a rule needs to call an external service, checking an email against a fraud API, say, wrap the call in a Success/Failure monad and pattern match on it inside the rule, rather than raising and rescuing. This keeps side effects explicit and testable in isolation with plain RSpec doubles.

Custom macros register through Dry::Validation::Contract.register_macro, and they compose the same predicate methods dry-schema ships (filled?, int?, gteq?). Register a macro once, in an initializer, and every Contract in the app gets it, dependency injection into rule blocks works the same way, passed through the contract's option declaration rather than pulled from global state.

Custom error messages

Override messages directly inside rule blocks with key.failure("must be an adult"), or centralize them in a YAML locale file once a Contract class serves several schemas across a team. Because dry-validation's schema processor keeps structural checks separate from rule-level business logic, overrides on filled, non-empty, or type-mismatched keys stay independent of the validation applied to age or format.

Pin the gem in your Gemfile to a fixed dry-validation version, the dry-rb GitHub changelog has flagged locale-key renames between minor releases (a September note under Types::Params is one real example) that break specs silently if you skip it. Test messages the way you'd want any params contract tested: assert on the exact result.errors.to_h value, not just success or failure.

Pattern matching on dry-monads result

A Contract#call in dry-validation always returns a Dry::Validation::Result, and that object responds to success? and failure? the same way any dry-monads Result does, so you can pattern-match on it instead of branching on booleans.

case NewUserContract.new.call(params)
in { success: true }
 Success
in { errors: { name: [String => msg] } }
 Failure(msg)
end

This matters most once a Contract class sits behind a Rack endpoint: matching on Result shape, rather than inspecting errors.to_h, keeps controller code type-safe end to end. dry-validation's changelog documents this Result-based contract as stable since the September 2019 1.0 release on dry-rb/GitHub, ahead of the schemas and rule-block split covered earlier.

Building custom macros for reusable rule logic

Custom macros are how dry-validation solves the one thing ActiveModel concerns never do cleanly: sharing a rule block's logic across contracts without inheritance or include. You register a macro once and call it by name inside any rule block, with its own dependencies injected at registration time.

class ApplicationContract < Dry::Validation::Contract
 register_macro(:adult_age) do
 key.failure("must be 18+") if values[:age] < 18
 end
end

class SignupContract < ApplicationContract
 schema { required(:age).filled(:integer) }
 rule(:age).validate(:adult_age)
end

Every contract that inherits from ApplicationContract gets the macro for free, no copy-pasted rule blocks, no mixin ordering bugs. That's the gap we kept hitting on the Rails migration: ActiveModel validators duplicate the same age-check proc across five models, while one macro covers all five contracts.

Check the changelog before relying on macro composition in production: the registration API stabilized late, and older gemfile-pinned versions behave differently.

Injecting external dependencies into validation rules

The Contract class takes constructor options with option :name, which makes any object, a repository, a rate limiter, a fraud-check client, available inside rule blocks without a service locator or global state. This is dependency injection in its plainest form: pass the dependency in at initialization, reference it by name in the rule.

class OrderContract < Dry::Validation::Contract
 option :inventory_client

 params do
 required(:sku).filled(:string)
 required(:quantity).filled(:integer)
 end

 rule(:sku, :quantity) do
 unless inventory_client.available?(values[:sku], values[:quantity])
 key.failure('insufficient stock')
 end
 end
end

Instantiate it per request: OrderContract.new(inventory_client: InventoryClient.new). Each contract instance carries its own dependency, so a spec can inject a stub client with zero mocking of the class itself, which is where dry-validation quietly outperforms ActiveModel: injected collaborators, not before_validation callbacks reaching into globals for state.

Dry-validation vs ActiveModel::Validations: when to switch

Switch from ActiveModel::Validations to dry-validation when validation logic starts branching on context, not just presence or format. ActiveModel::Validations couples rules to the model instance, so a validates :email, presence: true line runs the same way for a freshly created user and one imported from a CSV, even when the two contexts need different rules.

A Contract class separates that concern by design: schema rules handle structure and coercion, rule blocks handle everything that depends on other fields, injected services, or the request context.

ActiveModel::Validations dry-validation
Coupled to model instance Yes No
Cross-field logic Custom methods, easy to duplicate Rule blocks with explicit key deps
Reuse across contexts (API, import, admin) Requires conditional validators Separate Contract per context
Output Boolean + errors on object Result object (dry-monads compatible)
Extension Custom validator classes Custom macros registered once, reused everywhere

The real gain shows up at scale. On one production Rails codebase, we moved 40 models off ActiveModel::Validations onto Contract classes, consolidating validation logic that had been duplicated across controllers and service objects into shared macros.

If your validations rarely leave the model and never touch external state, ActiveModel::Validations is still less ceremony. Once three or more contexts need different rules for the same params, a Contract class pays for itself within a sprint.

FAQ: Common dry-validation questions

When should I reach for dry-validation?

dry-validation validates input data, request params, form submissions, API payloads, through a Contract class that keeps schema processor types separate from cross-field rule blocks. Schemas handle structure and coercion; rules check business logic like uniqueness or comparisons across keys. Reach for it once validation needs more than presence checks.

What's the practical difference between dry-schema and dry-validation?

dry-schema alone covers structure, type coercion, and key filtering with no business rules attached. dry-validation depends on dry-schema internally, documented in the dry-rb GitHub changelog, worth checking each major version bump, since schema processor types have shifted between releases. dry-validation also ships under the same MIT license as the rest of dry-rb.

How do I integrate dry-validation with rails?

Add dry-validation to your Gemfile, define one Contract class per form or endpoint, and call .call(params) in the controller before ActiveRecord touches anything. Pattern-match the returned Result object with dry-monads for success or failure branches. This keeps validation out of your models entirely.

How do I validate an array of hashes in dry-validation?

Nest a schema inside an array(:hash) type block and declare the same per-item keys you'd use at the top level. For example, required(:items).array(:hash) do required(:name).filled(:string) end checks every item's name value. Use this for bulk imports or nested JSON payloads.

Custom error messages

Override messages through YAML locale files or an inline message: option inside rule blocks, and register custom macros once the same message logic repeats across several contracts. Cover the wording with a spec that asserts on result.errors.to_h. This matters most when API clients parse error text directly.

Dry-validation vs ActiveRecord validations: Which should I use?

ActiveRecord validations suit simple, model-bound presence or format checks tied to one table. dry-validation contracts suit logic that depends on context, a CSV import applying different rules than a web form for the same value. Choose based on how tightly the rule depends on the model instance itself.

Start refactoring your validation layer

Rewriting a validation layer is a scoping problem before it's a coding problem. Decide which contracts move first, which custom macros get shared across teams, and where dry-monads pattern matching replaces scattered conditionals.

Prioritize by risk and reuse: start with the validation library or module carrying the most duplicate logic, move next to contracts shared across services, and leave edge-case rules for last. Teams that map this before touching a Gemfile ship the migration in fewer passes and with less regression risk.

Track three metrics as you go: duplicate validation blocks removed, average time to add a new rule, and defect rate on validated fields. A short weekly report against these numbers turns "we refactored the validation layer" into a measurable claim, not a feeling.

If your team is still deciding whether a full dry-validation rollout is worth the license-free, open source investment, a short, structured exercise beats a long debate.

A Product Design Sprint maps your validation scope, contract boundaries, and rule dependencies in five days, giving you a clear before-and-after value case for the migration rather than a guess. Run a Product Design Sprint with our team and leave with a validated plan, not just an opinion.

If the open question is less about scope and more about whether the underlying product direction is even right, a Product Validation Sprint can get that risk-checked before committing further.

Either sprint gives you another concrete data point before you commit engineering time to the rewrite.

Mateusz Kluge

Mateusz is a self-taught programmer who is not afraid of solving difficult problems. Over about 6 years of his professional work, he has acquired a lot of knowledge about different programming languages and technologies.

We're Netguru

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency.

Let's talk business