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
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
- Understanding ActiveModel::EachValidator
- Creating the Validator Class
- Adding Railtie for Auto-loading
- Common Pitfalls and How to Fix Them
- Testing Your Validator
- 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
Key Points:
- Namespace your validator under your gem's module to avoid conflicts
-
Handle blank values appropriately with
allow_blankoption -
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
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
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
✅ Correct:
class DomainValidator < ActiveModel::EachValidator
# This is the right base class
end
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
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
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
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
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
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
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"
Performance Considerations
When building validators for gems that focus on performance (like domain_extractor):
- Cache expensive operations: Parse domains once and reuse results
- Minimize allocations: Use frozen strings and constants
- Fail fast: Check simple conditions before expensive validation
- 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
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
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
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
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
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
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"
}
Why This Matters for OpenSite AI
The domain_extractor gem is part of OpenSite AI's open-source initiative, focused on:
- Increasing organic traffic: High-quality, well-documented gems attract users and backlinks
- Performance-first design: Every feature, including validators, is optimized for speed
- Developer experience: Clean APIs and Rails integration make adoption easy
- Community building: Open source contributions grow the ecosystem
Key Takeaways
-
Use
ActiveModel::EachValidatoras your base class for attribute validators - Create a Railtie to auto-load validators in Rails applications
-
Handle standard options like
allow_blankand custom messages - Test thoroughly including Rails integration tests
- Keep validation logic separate from the validator class for reusability
- Document clearly with examples in your README
- 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
Or add to your Gemfile:
gem 'domain_extractor'
Then use it in your models:
class Company < ApplicationRecord
validates :website, domain: true
end
Resources
- domain_extractor on GitHub
- domain_extractor on RubyGems
- ActiveModel::EachValidator Documentation
- Rails Guides: Custom Validators
- OpenSite AI
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)