Creating a Validation Contract
Define type-safe, composable validation rules with dry-validation contracts.
Quick Reference
- Location:
slices/<slice>/contracts/or inline in the operation. - Structure:
Dry::Validation::Contractwithschemaandruleblocks. - Usage:
contract.call(params)returns a result with.success?and.errors. - Rule: Every operation that accepts user input uses a contract.
HARD-GATE
Write test → Run test → Verify it FAILS → Implement → Verify it PASSES
DO NOT use raw hashes as operation input. Validate through a contract first.
DO NOT put validation logic in actions. Contracts are the single source of validation.
Core Process
Pattern reference: See CONTRACT_PATTERNS.md for examples of custom predicates, nested schemas, composed contracts, and testing patterns.
- Define the contract — declare expected fields, types, and rules:
module Users class CreateContract < Dry::Validation::Contract schema do required(:email).filled(:string) required(:name).filled(:string, min_size?: 2) optional(:role).filled(:string, included_in?: %w[admin member]) end rule(:email) do key.failure("must be a valid email") unless value.match?(URI::MailTo::EMAIL_REGEXP) end end end - Use in the operation — call the contract as the first step:
def validate(input) result = CreateContract.new.call(input) result.success? ? Success(result.to_h) : Failure(result.errors.to_h) end - Custom predicates and composed contracts — for reusable validation rules and shared fields (e.g., pagination params). If a
CONTRACT_PATTERNS.mdcompanion file is present in the bundle, consult it for executable examples of custom predicates, composed contracts, i18n error messages, and nested schemas. - Test the contract — verify valid and invalid inputs independently from the operation:
RSpec.describe Users::CreateContract do subject(:contract) { described_class.new } context "with valid input" do it "succeeds" do result = contract.call(email: "user@example.com", name: "Alice") expect(result).to be_success expect(result.to_h).to include(email: "user@example.com", name: "Alice") end end context "with invalid input" do it "fails with email error" do result = contract.call(email: "not-an-email", name: "Alice") expect(result).to be_failure expect(result.errors[:email]).to include("must be a valid email") end it "fails when name is too short" do result = contract.call(email: "user@example.com", name: "A") expect(result).to be_failure expect(result.errors[:name]).to be_present end end end
Output Style
- Contract class — complete schema and rule definitions.
- Operation integration — how the operation uses the contract.
- Spec — test valid inputs (Success) and invalid inputs (Failure with specific errors).
- English only unless user requests otherwise.
Integration
| Skill | When to chain |
|---|---|
| create-operation | After defining the contract, use it as the first step of the operation |
| create-action | Contracts validate action params before they reach the operation |