Rails Form Objects With dry-rb

Building form objects in modern Rails applications is nothing new. Most Ruby developers are familiar with including Virtus and ActiveModel::Validations to their form objects. Today I would like to show how to build a form object using dry-types and dry-validation

Let's build a simple form object for creating postcards. We have 3 models inside the application:

  • Country with name and is_state_required fields. The second field indicates that to create a proper address, a user needs to provide a state - like in the US.
  • Country::State with name and country_id.
  • Postcard with state_id, country_id, content and address fields.

Definition of done

  • Form creates new postcard (quite obvious).
  • Validates presence of address, city, zip code, content and country.
  • Validates format of zip code.
  • Validates length of content (if you want something short just tweet it or text it).
  • If selected country requires a state, presence of state should be validated.

Attributes and types

Let's start with attributes definition. Our form object needs to inherit from Dry::Types::Struct. Dry will generate appropriate getters and the constructor.

class Postcard
  module Types
    include Dry::Types.module
  end

  class CreateForm < Dry::Types::Struct
    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
  end
end

Just include Dry::Types.module module and use the types. Dry-types comes with a wide choice of primitive types along with modifiers.

Things get a bit more complicated when we would like to use our rails models. We need to register our types in order to create attributes with these types. Which is done as follows: TypeName = Dry::Types::Definition.new(::MyRubyClass) You can also tell dry-types how should the Type be constructed by calling .constructor with a block.

So our definitions looks like this:

module Types
  include Dry::Types.module
  Country = Dry::Types::Definition.new(::Country)
  CountryState = Dry::Types::Definition.new(::Country::State)
end

Now we can use Country and CountryState as types. So finally form's definition looks like this:

class CreateForm < Dry::Types::Struct
  attribute :address, Types::Coercible::String
  attribute :city, Types::Coercible::String
  attribute :zip_code, Types::Coercible::String
  attribute :content, Types::Coercible::String
  attribute :country, Types::Country
  attribute :state, Types::CountryState
end

Yeah, we managed to create a simple struct.

A note about the dry-types struct constructor

If we don't specify constructor type, a strict constructor will be generated, which means it will throw ArgumentError if an attribute is missing. We will handle presence validation using dry-validation so we will use schema or symbolized constructor more information on constructor types here. To use schema constructor type we need to call constructor_type(:schema) inside class body.

Validations

To perform validations inside our form object we'll use dry-validation gem. It comes with a wide variety of predicates, which are simple to use. Lets start with presence validation:

PostcardSchema = Dry::Validation.Schema do
  required(:address).filled
  required(:city).filled
  required(:zip_code).filled
  required(:content).filled
  required(:country).filled
end

We create a schema to which we pass model attributes, defined previously, like this:

errors = PostcardSchema.call(to_hash).messages(full: true)

Ok so what's happened here:

  • to_hash (or to_h ) generates a hash based on attributes
  • .messages(full: true) returns full error messages

To pass more requirements to validation, like format, length and so on just pass parameters to .filled method. Lets take content as an example, not only it should be present but also it should be longer than 20 characters:

required(:content).filled(min_size?: 20)

The full list of ready to use predicates can be accessed here.

More complex validation logic

Presence or length validations are delivered by dry-validation. Unfortunately(?) in real live application those are usually not enough, that's why dry-validation allows us to write our own predicates.

Let start with a simple one, which will check if the country passed to validation requires state.

PostcardSchema = Dry::Validation.Schema do
  configure do
    config.messages_file = Rails.root.join('config/locales/errors.yml')
    def state_required?(country)
      country.is_state_required
    end
  end
# (...)
end

As simple as that, just remember to put proper error message to your errors.yml file, more information about errors file here.

So let's get to the core and check presence of state only if the country requires it. We need to tell validation that the state might be present (or might be not). To do this just put the following line to our schema:

required(:state).maybe

Defining the rule itself

Defining the rule itself goes as follows:

rule(country_requires_state: [:country, :state]) do |country, state|
  country.state_required? > state.filled?
end

As simple as that:

  • we pass rule name along with the fields that are required inside the rule. In our case we use country and state.
  • Those variables are yielded to the block.
  • The rule is translated like this if state is required then check presence of state.

More information about high level rules is available here

Finished form object

class Postcard
  module Types
    include Dry::Types.module
    Country = Dry::Types::Definition
                .new(::Country)
    CountryState = Dry::Types::Definition
                     .new(::Country::State)
  end

  class CreateForm < Dry::Types::Struct
    constructor_type(:schema)

    ZIP_CODE_FORMAT = /\d{5}/
    MINIMAL_CONTENT_LENGTH = 20

    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
    attribute :country, Types::Country
    attribute :state, Types::CountryState


    def save!
      errors = PostcardSchema.call(to_hash).messages(full: true)
      raise CommandValidationFailed, errors if errors.present?
      Postcard.create!(to_hash)
    end

    private

    PostcardSchema = Dry::Validation.Schema do
      configure do
        config.messages_file = Rails.root.join('config/locales/errors.yml')
        def state_required?(country)
          country.is_state_required
        end
      end
      required(:address).filled
      required(:city).filled
      required(:zip_code).filled(format?: ZIP_CODE_FORMAT)
      required(:content).filled(min_size?: MINIMAL_CONTENT_LENGTH)
      required(:state).maybe
      required(:country).filled

      rule(country_requires_state: [:country, :state]) do |country, state|
        country.state_required? > state.filled?
      end
    end
  end
end

Full project, including models and spec is available on my github.

Possible refactor

Just to make things simple and readable in a blog post I put everything connected to the object itself into one file. In real life application this is probably not the best way of organizing your codebase. What can be done:

  • Types module could be placed as a separate module, probably even a global scope one
  • PostcardSchema could be placed outside this form object and used in, for example, update form
  • the same goes to constants - ZIP_CODE_FORMAT and MINIMAL_CONTENT_LENGTH

Conclusion

Using dry-types allows you to write type safe components to your application. This library comes with a wide choice of ready to use types and it's quite easy to define your own.

I feel like dry-validation approach is more clean than ActiveModel one. You put all your validation logic inside one, clearly bounded place. Those validations can be easily reused by other forms (like update ones).

The biggest problem with dry-rb, similarly to ROM and Roda, is a lack of documentation which allows fresh users to start easily. Trust me or not I spent over 2 hours writing the form object. Mostly because the problems with the documentation and lack of blogposts. So hopefuly this blogpost will save someone's 2 hours.