Validating Models Without ActiveRecord in Rails

Have you ever needed a class in Rails that behaves like an ActiveRecord model but isn’t backed by a database table? A common scenario is building something like a contact form. You need validation for fields like name, email, and message, but there’s no need to store this data in your database.

Sure, there are gems for this, but sometimes it’s satisfying to understand the mechanics and avoid unnecessary dependencies. Let’s explore how to validate a plain Ruby object in Rails without using ActiveRecord or plugins.


The Problem: Validations Without ActiveRecord

You might assume that including ActiveModel::Validations in your class is enough to handle validations, like so:

class Contact
  include ActiveModel::Validations
  attr_accessor :name, :email, :message

  validates :email, presence: true
  validates :message, presence: true
end

This setup is a good start but incomplete. ActiveModel validations rely on certain methods that ActiveRecord provides by default, like persisted? and new_record?. Without these, your validations won’t work as expected.

Let’s flesh out a functional implementation.


A Complete Example

Here’s how you can build a plain Ruby class with ActiveModel validations:

class Contact
include ActiveModel::Model

  attr_accessor :name, :email, :message

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :message, presence: true

  def save
    if valid?
      # Logic for sending an email or performing another action
      true
    else
      false
    end
  end
end

Key Points:

  1. ActiveModel::Model: This module includes all necessary ActiveModel modules, like validations and conversion methods. It’s a one-stop shop for turning plain Ruby objects into form-like models.
  2. save Method: You’re responsible for defining what “saving” means—e.g., sending an email or calling an external API.

Using the Model

Here’s how you might use this Contact class:

contact = Contact.new(name: "Nico", email: "nico@test.com", message: "Hey!")

if contact.save
  puts "Message sent!"
else
  puts "Errors: #{contact.errors.full_messages.join(', ')}"
end

Adding Custom Behavior

Want to reuse this pattern for multiple non-ActiveRecord models? Extract a base class:

class ApplicationForm
  include ActiveModel::Model

  def save
    if valid?
      perform
      true
    else
      false
    end
  end

  def perform
    raise NotImplementedError, "Subclasses must define `perform`"
  end
end

class ContactForm < ApplicationForm
  attr_accessor :name, :email, :message

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :message, presence: true

  def perform
    # Logic for sending the contact message
  end
end

This pattern keeps your code DRY and makes it easy to add new non-database models in the future.


When to Use This Approach

  • Form Objects: Perfect for forms where data doesn’t need to be persisted, like contact forms, search forms, or API integrations.
  • Service Objects: Combine with service objects to encapsulate business logic while still benefiting from validations.
  • Avoid Overhead: Great when you want lightweight models without the overhead of database tables.

Alternatives to Consider

If you’re looking for prebuilt solutions, consider these gems:

  • Reform: Provides a robust framework for form objects with validations and composition.
  • Virtus: Adds attributes and type coercion to plain Ruby objects (though now deprecated in favor of dry-rb).
  • ActiveInteraction: A gem for managing business logic and validations.

Conclusion

Validating non-ActiveRecord models in Rails is straightforward with ActiveModel. By understanding the building blocks, you can create lightweight, flexible models tailored to your app’s needs. Whether you’re sending a contact email or building an external API integration, this approach keeps your code clean and focused.

Try it out in your next Rails project—you might find it’s the perfect fit for your use case!

Have comments or want to discuss this topic?

Send an email to ~bounga/public-inbox@lists.sr.ht