Overview
Data validation verifies that input data meets specified requirements before processing or storage. The validation process examines data against defined rules, constraints, and business logic to ensure correctness, completeness, and safety. Applications perform validation at multiple layers: user input, API requests, database constraints, and inter-service communication.
Validation operates on different data characteristics. Type validation checks whether data matches expected types (string, integer, boolean). Format validation verifies structural patterns like email addresses or phone numbers. Range validation ensures numeric values fall within acceptable bounds. Business rule validation enforces domain-specific constraints that vary by application context.
The validation lifecycle occurs at distinct points in data flow. Client-side validation provides immediate feedback to users but cannot be trusted for security. Server-side validation acts as the authoritative checkpoint, rejecting invalid data before processing. Database-level validation serves as a final safety layer, enforcing constraints through schema definitions.
# Basic validation example
class User
attr_accessor :email, :age
def valid?
validate_email && validate_age
end
private
def validate_email
email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
end
def validate_age
age.is_a?(Integer) && age >= 0 && age <= 150
end
end
user = User.new
user.email = "invalid-email"
user.age = 25
user.valid? # => false
Validation failures generate error messages that communicate problems to users or calling systems. Error messages balance specificity with security, providing enough information for correction without exposing system internals. Validation frameworks aggregate multiple errors, allowing users to fix all issues simultaneously rather than discovering problems one at a time.
Key Principles
Data validation operates on the principle of fail-fast: detect problems as early as possible in the processing pipeline. Early detection reduces wasted processing on invalid data and prevents corrupted data from propagating through systems. The validation occurs before expensive operations like database writes or external API calls.
Validation separates concerns between syntax and semantics. Syntactic validation checks format and structure without understanding meaning. A credit card number passes syntactic validation if it contains the correct number of digits and passes the Luhn algorithm. Semantic validation verifies the card is valid, not expired, and has sufficient funds for a transaction.
The validation process distinguishes between different error severities. Hard errors indicate data that cannot be processed under any circumstance. A missing required field represents a hard error. Soft errors represent warnings where processing might continue with degraded functionality. A deprecated field format might generate a soft error while still accepting the data.
Validation rules exist in multiple forms with different characteristics. Static rules derive from data types and schema definitions, remaining constant across all records. Dynamic rules depend on runtime context, current system state, or relationships with other data. A date field might require static validation for correct format but dynamic validation ensuring the date falls within an available booking period.
Idempotent validation produces identical results when executed multiple times on the same input. Non-idempotent validation depends on external state that might change between invocations. Checking whether an email address matches a pattern is idempotent. Verifying an email address is not already registered requires a database query and is not idempotent.
Validation frameworks compose rules through logical operators. AND composition requires all rules to pass. OR composition succeeds if any rule passes. XOR composition enforces mutual exclusivity. Complex validation logic combines these operators in nested structures.
# Demonstrating validation composition
class OrderValidator
def validate(order)
required_fields(order) &&
valid_quantities(order) &&
(guest_checkout?(order) || registered_user?(order))
end
def required_fields(order)
order.items.any? && order.total.positive?
end
def valid_quantities(order)
order.items.all? { |item| item.quantity > 0 }
end
def guest_checkout?(order)
order.email.present? && order.shipping_address.present?
end
def registered_user?(order)
order.user_id.present?
end
end
Validation maintains the single responsibility principle by separating validation logic from business logic. A model class should not contain complex validation code mixed with data manipulation methods. Validator objects encapsulate validation rules, making them reusable and testable independently.
The principle of least surprise guides validation behavior. Validation rules should align with user expectations based on field labels and context. A field labeled "Optional" should not trigger required field errors. Date fields should accept common date formats rather than requiring a single obscure format.
Ruby Implementation
Ruby provides validation through multiple layers: language-level type checking, library-based validation, and framework conventions. Ruby's dynamic typing means validation occurs at runtime rather than compile time, requiring explicit validation code.
ActiveModel provides the standard validation framework used across Ruby applications. Classes include the ActiveModel::Validations module to gain validation functionality. Validators declare rules using class-level macros that generate validation methods.
class Article
include ActiveModel::Validations
attr_accessor :title, :body, :author, :category, :published_at
validates :title, presence: true, length: { minimum: 5, maximum: 200 }
validates :body, presence: true, length: { minimum: 100 }
validates :author, presence: true
validates :category, inclusion: { in: %w[tech business science] }
validates :published_at, presence: true, if: :published?
validate :published_date_not_in_future
def published?
# Custom logic to determine if article is published
@published == true
end
private
def published_date_not_in_future
if published_at && published_at > Time.current
errors.add(:published_at, "cannot be in the future")
end
end
end
article = Article.new
article.title = "Tech"
article.valid? # => false
article.errors.full_messages
# => ["Title is too short (minimum is 5 characters)",
# "Body can't be blank",
# "Author can't be blank"]
Custom validators extend the validation framework for reusable validation logic. Validators inherit from ActiveModel::EachValidator and implement the validate_each method. Custom validators accept options passed in the validation declaration.
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
record.errors.add(attribute, options[:message] || "is not a valid email")
end
end
end
class User
include ActiveModel::Validations
attr_accessor :email, :backup_email
validates :email, presence: true, email: true
validates :backup_email, email: true, allow_blank: true
end
user = User.new(email: "invalid")
user.valid? # => false
user.errors[:email] # => ["is not a valid email"]
Conditional validation uses :if and :unless options to apply rules based on runtime conditions. Conditions can be method names, lambda expressions, or Proc objects. Multiple validators can share the same condition using the with_options method.
class CreditCard
include ActiveModel::Validations
attr_accessor :number, :cvv, :expiry_month, :expiry_year, :billing_zip
validates :number, presence: true, length: { is: 16 }
validates :cvv, presence: true, length: { is: 3 }
validates :expiry_month, inclusion: { in: 1..12 }
validates :expiry_year,
numericality: { greater_than_or_equal_to: Date.current.year }
validates :billing_zip,
presence: true,
if: -> { requires_billing_address? }
validate :card_not_expired
private
def requires_billing_address?
# Logic to determine if billing address is required
true
end
def card_not_expired
if expiry_year && expiry_month
expiry = Date.new(expiry_year, expiry_month, -1)
if expiry < Date.current
errors.add(:base, "Credit card has expired")
end
end
end
end
Validation contexts allow different rules for different scenarios. The same model might require different validation for creation versus update operations. Contexts are specified when calling valid? or save methods.
class Invoice
include ActiveModel::Validations
attr_accessor :draft, :number, :date, :total, :approved_by
validates :number, presence: true, on: :publish
validates :date, presence: true, on: :publish
validates :total, numericality: { greater_than: 0 }, on: :publish
validates :approved_by, presence: true, on: :finalize
def publish
valid?(:publish)
end
def finalize
valid?(:finalize)
end
end
invoice = Invoice.new
invoice.draft = true
invoice.valid? # => true (no default validations)
invoice.valid?(:publish) # => false (requires number, date, total)
Validation error objects provide structured access to validation failures. The errors collection stores messages keyed by attribute names. Error messages support internationalization through I18n, allowing translation of error text.
class Product
include ActiveModel::Validations
attr_accessor :name, :price, :stock
validates :name, presence: true, length: { maximum: 100 }
validates :price, numericality: { greater_than: 0 }
validates :stock, numericality: { greater_than_or_equal_to: 0, only_integer: true }
end
product = Product.new
product.name = ""
product.price = -10
product.stock = 5.5
product.valid? # => false
# Accessing errors
product.errors.count # => 3
product.errors[:name] # => ["can't be blank"]
product.errors[:price] # => ["must be greater than 0"]
product.errors[:stock] # => ["must be an integer"]
# Full messages
product.errors.full_messages
# => ["Name can't be blank",
# "Price must be greater than 0",
# "Stock must be an integer"]
# Checking specific attributes
product.errors.include?(:name) # => true
Practical Examples
Validating user registration demonstrates multi-field validation with cross-field dependencies. Password confirmation requires comparing two fields. Email uniqueness requires database queries. Terms acceptance represents boolean validation.
class UserRegistration
include ActiveModel::Validations
attr_accessor :email, :username, :password, :password_confirmation,
:age, :terms_accepted, :newsletter
validates :email,
presence: true,
email: true,
uniqueness: true
validates :username,
presence: true,
length: { minimum: 3, maximum: 20 },
format: { with: /\A[a-zA-Z0-9_]+\z/ }
validates :password,
presence: true,
length: { minimum: 8 },
format: {
with: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: "must include uppercase, lowercase, and digit"
}
validates :password_confirmation,
presence: true
validates_confirmation_of :password
validates :age,
numericality: { only_integer: true, greater_than_or_equal_to: 13 }
validates :terms_accepted,
acceptance: { message: "must be accepted to continue" }
validate :professional_email_for_minors
private
def professional_email_for_minors
if age && age < 18
free_providers = %w[gmail.com yahoo.com hotmail.com]
domain = email.to_s.split('@').last
if free_providers.include?(domain)
errors.add(:email, "must be a school or institutional email for users under 18")
end
end
end
end
registration = UserRegistration.new(
email: "student@gmail.com",
username: "jr",
password: "pass",
password_confirmation: "pass",
age: 15,
terms_accepted: false
)
registration.valid? # => false
registration.errors.full_messages
# => ["Username is too short (minimum is 3 characters)",
# "Password is too short (minimum is 8 characters)",
# "Password must include uppercase, lowercase, and digit",
# "Terms accepted must be accepted to continue",
# "Email must be a school or institutional email for users under 18"]
API request validation handles complex nested parameters with varying requirements. Optional parameters require validation only when present. Array parameters need validation for each element. File uploads require size and type constraints.
class ApiRequestValidator
include ActiveModel::Validations
attr_accessor :endpoint, :method, :headers, :body, :attachments
validates :endpoint, presence: true, format: { with: %r{\A/[a-z0-9/_-]*\z} }
validates :method, inclusion: { in: %w[GET POST PUT PATCH DELETE] }
validates :headers, presence: true
validate :content_type_header_present
validate :body_size_limit
validate :attachments_valid
MAX_BODY_SIZE = 10.megabytes
MAX_ATTACHMENT_SIZE = 25.megabytes
ALLOWED_MIME_TYPES = %w[
image/jpeg image/png image/gif
application/pdf application/msword
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].freeze
private
def content_type_header_present
unless headers.is_a?(Hash) && headers['Content-Type']
errors.add(:headers, "must include Content-Type")
end
end
def body_size_limit
if body && body.bytesize > MAX_BODY_SIZE
errors.add(:body, "exceeds maximum size of #{MAX_BODY_SIZE / 1.megabyte}MB")
end
end
def attachments_valid
return unless attachments.is_a?(Array)
attachments.each_with_index do |attachment, index|
unless attachment.respond_to?(:size) && attachment.respond_to?(:content_type)
errors.add(:attachments, "attachment #{index} is invalid")
next
end
if attachment.size > MAX_ATTACHMENT_SIZE
errors.add(:attachments,
"attachment #{index} exceeds maximum size of #{MAX_ATTACHMENT_SIZE / 1.megabyte}MB")
end
unless ALLOWED_MIME_TYPES.include?(attachment.content_type)
errors.add(:attachments,
"attachment #{index} has unsupported type #{attachment.content_type}")
end
end
end
end
Financial transaction validation enforces business rules around monetary operations. Amount precision matters for currency calculations. Account balance verification prevents overdrafts. Transaction limits prevent fraud. Duplicate detection prevents double-processing.
class TransactionValidator
include ActiveModel::Validations
attr_accessor :account_id, :amount, :currency, :description,
:transaction_type, :idempotency_key, :timestamp
validates :account_id, presence: true
validates :amount,
presence: true,
numericality: { greater_than: 0 }
validates :currency,
presence: true,
inclusion: { in: %w[USD EUR GBP JPY] }
validates :transaction_type,
inclusion: { in: %w[deposit withdrawal transfer] }
validates :idempotency_key,
presence: true,
uniqueness: true
validates :timestamp,
presence: true
validate :amount_precision
validate :sufficient_balance, if: -> { transaction_type == 'withdrawal' }
validate :within_daily_limit
validate :not_duplicate_transaction
validate :timestamp_not_future
MAX_DAILY_WITHDRAWAL = 10_000
private
def amount_precision
if amount
decimal_places = amount.to_s.split('.').last.length rescue 0
if decimal_places > 2
errors.add(:amount, "cannot have more than 2 decimal places")
end
end
end
def sufficient_balance
account = Account.find(account_id)
if account.balance < amount
errors.add(:amount,
"insufficient balance (available: #{account.balance} #{currency})")
end
rescue ActiveRecord::RecordNotFound
errors.add(:account_id, "not found")
end
def within_daily_limit
if transaction_type == 'withdrawal'
today_withdrawals = Transaction.where(
account_id: account_id,
transaction_type: 'withdrawal',
created_at: Time.current.beginning_of_day..Time.current.end_of_day
).sum(:amount)
if today_withdrawals + amount > MAX_DAILY_WITHDRAWAL
errors.add(:amount,
"exceeds daily withdrawal limit (used: #{today_withdrawals}, limit: #{MAX_DAILY_WITHDRAWAL})")
end
end
end
def not_duplicate_transaction
duplicate = Transaction.find_by(idempotency_key: idempotency_key)
if duplicate && duplicate.created_at > 1.hour.ago
errors.add(:idempotency_key, "duplicate transaction detected")
end
end
def timestamp_not_future
if timestamp && timestamp > Time.current
errors.add(:timestamp, "cannot be in the future")
end
end
end
Form input validation handles user-submitted data with specific formatting requirements. Phone numbers accept multiple formats but standardize storage. Date parsing accommodates different input styles. File uploads verify file types through content inspection rather than extension checking.
class ContactFormValidator
include ActiveModel::Validations
attr_accessor :name, :email, :phone, :message, :preferred_contact_date,
:contact_method, :attachment
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
validates :email, presence: true, email: true
validates :message, presence: true, length: { minimum: 10, maximum: 5000 }
validates :contact_method, inclusion: { in: %w[email phone either] }
validate :phone_format
validate :phone_required_for_phone_contact
validate :preferred_contact_date_format
validate :attachment_content_type
private
def phone_format
return if phone.blank?
# Remove common formatting characters
cleaned = phone.gsub(/[\s\-\(\)\.+]/, '')
unless cleaned =~ /\A\d{10,15}\z/
errors.add(:phone, "must be a valid phone number")
end
end
def phone_required_for_phone_contact
if contact_method == 'phone' && phone.blank?
errors.add(:phone, "required when phone contact is selected")
end
end
def preferred_contact_date_format
return if preferred_contact_date.blank?
begin
date = Date.parse(preferred_contact_date.to_s)
if date < Date.current
errors.add(:preferred_contact_date, "must be today or in the future")
end
rescue ArgumentError
errors.add(:preferred_contact_date, "is not a valid date")
end
end
def attachment_content_type
return unless attachment
# Check actual file content, not just extension
return unless attachment.respond_to?(:read)
file_data = attachment.read(12)
attachment.rewind
magic_numbers = {
"\xFF\xD8\xFF" => 'image/jpeg',
"\x89PNG\r\n\x1A\n" => 'image/png',
"GIF89a" => 'image/gif',
"GIF87a" => 'image/gif',
"%PDF" => 'application/pdf'
}
detected_type = magic_numbers.find { |magic, _| file_data.start_with?(magic) }&.last
unless detected_type
errors.add(:attachment, "must be an image or PDF file")
end
end
end
Security Implications
Validation functions as the primary defense against injection attacks. SQL injection occurs when unvalidated input becomes part of SQL queries. Command injection happens when user input reaches system shell execution. Validation must sanitize or reject input containing shell metacharacters, SQL operators, or script tags.
class SecureInputValidator
include ActiveModel::Validations
attr_accessor :search_query, :filename, :command_args
validate :no_sql_injection
validate :safe_filename
validate :safe_command_args
SQL_PATTERNS = [
/(\b(union|select|insert|update|delete|drop|create|alter|exec|execute)\b)/i,
/(--|\#|\/\*|\*\/)/, # SQL comment patterns
/(['";])/ # Quote characters
].freeze
SHELL_METACHARACTERS = /[;&|`$<>(){}[\]!]/
private
def no_sql_injection
return if search_query.blank?
SQL_PATTERNS.each do |pattern|
if search_query =~ pattern
errors.add(:search_query, "contains potentially dangerous characters")
break
end
end
end
def safe_filename
return if filename.blank?
# Check for path traversal
if filename.include?('..') || filename.include?('/')
errors.add(:filename, "cannot contain path components")
end
# Check for null bytes
if filename.include?("\x00")
errors.add(:filename, "cannot contain null bytes")
end
# Whitelist approach for allowed characters
unless filename =~ /\A[a-zA-Z0-9_\-\.]+\z/
errors.add(:filename, "contains invalid characters")
end
end
def safe_command_args
return if command_args.blank?
if command_args.is_a?(String) && command_args =~ SHELL_METACHARACTERS
errors.add(:command_args, "contains shell metacharacters")
elsif command_args.is_a?(Array)
command_args.each do |arg|
if arg.to_s =~ SHELL_METACHARACTERS
errors.add(:command_args, "contains shell metacharacters")
break
end
end
end
end
end
Cross-site scripting (XSS) prevention requires validating and sanitizing HTML input. User-generated content must not contain malicious JavaScript. Validation can strip HTML tags entirely, whitelist safe tags, or escape special characters. The validation strategy depends on whether HTML formatting is required in the field.
class XssPreventionValidator
include ActiveModel::Validations
attr_accessor :comment, :bio, :html_content
validate :no_script_tags_in_comment
validate :safe_html_in_bio
validate :sanitize_html_content
SCRIPT_PATTERN = /<script\b[^>]*>(.*?)<\/script>/mi
UNSAFE_PATTERNS = [
/on\w+\s*=/i, # Event handlers like onclick=
/javascript:/i, # javascript: protocol
/<iframe/i, # iframe tags
/<embed/i, # embed tags
/<object/i # object tags
].freeze
ALLOWED_TAGS = %w[p br strong em ul ol li a].freeze
private
def no_script_tags_in_comment
return if comment.blank?
if comment =~ SCRIPT_PATTERN
errors.add(:comment, "cannot contain script tags")
end
UNSAFE_PATTERNS.each do |pattern|
if comment =~ pattern
errors.add(:comment, "contains potentially malicious content")
break
end
end
end
def safe_html_in_bio
return if bio.blank?
# Extract all HTML tags
tags = bio.scan(/<(\w+)/).flatten.map(&:downcase)
disallowed = tags - ALLOWED_TAGS
if disallowed.any?
errors.add(:bio, "contains disallowed HTML tags: #{disallowed.join(', ')}")
end
end
def sanitize_html_content
return if html_content.blank?
# Use Rails sanitize helper or similar
sanitized = ActionController::Base.helpers.sanitize(
html_content,
tags: ALLOWED_TAGS,
attributes: %w[href]
)
if sanitized != html_content
errors.add(:html_content, "contains unsafe HTML that would be stripped")
end
end
end
Mass assignment vulnerabilities occur when validation does not restrict which attributes can be updated. An attacker might add admin=true to a request payload. Strong parameters in Rails provide protection, but validation should verify that sensitive fields cannot be modified through user input.
class MassAssignmentProtection
include ActiveModel::Validations
attr_accessor :attributes, :current_user, :context
validate :no_protected_attributes
validate :role_escalation_prevented
PROTECTED_ATTRIBUTES = %w[
id created_at updated_at
admin role permissions
account_balance credit_limit
password_digest authentication_token
].freeze
private
def no_protected_attributes
return unless attributes.is_a?(Hash)
protected_found = attributes.keys.map(&:to_s) & PROTECTED_ATTRIBUTES
if protected_found.any?
errors.add(:base, "Cannot modify protected attributes: #{protected_found.join(', ')}")
end
end
def role_escalation_prevented
return unless attributes.is_a?(Hash)
if attributes.key?('role') || attributes.key?(:role)
unless current_user&.admin?
errors.add(:role, "cannot be modified without admin privileges")
end
end
end
end
Rate limiting validation prevents abuse by limiting request frequency. Validation checks whether the user or IP address has exceeded allowed request counts within a time window. Failed login attempts require stricter limits than general API requests.
class RateLimitValidator
include ActiveModel::Validations
attr_accessor :user_id, :ip_address, :action, :timestamp
validate :within_rate_limit
RATE_LIMITS = {
'api_request' => { count: 1000, window: 1.hour },
'login_attempt' => { count: 5, window: 15.minutes },
'password_reset' => { count: 3, window: 1.hour },
'email_send' => { count: 10, window: 1.hour }
}.freeze
private
def within_rate_limit
limit_config = RATE_LIMITS[action]
return unless limit_config
cache_key = "rate_limit:#{action}:#{identifier}"
count = Rails.cache.read(cache_key) || 0
if count >= limit_config[:count]
errors.add(:base,
"Rate limit exceeded. Try again in #{time_until_reset(cache_key, limit_config[:window])}")
end
end
def identifier
user_id || ip_address
end
def time_until_reset(cache_key, window)
ttl = Rails.cache.fetch("#{cache_key}:ttl") { Time.current + window }
remaining = ttl - Time.current
"#{(remaining / 60).ceil} minutes"
end
end
Common Patterns
The validator object pattern extracts validation logic into dedicated classes. Validator objects separate validation concerns from model classes, improving testability and reusability. Each validator handles a specific validation scenario and can be composed with other validators.
# Validator object pattern
class EmailValidator
def initialize(email)
@email = email
end
def valid?
format_valid? && deliverable?
end
def errors
@errors ||= []
end
private
def format_valid?
unless @email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
errors << "Invalid email format"
return false
end
true
end
def deliverable?
domain = @email.split('@').last
# Check MX records exist for domain
require 'resolv'
mx_records = Resolv::DNS.open { |dns| dns.getresources(domain, Resolv::DNS::Resource::IN::MX) }
if mx_records.empty?
errors << "Email domain has no mail servers"
return false
end
true
rescue StandardError => e
errors << "Could not verify email deliverability"
false
end
end
# Usage
validator = EmailValidator.new("user@example.com")
if validator.valid?
# Proceed with email
else
puts validator.errors
end
The form object pattern encapsulates complex multi-model validation. Forms often span multiple database tables, requiring coordinated validation across related models. Form objects validate the entire operation before persisting any changes.
class OrderForm
include ActiveModel::Validations
attr_accessor :user_id, :shipping_address_attributes, :billing_address_attributes,
:payment_method_attributes, :items_attributes
validates :user_id, presence: true
validates :items_attributes, presence: true
validate :valid_addresses
validate :valid_payment_method
validate :valid_items
validate :inventory_available
def save
return false unless valid?
ActiveRecord::Base.transaction do
@order = Order.create!(user_id: user_id)
create_addresses
create_payment_method
create_items
@order
end
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
private
def valid_addresses
shipping = AddressValidator.new(shipping_address_attributes)
unless shipping.valid?
errors.add(:shipping_address, shipping.errors.full_messages.join(', '))
end
billing = AddressValidator.new(billing_address_attributes)
unless billing.valid?
errors.add(:billing_address, billing.errors.full_messages.join(', '))
end
end
def valid_payment_method
validator = PaymentMethodValidator.new(payment_method_attributes)
unless validator.valid?
errors.add(:payment_method, validator.errors.full_messages.join(', '))
end
end
def valid_items
return unless items_attributes.is_a?(Array)
items_attributes.each_with_index do |item, index|
unless item[:product_id] && item[:quantity]
errors.add(:items, "Item #{index} is incomplete")
end
end
end
def inventory_available
return unless items_attributes.is_a?(Array)
items_attributes.each do |item|
product = Product.find(item[:product_id])
if product.stock < item[:quantity]
errors.add(:items,
"Insufficient stock for #{product.name} (available: #{product.stock})")
end
end
rescue ActiveRecord::RecordNotFound
errors.add(:items, "Invalid product ID")
end
def create_addresses
@order.create_shipping_address!(shipping_address_attributes)
@order.create_billing_address!(billing_address_attributes)
end
def create_payment_method
@order.create_payment_method!(payment_method_attributes)
end
def create_items
items_attributes.each do |item|
@order.items.create!(
product_id: item[:product_id],
quantity: item[:quantity],
price: Product.find(item[:product_id]).price
)
end
end
end
The specification pattern defines business rules as composable objects. Specifications test whether objects satisfy criteria and can be combined using logical operators. This pattern works well for complex conditional validation.
class Specification
def and(other)
AndSpecification.new(self, other)
end
def or(other)
OrSpecification.new(self, other)
end
def not
NotSpecification.new(self)
end
end
class AndSpecification < Specification
def initialize(left, right)
@left = left
@right = right
end
def satisfied_by?(candidate)
@left.satisfied_by?(candidate) && @right.satisfied_by?(candidate)
end
end
class OrSpecification < Specification
def initialize(left, right)
@left = left
@right = right
end
def satisfied_by?(candidate)
@left.satisfied_by?(candidate) || @right.satisfied_by?(candidate)
end
end
class NotSpecification < Specification
def initialize(spec)
@spec = spec
end
def satisfied_by?(candidate)
!@spec.satisfied_by?(candidate)
end
end
# Concrete specifications
class PremiumUserSpec < Specification
def satisfied_by?(user)
user.subscription_level == 'premium'
end
end
class ActiveUserSpec < Specification
def satisfied_by?(user)
user.last_login_at > 30.days.ago
end
end
class VerifiedEmailSpec < Specification
def satisfied_by?(user)
user.email_verified_at.present?
end
end
# Usage
premium_and_active = PremiumUserSpec.new.and(ActiveUserSpec.new)
eligible_for_feature = premium_and_active.and(VerifiedEmailSpec.new)
user = User.find(123)
if eligible_for_feature.satisfied_by?(user)
# Grant access to feature
end
The null object pattern handles optional validation cleanly. Instead of checking for nil before validation, use null objects that always pass validation. This eliminates conditional logic scattered throughout validation code.
class AddressValidator
def initialize(address)
@address = address || NullAddress.new
end
def valid?
@address.valid?
end
def errors
@address.errors
end
end
class NullAddress
def valid?
true
end
def errors
[]
end
def present?
false
end
end
# Usage - no nil checks needed
address_validator = AddressValidator.new(user.address)
if address_validator.valid?
# Proceed
else
# Handle errors
end
Error Handling & Edge Cases
Validation errors require careful handling to provide useful feedback while protecting system internals. Error messages should identify the problem without exposing database schemas, internal variable names, or implementation details. Generic messages like "Validation failed" provide no actionable information. Specific messages like "Email address is invalid" help users correct problems.
class ValidationErrorHandler
def self.format_errors(record)
return {} unless record.respond_to?(:errors)
formatted = {}
record.errors.each do |error|
attribute = error.attribute
message = error.message
# Remove technical prefixes from attribute names
friendly_attribute = attribute.to_s.humanize
formatted[attribute] ||= []
formatted[attribute] << "#{friendly_attribute} #{message}"
end
formatted
end
def self.api_response(record)
{
status: 'error',
message: 'Validation failed',
errors: format_errors(record)
}
end
def self.log_validation_failure(record, context = {})
Rails.logger.warn({
event: 'validation_failure',
model: record.class.name,
errors: record.errors.full_messages,
context: context
}.to_json)
end
end
# Usage in API controller
def create
@user = User.new(user_params)
if @user.valid?
@user.save!
render json: @user, status: :created
else
ValidationErrorHandler.log_validation_failure(@user, { action: 'create' })
render json: ValidationErrorHandler.api_response(@user), status: :unprocessable_entity
end
end
Cascading validation failures occur when one validation failure prevents subsequent validations from running. Required field validation must run before format validation. Type checking must precede range validation. Organizing validations in dependency order prevents confusing error messages.
class Document
include ActiveModel::Validations
attr_accessor :title, :file_path, :file_size, :created_at
# Validate presence first
validates :title, presence: true
validates :file_path, presence: true
validates :file_size, presence: true
# Then validate format/type
validates :title,
length: { minimum: 3, maximum: 200 },
if: :title?
validates :file_size,
numericality: { only_integer: true, greater_than: 0 },
if: :file_size?
# Finally validate business rules
validate :file_exists, if: :file_path?
validate :file_size_reasonable, if: :file_size_valid?
MAX_FILE_SIZE = 100.megabytes
private
def title?
title.present?
end
def file_path?
file_path.present?
end
def file_size?
file_size.present?
end
def file_size_valid?
file_size.present? && file_size.is_a?(Integer) && file_size > 0
end
def file_exists
unless File.exist?(file_path)
errors.add(:file_path, "file not found at specified path")
end
end
def file_size_reasonable
if file_size > MAX_FILE_SIZE
errors.add(:file_size,
"exceeds maximum allowed size of #{MAX_FILE_SIZE / 1.megabyte}MB")
end
end
end
Transient validation failures happen when validation passes initially but fails during processing. A unique constraint might pass validation but fail during database insertion if another process creates a conflicting record. Network timeouts might prevent external validation checks from completing. Validation code must handle these scenarios gracefully.
class UniqueEmailValidator
def initialize(email, excluding_id: nil)
@email = email
@excluding_id = excluding_id
end
def valid?
check_uniqueness
rescue ActiveRecord::StatementInvalid => e
# Database connection issue
Rails.logger.error("Database error during email validation: #{e.message}")
@error = "Unable to verify email uniqueness at this time"
false
rescue StandardError => e
# Unexpected error
Rails.logger.error("Unexpected error during email validation: #{e.message}")
@error = "Validation error occurred"
false
end
def error
@error ||= "Email address is already registered"
end
private
def check_uniqueness
query = User.where(email: @email)
query = query.where.not(id: @excluding_id) if @excluding_id
if query.exists?
@error = "Email address is already registered"
return false
end
true
end
end
# Usage with retry logic
def register_user(email, password)
retries = 0
max_retries = 3
begin
validator = UniqueEmailValidator.new(email)
unless validator.valid?
return { success: false, error: validator.error }
end
user = User.create!(email: email, password: password)
{ success: true, user: user }
rescue ActiveRecord::RecordNotUnique
# Race condition - email was registered between validation and save
{ success: false, error: "Email address is already registered" }
rescue ActiveRecord::StatementInvalid => e
retries += 1
if retries < max_retries
sleep(0.5 * retries) # Exponential backoff
retry
else
{ success: false, error: "Unable to complete registration at this time" }
end
end
end
Partial validation allows validating subsets of fields. Wizard-style forms validate different fields at each step. Draft saves validate only completed fields. Context-specific validation runs different rules for different scenarios.
class MultiStepFormValidator
include ActiveModel::Validations
attr_accessor :step, :name, :email, :address, :payment_method
# Step 1 validations
validates :name, presence: true, if: -> { step_active?(:personal_info) }
validates :email, presence: true, email: true, if: -> { step_active?(:personal_info) }
# Step 2 validations
validates :address, presence: true, if: -> { step_active?(:shipping) }
validate :valid_address_format, if: -> { step_active?(:shipping) }
# Step 3 validations
validates :payment_method, presence: true, if: -> { step_active?(:payment) }
validate :valid_payment_method, if: -> { step_active?(:payment) }
STEPS = [:personal_info, :shipping, :payment]
def validate_step(current_step)
@step = current_step
valid?
end
def all_steps_valid?
STEPS.all? { |step| validate_step(step) }
end
private
def step_active?(step_name)
step == step_name || step == :all
end
def valid_address_format
return if address.blank?
required_fields = [:street, :city, :state, :zip]
missing = required_fields.select { |field| address[field].blank? }
if missing.any?
errors.add(:address, "missing required fields: #{missing.join(', ')}")
end
end
def valid_payment_method
return if payment_method.blank?
unless %w[credit_card debit_card paypal].include?(payment_method)
errors.add(:payment_method, "invalid payment type")
end
end
end
# Usage in controller
def update_step
form = MultiStepFormValidator.new(step_params)
if form.validate_step(params[:step].to_sym)
session[:form_data] = form.attributes
redirect_to next_step_path
else
@errors = form.errors
render :edit
end
end
Common Pitfalls
Validating in the wrong layer causes security vulnerabilities and inconsistent behavior. Client-side validation provides user experience benefits but offers no security. Malicious users bypass JavaScript validation by manipulating HTTP requests directly. Server-side validation must repeat all client-side checks.
# WRONG - Trusting client-side validation
class UsersController < ApplicationController
def create
# Dangerous - assumes client sent valid data
@user = User.create!(params[:user])
render json: @user
end
end
# CORRECT - Always validate server-side
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.valid?
@user.save!
render json: @user, status: :created
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:email, :name, :age)
end
end
Validation without whitelisting allows mass assignment vulnerabilities. Accepting all parameters from requests enables attackers to set any field, including protected attributes like admin flags or account balances.
# WRONG - Accepting all parameters
def create
@user = User.new(params[:user]) # Dangerous
@user.save
end
# CORRECT - Whitelist allowed parameters
def create
@user = User.new(user_params)
@user.save
end
private
def user_params
params.require(:user).permit(:email, :name, :age)
end
# For admin-only fields, check authorization
def admin_user_params
if current_user.admin?
params.require(:user).permit(:email, :name, :age, :role, :permissions)
else
params.require(:user).permit(:email, :name, :age)
end
end
Regex validation without anchors allows partial matches. A regex /\d{3}-\d{4}/ matches "phone: 555-1234 xxx" because the pattern appears somewhere in the string. Always use \A and \z anchors for complete string matching.
# WRONG - Partial matching
validates :phone, format: { with: /\d{3}-\d{4}/ }
# Accepts: "my phone is 555-1234 and other text"
# CORRECT - Complete string matching
validates :phone, format: { with: /\A\d{3}-\d{4}\z/ }
# Only accepts: "555-1234"
# WRONG - Using ^ and $ which match line boundaries
validates :input, format: { with: /^valid$/}
# Accepts: "valid\nmalicious_code"
# CORRECT - Using \A and \z for string boundaries
validates :input, format: { with: /\Avalid\z/ }
# Only accepts exactly: "valid"
Validation order matters when validations depend on each other. Validating format before presence causes unclear error messages. Validating derived fields before their dependencies produces incorrect results.
# WRONG - Format validation before presence
validates :email, format: { with: /.../ }
validates :email, presence: true
# Error: "Email is invalid" when email is blank
# CORRECT - Presence first, then format
validates :email, presence: true
validates :email, format: { with: /.../ }, if: :email?
# WRONG - Validating calculated field before its dependencies
validates :total_price, numericality: { greater_than: 0 }
validates :quantity, :unit_price, presence: true
# total_price validation runs before dependencies exist
# CORRECT - Validate dependencies first, then calculated fields
validates :quantity, :unit_price, presence: true
validates :total_price, numericality: { greater_than: 0 }, if: :can_calculate_total?
def can_calculate_total?
quantity.present? && unit_price.present?
end
Ignoring encoding issues in validation causes unexpected failures. String length validation may count bytes instead of characters for multibyte encodings. Regex patterns might fail on non-ASCII input.
# WRONG - Byte length vs character length
validates :name, length: { maximum: 50 }
# "你好世界" is 4 characters but 12 bytes in UTF-8
# CORRECT - Explicitly handle encoding
validates :name, length: { maximum: 50 }
validate :name_character_length
def name_character_length
if name && name.chars.length > 50
errors.add(:name, "is too long (maximum is 50 characters)")
end
end
# For regex validation, use Unicode character classes
validates :username,
format: { with: /\A[\p{L}\p{N}_-]+\z/ } # Matches letters and numbers in any language
# vs /\A[a-zA-Z0-9_-]+\z/ which only matches ASCII
Not handling nil values separately from blank values causes confusion. Empty strings, nil, and false have different meanings but often get conflated in validation.
# WRONG - Treating nil and empty string the same
validates :notes, length: { minimum: 10 }
# Fails on nil with confusing message
# CORRECT - Handle nil explicitly
validates :notes, length: { minimum: 10 }, allow_nil: true
# Or require presence if nil is invalid
validates :notes, presence: true
validates :notes, length: { minimum: 10 }, if: :notes?
# Be careful with boolean values
# WRONG
validates :terms_accepted, presence: true
# Fails when terms_accepted is explicitly false
# CORRECT
validates :terms_accepted, inclusion: { in: [true] }
# Only accepts explicit true value
Database-level validation mismatches cause save failures after successful validation. The model validates successfully but the database rejects the record due to stricter constraints.
# Database has: add_index :users, :email, unique: true
# But model only has:
validates :email, presence: true
# This creates a race condition:
user = User.new(email: "test@example.com")
user.valid? # => true
user.save # => raises ActiveRecord::RecordNotUnique
# CORRECT - Match database constraints in model
validates :email, presence: true, uniqueness: true
# For complex database constraints, handle exceptions
def create
@user = User.new(user_params)
if @user.valid?
begin
@user.save!
render json: @user, status: :created
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, "has already been taken")
render json: { errors: @user.errors }, status: :unprocessable_entity
end
else
render json: { errors: @user.errors }, status: :unprocessable_entity
end
end
Reference
Built-in Validators
| Validator | Purpose | Example |
|---|---|---|
| presence | Field must not be blank | validates :name, presence: true |
| absence | Field must be blank | validates :legacy_field, absence: true |
| length | String length constraints | validates :title, length: { minimum: 5, maximum: 100 } |
| numericality | Numeric value constraints | validates :age, numericality: { only_integer: true, greater_than: 0 } |
| format | Pattern matching with regex | validates :email, format: { with: /regexp/ } |
| inclusion | Value must be in list | validates :status, inclusion: { in: %w[active inactive] } |
| exclusion | Value must not be in list | validates :subdomain, exclusion: { in: %w[www admin] } |
| uniqueness | Value must be unique | validates :email, uniqueness: true |
| confirmation | Two fields must match | validates :password, confirmation: true |
| acceptance | Boolean must be accepted | validates :terms, acceptance: true |
Validation Options
| Option | Purpose | Example |
|---|---|---|
| allow_nil | Skip validation if value is nil | validates :notes, length: { minimum: 10 }, allow_nil: true |
| allow_blank | Skip validation if value is blank | validates :title, format: { with: /.../ }, allow_blank: true |
| if | Conditional validation with method or lambda | validates :phone, presence: true, if: :phone_required? |
| unless | Inverse conditional validation | validates :ssn, presence: true, unless: :international? |
| on | Validation context | validates :number, presence: true, on: :publish |
| message | Custom error message | validates :age, numericality: { greater_than: 0, message: "must be positive" } |
| strict | Raise exception on failure | validates :email, presence: true, strict: true |
Validation Error Methods
| Method | Purpose | Return Value |
|---|---|---|
| valid? | Check if model is valid | Boolean |
| invalid? | Check if model is invalid | Boolean |
| errors | Access errors collection | ActiveModel::Errors object |
| errors.add | Add error to attribute | Adds error message |
| errors.count | Count of errors | Integer |
| errors.full_messages | Array of complete error messages | Array of strings |
| errors.clear | Remove all errors | Empties errors collection |
| errors.include? | Check if attribute has errors | Boolean |
| errors.messages | Hash of errors by attribute | Hash with array values |
Numericality Options
| Option | Purpose | Example |
|---|---|---|
| greater_than | Value must exceed number | numericality: { greater_than: 0 } |
| greater_than_or_equal_to | Value must be at least number | numericality: { greater_than_or_equal_to: 18 } |
| less_than | Value must be below number | numericality: { less_than: 100 } |
| less_than_or_equal_to | Value must be at most number | numericality: { less_than_or_equal_to: 65 } |
| equal_to | Value must equal number exactly | numericality: { equal_to: 42 } |
| other_than | Value must not equal number | numericality: { other_than: 0 } |
| odd | Value must be odd | numericality: { odd: true } |
| even | Value must be even | numericality: { even: true } |
| only_integer | Value must be integer | numericality: { only_integer: true } |
Length Options
| Option | Purpose | Example |
|---|---|---|
| minimum | Minimum length | length: { minimum: 5 } |
| maximum | Maximum length | length: { maximum: 100 } |
| in | Length range | length: { in: 5..100 } |
| is | Exact length | length: { is: 16 } |
| within | Length range (alias for in) | length: { within: 5..100 } |
Format Validation Patterns
| Pattern | Purpose | Regex Example |
|---|---|---|
| Email address | Valid email format | /\A[\w+-.]+@[a-z\d-]+(.[a-z\d-]+)*.[a-z]+\z/i |
| URL | Web address format | /\Ahttps?://[\S]+\z/ |
| Phone number | Numeric phone format | /\A\d{10}\z/ |
| Alphanumeric | Letters and numbers only | /\A[a-zA-Z0-9]+\z/ |
| Slug | URL-safe string | /\A[a-z0-9-]+\z/ |
| Hex color | Hexadecimal color code | /\A#([A-Fa-f0-9]{6} |
| IPv4 address | IP address format | /\A\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}\z/ |
| Credit card | Credit card number | /\A\d{13,16}\z/ |
Custom Validator Template
class CustomValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless condition_met?(value)
record.errors.add(attribute,
options[:message] || "is invalid")
end
end
private
def condition_met?(value)
# Validation logic
true
end
end
# Usage
class Model
include ActiveModel::Validations
validates :field, custom: true
end
Conditional Validation Template
# Method symbol
validates :field, presence: true, if: :method_name
# Lambda with record parameter
validates :field, presence: true, if: ->(record) { record.condition? }
# Proc with record parameter
validates :field, presence: true,
if: Proc.new { |record| record.condition? }
# String evaluation (avoid - security risk)
validates :field, presence: true, if: "condition?"
# Multiple conditions with block
validates :field, presence: true, if: -> { condition1? && condition2? }
Validation Context Usage
# Define context-specific validations
validates :field, presence: true, on: :create
validates :field, presence: true, on: :update
validates :field, presence: true, on: :custom_context
# Validate with context
model.valid?(:custom_context)
# Save with context
model.save(context: :custom_context)
Common Validation Combinations
# Required field with format
validates :email,
presence: true,
format: { with: /regexp/ },
uniqueness: true
# Numeric field with range
validates :age,
presence: true,
numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 150
}
# Optional field with conditional constraints
validates :phone,
format: { with: /regexp/ },
allow_blank: true,
if: :phone_provided?
# Complex conditional validation
validates :passport_number,
presence: true,
if: -> { international? && !has_visa? }