O que é um form object?
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 |
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.