O que é um form object?

Prévia grátis 8 min de leitura

O formulário que não cabe em nenhum model

Imagine uma página de inscrição num curso. O usuário preenche um formulário, mas nos bastidores esse único envio precisa criar um User, um Enrollment e um prêmio inicial de XP. Qual model deve validar todos esses campos? O User não sabe nada de matrículas, e o Enrollment não sabe nada de senhas.

Essa é a parede que toda aplicação Rails em crescimento encontra: um formulário cujos dados não se encaixam num único model do Active Record. Os campos se espalham por vários models, as validações dependem do contexto, e a lógica não tem um lugar natural na camada de models.

Um form object é um objeto Ruby simples que faz o papel de model por trás de um formulário. Ele junta os parâmetros, valida-os para o contexto daquele formulário e deixa a persistência para um service object. Ele existe para manter as preocupações do formulário separadas das preocupações do model.

Por que models não bastam

Models do Active Record carregam duas responsabilidades. Eles definem o formato dos dados (colunas, tipos, associações) e garantem invariantes de negócio, como "um email precisa ser único" ou "um curso precisa ter título". São regras sempre verdadeiras: valem não importa como nem onde os dados são criados.

Formulários acrescentam regras de contexto. Um formulário de cadastro exige confirmação de senha, e um formulário de admin que cria um usuário não exige. Um formulário de inscrição exige aceitar os termos de uso, e um endpoint de API não exige. Um assistente em várias etapas valida campos diferentes em cada etapa.

Ponha validações de contexto dentro do model e você terá código assim:

class User < ApplicationRecord
  validates :password_confirmation, presence: true, if: :signing_up?
  validates :terms_accepted, acceptance: true, if: :from_registration_form?
  validates :admin_notes, presence: true, if: :created_by_admin?

  attr_accessor :signing_up, :from_registration_form, :created_by_admin

  def signing_up?
    signing_up == true
  end
end

Esse model agora sabe de fluxos de cadastro, formulários de inscrição e rotinas de admin. Cada formulário novo acrescenta mais uma flag, mais uma cláusula if: e mais um método de que o model não deveria precisar.

O model quebrou o princípio da responsabilidade única: ele cuida da integridade dos dados e da validação específica de formulários, duas preocupações que mudam por motivos diferentes.

A solução do form object

Um form object leva a lógica de contexto para uma classe própria. É um objeto Ruby simples que inclui ActiveModel::Model e ActiveModel::Attributes, que lhe dão integração suficiente com o Rails para funcionar com form_with, validações e mensagens de erro, sem tocar no banco.

# Um form object hipotético; este app não tem o diretório app/forms
module Auth
  class RegistrationForm
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :name, :string
    attribute :email, :string
    attribute :password, :string
    attribute :password_confirmation, :string
    attribute :terms_accepted, :boolean

    validates :name, presence: true
    validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
    validates :password, presence: true, length: { minimum: 8 }
    validates :password_confirmation, presence: true
    validates :terms_accepted, acceptance: true

    validate :passwords_must_match

    private

    def passwords_must_match
      return if password == password_confirmation
      errors.add(:password_confirmation, "doesn't match password")
    end
  end
end

Essa classe guarda todas as validações específicas do formulário de inscrição. O model User fica só com as invariantes sempre verdadeiras, como a unicidade do email e o tamanho da senha, e o form object cuida do resto.

O que este app faz no lugar

Este app não tem form objects. O cadastro dele é um formulário de um model só: o Auth::RegistrationsController passa os parâmetros direto para o Auth::RegisterUserService, que monta um Identity::User com um password_confirmation. As validações do model conferem o email, o username e a senha, e o has_secure_password confere se a confirmação bate. Com um model e nenhum campo extra, isso basta, que é a linha "não" da tabela de decisão abaixo.

Um form object passaria a valer a pena no dia em que o cadastro ganhasse um checkbox de termos, ou começasse a criar uma matrícula no mesmo envio.

Onde os form objects ficam no DDD

Cada camada tem um trabalho:

Requisição HTTP
     |
     v
+------------------+
| Controller       |  Recebe params, retorna resposta HTTP
+------------------+
     |
     v
+------------------+
| Form Object      |  Valida input para este contexto específico
+------------------+
     |
     v
+------------------+
| Service Object   |  Executa lógica de negócio, persiste dados
+------------------+
     |
     v
+------------------+
| Model            |  Forma dos dados, invariantes sempre verdadeiras
+------------------+

O controller recebe os parâmetros e os entrega ao form object. O form object os valida e, se forem válidos, o controller passa os dados validados a um service object, que roda a lógica de negócio e devolve um Result.

Cada camada não sabe nada das camadas acima dela. O serviço não sabe que foi chamado a partir de um formulário, e o model não sabe que um serviço o usou. Isso é separação de responsabilidades na prática.

O controller continua enxuto

Com um form object, o controller vira um orquestrador simples:

# Uma versão hipotética do controller que usa o form object acima
module Auth
  class RegistrationsController < ApplicationController
    def new
      @form = RegistrationForm.new
    end

    def create
      @form = RegistrationForm.new(registration_params)

      if @form.valid?
        result = RegisterUserService.call(
          name: @form.name,
          email: @form.email,
          password: @form.password
        )

        if result.success
          redirect_to root_path, notice: "Welcome!"
        else
          @form.errors.add(:base, result.error)
          render :new, status: :unprocessable_content
        end
      else
        render :new, status: :unprocessable_content
      end
    end

    private

    def registration_params
      params.require(:auth_registration_form).permit(
        :name, :email, :password,
        :password_confirmation, :terms_accepted
      )
    end
  end
end

O controller faz três coisas: montar o formulário, conferir se é válido, chamar o serviço. Não guarda lógica de negócio, nem validações complexas, nem consultas ao banco. É assim que "controllers cuidam só de HTTP" fica na prática.

Quando você precisa de um form object

Nem todo formulário precisa de um. Aqui vai um guia de decisão:

Situação Form object? Por quê
CRUD simples de um model Não As validações do Active Record bastam
Campos que não estão em nenhum model Sim Não há model para guardar as validações
Um formulário cria vários models Sim Nenhum model sozinho é dono de todos os campos
Validações de contexto Sim Mantém a lógica condicional fora dos models
Assistente em várias etapas Sim Cada etapa valida campos diferentes
Campos virtuais, como um checkbox de termos Sim Campos virtuais não pertencem aos models
Um endpoint de API com os mesmos dados Não, ou outro formulário Outro contexto tem outras validações

A regra geral: se você está acrescentando attr_accessor ou if: :some_context? a um model por causa de um formulário, extraia um form object.

Erros comuns

O primeiro erro é pôr a persistência no form object. O trabalho dele é validar, e não salvar registros, e misturar as duas coisas desfaz a separação. O form object valida; se os dados forem válidos, o controller chama um serviço para persisti-los.

O segundo é criar um form object para todo formulário. Formulários de CRUD simples que correspondem a um model não precisam da camada extra, e excesso de engenharia deixa o time mais lento. Recorra a um form object quando os models começarem a juntar validações de contexto.

O terceiro é duplicar validações do model no form object. Se o model e o formulário conferem os dois o formato do email, você tem duas fontes de verdade, e uma mudança na regra precisa ser feita duas vezes. O formulário valida preocupações do formulário, como a confirmação de senha e o checkbox de termos, e o model valida as invariantes dos dados.

A unicidade é um caso especial: um form object nem consegue declará-la. validates :email, uniqueness: true numa classe que só inclui ActiveModel::Model gera ArgumentError: Unknown validator: 'UniquenessValidator', porque esse validador pertence ao Active Record e precisa do banco. A unicidade fica no model, apoiada num índice único.

Resumo

Conceito Ponto-chave
Form object Um objeto Ruby simples que faz o papel de model por trás de um formulário e guarda as validações de contexto dele
O problema Models se enchem de validações condicionais quando formulários diferentes precisam de regras diferentes
ActiveModel::Model Dá a um objeto simples validações, nomes e erros, tudo de que o form_with precisa
Lugar no DDD Controller, depois form object, depois serviço, depois model
Separação de responsabilidades Formulário: regras de entrada deste contexto. Model: invariantes sempre verdadeiras. Serviço: lógica de negócio
Quando usar Formulários de vários models, validações de contexto, campos virtuais, assistentes em etapas
Quando não usar CRUD simples de um model, como o cadastro deste app
Aula 1 de 7 · grátis

Continue com a aula 2

ActiveModel::Model e attributes

Explica como ActiveModel::Model e atributos com tipo fazem um objeto simples funcionar com form_with e validações.

Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.

Digite para buscar...