DEV Community

Cover image for Building Custom Ruby on Rails Model Validators in Gems: A Complete Guide
Jordan Hudgens
Jordan Hudgens

Posted on

Building Custom Ruby on Rails Model Validators in Gems: A Complete Guide

If you're building a Ruby gem that integrates with Rails applications, you might want to provide custom validators that developers can use declaratively in their models. In this comprehensive tutorial, we'll walk through building a custom ActiveModel validator in a gem, using the domain_extractor gem's DomainValidator as our case study.

Why Custom Validators?

Custom validators allow gem users to validate model attributes with a simple, declarative syntax:

class User < ApplicationRecord
  validates :website, domain: true
  validates :company_url, domain: { allow_subdomains: true }
end
Enter fullscreen mode Exit fullscreen mode

This is much cleaner than writing custom validation methods in every model, and it encapsulates complex validation logic in a reusable, testable component.

Table of Contents

  1. Understanding ActiveModel::EachValidator
  2. Creating the Validator Class
  3. Adding Railtie for Auto-loading
  4. Common Pitfalls and How to Fix Them
  5. Testing Your Validator
  6. Real-World Example: domain_extractor

1. Understanding ActiveModel::EachValidator

Rails provides ActiveModel::EachValidator as the base class for attribute-specific validators. It handles the iteration over attributes and calls your validation logic for each one.[3][4]

The key method you need to implement is validate_each(record, attribute, value):

  • record: The model instance being validated
  • attribute: The attribute name (as a symbol)
  • value: The current value of the attribute

2. Creating the Validator Class

Let's start by creating a custom validator for domain names. Create a file at lib/your_gem/validators/domain_validator.rb:

module YourGem
  module Validators
    class DomainValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        return if value.blank? && options[:allow_blank]

        unless valid_domain?(value)
          record.errors.add(
            attribute,
            options[:message] || :invalid_domain,
            value: value
          )
        end
      end

      private

      def valid_domain?(value)
        # Your validation logic here
        # For domain_extractor, this uses DomainExtractor.valid?(value)
        YourGem.valid?(value)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • Namespace your validator under your gem's module to avoid conflicts
  • Handle blank values appropriately with allow_blank option
  • Use custom error messages through the options[:message] parameter
  • Keep validation logic separate in private methods for testability

3. Adding Railtie for Auto-loading

To make your validator automatically available in Rails applications, you need to create a Railtie. Create lib/your_gem/railtie.rb:

module YourGem
  class Railtie < ::Rails::Railtie
    initializer 'your_gem.validators' do
      require 'your_gem/validators/domain_validator'
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, in your main gem file (lib/your_gem.rb), add:

require 'your_gem/version'
require 'your_gem/railtie' if defined?(Rails)

module YourGem
  # Your gem code here
end
Enter fullscreen mode Exit fullscreen mode

The if defined?(Rails) check ensures the Railtie only loads when Rails is present, keeping your gem usable in non-Rails contexts.

4. Common Pitfalls and How to Fix Them

Pitfall #1: Incorrect Base Class (Version 0.2.5 Bug)

Wrong:

class DomainValidator < ActiveRecord::Base
  # This won't work!
end
Enter fullscreen mode Exit fullscreen mode

Correct:

class DomainValidator < ActiveModel::EachValidator
  # This is the right base class
end
Enter fullscreen mode Exit fullscreen mode

Why this matters: ActiveRecord::Base is for database-backed models, not validators. Using it as a base class for validators will cause instantiation errors when Rails tries to use your validator.

This was the exact bug in domain_extractor v0.2.5, which was quickly fixed in v0.2.6. The version 0.2.5 was yanked from RubyGems due to this critical error.

Pitfall #2: Not Handling Options Properly

Your validator should respect standard validation options:[4][3]

def validate_each(record, attribute, value)
  # Always check allow_blank first
  return if value.blank? && options[:allow_blank]
  return if value.nil? && options[:allow_nil]

  # Your validation logic
end
Enter fullscreen mode Exit fullscreen mode

Pitfall #3: Forgetting to Require the Validator

Make sure your Railtie explicitly requires the validator file:

initializer 'your_gem.validators' do
  require 'your_gem/validators/domain_validator'
end
Enter fullscreen mode Exit fullscreen mode

5. Testing Your Validator

Create comprehensive tests for your validator:

# spec/validators/domain_validator_spec.rb
require 'spec_helper'

RSpec.describe YourGem::Validators::DomainValidator do
  class TestModel
    include ActiveModel::Validations
    attr_accessor :website
    validates :website, domain: true
  end

  let(:model) { TestModel.new }

  describe 'valid domains' do
    it 'accepts valid domain names' do
      model.website = 'example.com'
      expect(model).to be_valid
    end

    it 'accepts domains with subdomains' do
      model.website = 'blog.example.com'
      expect(model).to be_valid
    end
  end

  describe 'invalid domains' do
    it 'rejects invalid domain formats' do
      model.website = 'not a domain'
      expect(model).not_to be_valid
      expect(model.errors[:website]).to be_present
    end

    it 'rejects domains with invalid characters' do
      model.website = 'exam_ple.com'
      expect(model).not_to be_valid
    end
  end

  describe 'options' do
    class TestModelWithOptions
      include ActiveModel::Validations
      attr_accessor :website
      validates :website, domain: { allow_blank: true }
    end

    it 'respects allow_blank option' do
      model = TestModelWithOptions.new
      model.website = ''
      expect(model).to be_valid
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

6. Real-World Example: domain_extractor

The domain_extractor gem provides a production-ready example of a custom validator. Let's look at how it implements domain validation:

Usage in Rails Models

class Company < ApplicationRecord
  # Basic domain validation
  validates :website, domain: true

  # With options
  validates :blog_url, domain: { 
    allow_blank: true,
    message: 'must be a valid domain name'
  }

  # Multiple validations
  validates :primary_domain, domain: true, presence: true
end
Enter fullscreen mode Exit fullscreen mode

What It Validates

The DomainValidator in domain_extractor checks:

✅ Valid domain format (RFC-compliant)
✅ Proper TLD structure (using Public Suffix List)
✅ Multi-part TLDs (e.g., .co.uk, .com.au)
✅ Subdomain handling
✅ IP address detection

Implementation Highlights

# From domain_extractor
module DomainExtractor
  module Validators
    class DomainValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        return if value.blank? && options[:allow_blank]

        unless DomainExtractor.valid?(value)
          record.errors.add(
            attribute,
            options[:message] || :invalid_domain,
            value: value
          )
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The validator delegates to DomainExtractor.valid?, which performs comprehensive domain validation using the gem's core parsing logic.

Advanced Features

Supporting Multiple Validation Options

class DomainValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank? && options[:allow_blank]

    result = DomainExtractor.parse(value)

    if options[:require_subdomain] && result.subdomain.nil?
      record.errors.add(attribute, :subdomain_required)
      return
    end

    if options[:allowed_tlds] && !options[:allowed_tlds].include?(result.tld)
      record.errors.add(attribute, :invalid_tld)
      return
    end

    unless DomainExtractor.valid?(value)
      record.errors.add(attribute, options[:message] || :invalid_domain)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Custom Error Messages

Support I18n for error messages by adding to your gem:

# config/locales/en.yml
en:
  errors:
    messages:
      invalid_domain: "is not a valid domain name"
      subdomain_required: "must include a subdomain"
      invalid_tld: "has an unsupported top-level domain"
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

When building validators for gems that focus on performance (like domain_extractor):

  1. Cache expensive operations: Parse domains once and reuse results
  2. Minimize allocations: Use frozen strings and constants
  3. Fail fast: Check simple conditions before expensive validation
  4. Benchmark: Validate performance impact on model save operations
class DomainValidator < ActiveModel::EachValidator
  # Cache regex patterns
  DOMAIN_REGEX = /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}\z/i.freeze

  def validate_each(record, attribute, value)
    # Quick format check before expensive parsing
    return record.errors.add(attribute, :invalid_domain) unless value =~ DOMAIN_REGEX

    # Now do expensive validation
    unless DomainExtractor.valid?(value)
      record.errors.add(attribute, :invalid_domain)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Integration with ActiveRecord

Your validator integrates seamlessly with ActiveRecord's validation framework:

class User < ApplicationRecord
  # Combines with other validators
  validates :website, domain: true, presence: true, uniqueness: true

  # Conditional validation
  validates :blog_url, domain: true, if: :blogger?

  # On specific actions
  validates :temp_domain, domain: { allow_blank: true }, on: :create

  # With custom callbacks
  validates :company_domain, domain: true
  before_validation :normalize_domain

  private

  def normalize_domain
    self.company_domain = company_domain&.downcase&.strip
  end
end
Enter fullscreen mode Exit fullscreen mode

The Version 0.2.5 → 0.2.6 Fix

The domain_extractor gem provides an excellent real-world example of catching and fixing a validator bug:

Version 0.2.5 (Yanked)

The initial implementation had a critical error:

class DomainValidator < ActiveRecord::Base  # ❌ Wrong base class!
  def validate_each(record, attribute, value)
    # ... validation logic
  end
end
Enter fullscreen mode Exit fullscreen mode

Problem: This caused ArgumentError when Rails tried to instantiate the validator, because ActiveRecord::Base is for models, not validators.

Version 0.2.6 (Fix)

The fix was simple but critical:

class DomainValidator < ActiveModel::EachValidator  # ✅ Correct!
  def validate_each(record, attribute, value)
    # ... validation logic
  end
end
Enter fullscreen mode Exit fullscreen mode

This demonstrates the importance of:

  • Using the correct base class
  • Thorough testing of Rails integration
  • Quick response to issues (same-day fix and release)
  • Proper gem versioning (yanking broken versions)

Complete File Structure

Here's the recommended structure for your gem with validators:

your_gem/
├── lib/
│   ├── your_gem.rb
│   ├── your_gem/
│   │   ├── version.rb
│   │   ├── railtie.rb
│   │   ├── validators/
│   │   │   └── domain_validator.rb
│   │   └── core_logic.rb
├── spec/
│   ├── validators/
│   │   └── domain_validator_spec.rb
│   └── spec_helper.rb
├── config/
│   └── locales/
│       └── en.yml
└── your_gem.gemspec
Enter fullscreen mode Exit fullscreen mode

Gemspec Configuration

Don't forget to specify Rails as a development dependency:

Gem::Specification.new do |spec|
  spec.name          = "your_gem"
  spec.version       = YourGem::VERSION
  spec.authors       = ["Your Name"]

  # Don't require Rails at runtime (optional usage)
  spec.add_development_dependency "rails", ">= 5.2"
  spec.add_development_dependency "rspec-rails"

  # Your gem's core dependencies
  spec.add_dependency "public_suffix", "~> 6.0"
end
Enter fullscreen mode Exit fullscreen mode

Documentation Best Practices

Include clear examples in your README:

## Rails Integration

domain_extractor provides a custom validator for ActiveRecord models:


class User < ApplicationRecord
  validates :website, domain: true
end

### Options

- `allow_blank`: Skip validation for blank values
- `message`: Custom error message
- `allow_subdomains`: Require or allow subdomains


validates :website, domain: { 
  allow_blank: true,
  message: "must be a valid domain"
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters for OpenSite AI

The domain_extractor gem is part of OpenSite AI's open-source initiative, focused on:

  1. Increasing organic traffic: High-quality, well-documented gems attract users and backlinks
  2. Performance-first design: Every feature, including validators, is optimized for speed
  3. Developer experience: Clean APIs and Rails integration make adoption easy
  4. Community building: Open source contributions grow the ecosystem

Key Takeaways

  1. Use ActiveModel::EachValidator as your base class for attribute validators
  2. Create a Railtie to auto-load validators in Rails applications
  3. Handle standard options like allow_blank and custom messages
  4. Test thoroughly including Rails integration tests
  5. Keep validation logic separate from the validator class for reusability
  6. Document clearly with examples in your README
  7. Version carefully and be ready to yank broken releases

Try It Yourself

Install domain_extractor and see the validator in action:

gem install domain_extractor
Enter fullscreen mode Exit fullscreen mode

Or add to your Gemfile:

gem 'domain_extractor'
Enter fullscreen mode Exit fullscreen mode

Then use it in your models:

class Company < ApplicationRecord
  validates :website, domain: true
end
Enter fullscreen mode Exit fullscreen mode

Resources

Conclusion

Building custom validators for your Ruby gems enhances their value to Rails developers. By following the patterns demonstrated in domain_extractor, you can create powerful, reusable validation logic that integrates seamlessly with Rails' validation framework.

The key is using the right base class (ActiveModel::EachValidator), providing a Railtie for auto-loading, and thoroughly testing your implementation. Learn from real-world examples like the 0.2.5 → 0.2.6 fix to avoid common pitfalls.

Ready to build your own validator? Start by studying the domain_extractor source code and adapt the patterns to your gem's needs.


About OpenSite AI: We're building high-performance, open-source tools for Ruby developers. Check out our other open source projects and join our growing community!

Top comments (0)