WellFormed
WellFormed is a lightweight form object library for Rails. Inherit from WellFormed::ResourceForm for standard create/update flows, or WellFormed::ActionForm for custom actions that don't persist a resource — both accept a resource, a user, and a params hash, with full ActiveModel::Model and ActiveModel::Attributes support.
Form objects are compatible with form_with and Rails view helpers anywhere an ActiveRecord model would be accepted.
Installation
TODO: Replace UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
Install the gem and add to the application's Gemfile by executing:
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
If bundler is not being used to manage dependencies, install the gem by executing:
gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
Usage
class CreateArticleForm < WellFormed::ResourceForm
resource_alias :article
attribute :title, :string
attribute :body, :string
validates :title, presence: true
validates :body, presence: true
end
# app/controllers/articles_controller.rb
def create
@form = CreateArticleForm.new(Article.new, current_user, article_params)
if (@article = @form.submit)
redirect_to @article
else
render :new, status: :unprocessable_entity
end
end
# app/controllers/api/articles_controller.rb
def create
@form = CreateArticleForm.new(Article.new, current_user, article_params)
if @form.save
render json: @form.article, status: :created
else
render json: {errors: @form.errors.}, status: :unprocessable_content
end
end
class PublishArticleForm < WellFormed::ActionForm
resource_alias :article
attribute :notify_subscribers, :boolean, default: false
def perform
article.publish!
PublishMailer.notify(article).deliver_later if notify_subscribers
end
end
# app/controllers/articles_controller.rb
def publish
@form = PublishArticleForm.new(@article, current_user, publish_params)
if @form.submit
redirect_to @article
else
render :edit, status: :unprocessable_entity
end
end
Constructor
form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
| Argument | Description |
|---|---|
resource |
The ActiveRecord object (or any object) the form wraps |
user |
The user performing the action |
params |
A hash of form values to assign to declared attributes (optional, defaults to {}) |
form.resource # => article
form.user # => current_user
form.title # => "Hello"
form.valid? # => true / false (ActiveModel validations)
form.errors # => ActiveModel::Errors
Overriding the constructor
Subclasses can define their own initialize — just call super to let the base set up resource, user, and attribute assignment. A common pattern is nested resource creation: the form accepts the parent as its first argument and builds the child record internally, keeping the controller clean:
module Articles
class CreatePostForm < WellFormed::ResourceForm
resource_alias :post
attribute :title, :string
attribute :body, :string
validates :title, presence: true
def initialize(article, user, params = {})
super(article.posts.build, user, params)
end
end
end
# Controller
def create
@form = Articles::CreatePostForm.new(@article, current_user, post_params)
if @form.save
redirect_to @article
else
render :new, status: :unprocessable_entity
end
end
article.posts.build scopes the new record to the parent so the association is set before save is called.
Translations — resource_alias
Call resource_alias to declare what the wrapped resource represents. This does two things:
- Adds a reader method on the form that aliases
resourceusing the given name - Sets
model_nameso that ActiveModel error messages use the correct I18n translation keys
class CreateArticleForm < WellFormed::ResourceForm
resource_alias :article
attribute :title, :string
validates :title, presence: true
end
form = CreateArticleForm.new(article, current_user, {})
form.article # => same as form.resource
form.errors. # => ["Title can't be blank"] (keyed under "article.*")
resource_alias accepts a symbol, a snake_case or CamelCase string, or an ActiveModel class:
resource_alias :article # symbol
resource_alias "article_comment" # snake_case string
resource_alias Article # class — borrows Article.model_name directly
Persistence — save and submit
The default save method:
- Runs validations — returns
falseimmediately if invalid - Assigns only form attributes that the resource has a corresponding setter for
- Calls
saveon the resource and returns its result (true/false)
form = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
form.save # => true / false
submit is a convenience wrapper around save that returns the resource on success or false on failure, making controller code more concise:
if (article = form.submit)
render json: article.as_json, status: :created
else
render json: { errors: form.errors. }, status: :unprocessable_entity
end
Use save! or submit! when you prefer raising over checking a return value — both raise WellFormed::RecordInvalid on failure:
form.save! # raises WellFormed::RecordInvalid if invalid or resource.save fails
form.submit! # raises WellFormed::RecordInvalid if invalid or resource.save fails
# (submit! also returns the resource on success)
rescue WellFormed::RecordInvalid => e
e.record # => the form object
e.message # => "Validation failed: Title can't be blank"
e.record.errors # => ActiveModel::Errors
end
For side effects around the save — sending emails, creating audit logs, etc. — use before_save, after_save, and after_save_commit callbacks. For forms that need fully custom persistence logic, consider WellFormed::Struct (plain Ruby object resources) or WellFormed::ActionForm (no resource persistence at all).
Unmatched attributes
Form attributes with no matching setter on the resource are silently skipped when save is called — they are filtered out before assign_attributes is called, so Rails never raises ActiveModel::UnknownAttributeError.
This is the normal case for virtual attributes like agree_to_terms or current_password, which exist on the form for validation or logic but have no corresponding column on the model — they are validated and accessible on the form as normal, without being assigned to the resource. Alternatively, declare the attribute on the form with attr_writer (or attr_accessor) instead of attribute — the value is still assigned from params, but won't be forwarded to the resource on save.
Use unmatched_attributes to opt in to a warning or error at the form level instead:
class CreateArticleForm < WellFormed::ResourceForm
unmatched_attributes :warn # prints a warning to stderr
# unmatched_attributes :raise # raises WellFormed::UnmatchedAttributesError
# unmatched_attributes :ignore # default — silent skip
end
:warn and :raise are useful during development to catch typos or attribute drift between the form and its resource.
Model validation errors — merge_model_errors
By default, save only runs form-level validations. If resource.save fails due to a model-level validation that the form does not replicate, save returns false with a generic :base error ("could not be saved") so that errors is never silently empty.
When you want model errors surfaced on the form — for example in an API that uses Halitosis::ErrorsSerializer — declare merge_model_errors in the form class. The form will then copy resource.errors onto itself whenever resource.save returns false:
class Api::CreateUserForm < WellFormed::ResourceForm
resource_alias :user
merge_model_errors # copies resource.errors onto the form on save failure
attribute :name, :string
attribute :email, :string
validates :name, presence: true
validates :email, presence: true
# No format validation here — the User model owns that rule
end
# User model
class User < ApplicationRecord
validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/, message: "is invalid" }
end
Submitting email: "not-an-email" passes form validation but fails at the model. With merge_model_errors, the error is copied back:
form.save # => false
form.errors[:email] # => ["is invalid"]
This integrates cleanly with Halitosis error serialisation:
render json: Halitosis::ErrorsSerializer.new(@form), status: :unprocessable_content
# { "errors": [{ "code": "email_invalid", "detail": "Email is invalid",
# "source": { "pointer": "/user/email" } }] }
Callbacks — before_validation and after_validation
Use before_validation to normalise or coerce attribute values before the validation run, and after_validation to act on the errors that were just collected — or to transform attribute values before they are assigned to the resource.
class CreateArticleForm < WellFormed::ResourceForm
attribute :title, :string
attribute :tag_list, :string
validates :title, presence: true
before_validation :normalise_title
after_validation :log_errors
private
def normalise_title
self.title = title&.strip&.downcase
end
def log_errors
Rails.logger.warn(errors.) if errors.any?
end
end
Blocks are also accepted:
before_validation { self.title = title&.strip }
To transform a form attribute before it reaches the resource, use after_validation — it runs after the validation run and before attributes are assigned:
after_validation { self.title = title&.strip&.downcase }
Callbacks — before_save and after_save
Use before_save to run logic after attributes have been assigned to the resource but before it is saved. Use after_save to run logic once the resource has been saved.
class CreateArticleForm < WellFormed::ResourceForm
attribute :title, :string
before_save :set_created_by
after_save :notify_subscribers
private
def set_created_by
resource.created_by = user
end
def notify_subscribers
NotificationMailer.new_article(resource).deliver_later
end
end
Blocks are also accepted:
before_save { resource.created_by = user }
A before_save callback can halt the save by calling throw :abort, in which case save returns false and resource.save is never called:
before_save { throw :abort unless user.can_publish? }
Commit callbacks — after_save_commit
after_save_commit registers a callback that fires after all surrounding database transactions have committed — safe for side effects like sending emails or enqueuing background jobs that must not run if the transaction rolls back.
Internally, after_save_commit uses ActiveRecord.after_all_transactions_commit — so if save is called inside a larger controller-level transaction, the callbacks wait for the outermost transaction to commit before firing.
class CreateArticleForm < WellFormed::ResourceForm
after_save_commit :notify_subscribers
private
def notify_subscribers
NotificationMailer.new_article(resource).deliver_later
end
end
Transactions — save_within_transaction
Call save_within_transaction to wrap the entire save (including all callbacks) in a database transaction. If resource.save returns false, or any callback raises, the transaction is rolled back automatically:
class CreateArticleForm < WellFormed::ResourceForm
resource_alias :article
attribute :title, :string
attribute :body, :string
validates :title, presence: true
save_within_transaction
after_save :create_audit_log # runs inside the transaction
after_save_commit :notify_subscribers # runs after the transaction commits
private
def create_audit_log
AuditLog.create!(action: :created, record: article, user: user)
end
def notify_subscribers
NotificationMailer.new_article(article).deliver_later
end
end
If AuditLog.create! raises, the article save is rolled back with it. after_save_commit callbacks only fire once the transaction successfully commits.
Action forms
For custom actions that don't map to a standard create/update — publishing, archiving, sending a notification — inherit from WellFormed::ActionForm. These are identical to regular forms except that attributes are not assigned to the resource, save is never called, and there is no default save implementation. You define perform instead.
class PublishArticleForm < WellFormed::ActionForm
resource_alias :article
attribute :notify_subscribers, :boolean, default: false
validates :article, presence: true
def perform
article.publish!
PublishMailer.notify(article).deliver_later if notify_subscribers
end
end
Call submit to run validations and then perform, or submit! to raise on failure:
form = PublishArticleForm.new(article, current_user, { notify_subscribers: true })
form.submit # => true if perform ran, false if invalid or halted by before_perform
# if halted with no errors, a generic base error ("could not be performed") is added
form.submit! # raises WellFormed::RecordInvalid if invalid or before_perform halts
Callbacks — before_perform and after_perform
before_perform and after_perform work the same way as before_save/after_save in regular forms:
class PublishArticleForm < WellFormed::ActionForm
before_perform :check_permissions
after_perform :audit_log
def perform
article.publish!
end
def
throw :abort unless user.can_publish?(article)
end
def audit_log
AuditLog.record(user, :published, article)
end
end
Blocks are also accepted:
before_perform { throw :abort unless user.can_publish?(article) }
Nested attributes
Nested form objects are built in to WellFormed::ResourceForm, WellFormed::ActionForm, and WellFormed::Struct.
class CreateOrderForm < WellFormed::ResourceForm
resource_alias :order
attribute :customer_name, :string
validates :customer_name, presence: true
nested_attributes_for :line_items do
attribute :name, :string
attribute :quantity, :integer
validates :name, presence: true
validates :quantity, numericality: { greater_than: 0 }
end
nested_attribute_for :billing_address do
attribute :street, :string
attribute :city, :string
attribute :postcode, :string
validates :street, presence: true
validates :city, presence: true
validates :postcode, presence: true
end
end
Macros
| Macro | Wraps |
|---|---|
nested_attributes_for :name |
A collection of nested forms (e.g. has_many) |
nested_attribute_for :name |
A single nested form (e.g. has_one / belongs_to) |
Each macro accepts either an inline block (shown above) or an explicit form class:
nested_attributes_for :line_items, LineItemForm
nested_attribute_for :billing_address, BillingAddressForm
Passing both raises ArgumentError.
Setters
Each macro defines two setters — both do the same thing and can be used interchangeably:
| Setter | Use case |
|---|---|
name_attributes= |
Rails fields_for / simple_fields_for (HTML form params with _attributes suffix) |
name= |
API-style params without the suffix |
The underlying resource receives name_attributes= immediately when either setter is called, so accepts_nested_attributes_for on the model works as normal.
Validations and errors
Nested forms are validated automatically as part of the parent form's valid? call. Errors from nested forms are promoted to the parent using structured keys:
- Collection —
"line_items[0].name","line_items[1].quantity", … - Singular —
"billing_address.street","billing_address.city", …
Controller integration
Strong params for HTML forms use the _attributes suffix:
def order_params
params.require(:order).permit(
:customer_name,
line_items_attributes: [:id, :name, :quantity, :_destroy],
billing_address_attributes: [:street, :city, :postcode]
)
end
For API endpoints that send params without the suffix, use plain nested keys instead:
def order_params
params.require(:order).permit(
:customer_name,
line_items: [:name, :quantity],
billing_address: [:street, :city, :postcode]
)
end
Initialising the form with pre-built nested records
When rendering a new-record form, build the nested records on the resource before passing it to the form so that fields_for / simple_fields_for has objects to iterate over:
def new
order = Order.new
order.line_items.build # collection — build at least one blank item
order.build_billing_address # singular
@form = CreateOrderForm.new(order, current_user)
end
<%= form_with model: @form, url: orders_path do |f| %>
<%= f.text_field :customer_name %>
<%= f.fields_for :line_items do |lf| %>
<%= lf.text_field :name %>
<%= lf.number_field :quantity %>
<% end %>
<%= f.fields_for :billing_address do |af| %>
<%= af.text_field :street %>
<%= af.text_field :city %>
<%= af.text_field :postcode %>
<% end %>
<%= f.submit %>
<% end %>
SimpleForm support
Nested form objects returned by nested_attributes_for / nested_attribute_for are compatible with simple_form. The anonymous class created by an inline block is given a synthetic model_name so that SimpleForm can infer input names and error wrappers correctly.
Use simple_fields_for in place of fields_for and f.input in place of the individual field helpers:
<%= simple_form_for @form, url: orders_path do |f| %>
<%= f.input :customer_name %>
<%= f.simple_fields_for :line_items do |lf| %>
<%= lf.input :name %>
<%= lf.input :quantity %>
<% end %>
<%= f.simple_fields_for :billing_address do |af| %>
<%= af.input :street %>
<%= af.input :city %>
<%= af.input :postcode %>
<% end %>
<%= f.submit %>
<% end %>
Validation errors on nested fields are promoted to the parent form with structured keys (e.g. "line_items[0].name"), which SimpleForm resolves back to the correct field and renders inline error messages automatically.
Collections
Use collection_for to declare collection-backed select fields on your form, with optional inclusion validation.
class CreatePostForm < WellFormed::ResourceForm
resource_alias :post
attribute :user_id, :integer
collection_for :user_id, validate: true do
User.order(:name)
end
end
collection_for generates a collection_for_<name> instance method that returns an ActiveRecord relation. The block is evaluated in the context of the form instance, so user, resource, and any other instance methods are available — useful for scoping:
collection_for :user_id do
User.where(organisation: resource.organisation)
end
Validation
Pass validate: true to automatically validate that the submitted value exists within the collection, matched by :id:
collection_for :user_id, validate: true do
User.order(:name)
end
Validation uses collection.where(id: value).pluck(:id) — a single scoped query rather than loading all records into memory. Blank values are always accepted (allow_blank: true); pair with a presence validator when the field is required.
To match on a field other than :id, pass the field name:
collection_for :status, validate: :code do
Status.active
end
Code-to-value resolution (resolves_to:)
Use resolves_to: when the form accepts one representation of a value (e.g. a code string or an integer id) but the resource stores a different one. After validation, the attribute is automatically replaced with the resolved value via a validate callback.
resolves_to: requires validate: to be a Symbol — the field used to look up the record. If the lookup fails, an :inclusion error is added and no transformation is applied.
Code → id
The form accepts a code string; the resource stores the integer id:
class CreatePostForm < WellFormed::ResourceForm
resource_alias :post
attribute :user_id # untyped — accepts a code string initially
collection_for :user_id, validate: :code, resolves_to: :id do
User.all
end
end
# Submitting "ALICE" resolves to user.id and saves that integer
form = CreatePostForm.new(Post.new, current_user, { user_id: "ALICE" })
form.save # post.user_id == 42
resolves_to: true is shorthand for resolves_to: :id.
Id → code
The form accepts an integer id; the resource stores the code string:
class CreatePostForm < WellFormed::ResourceForm
resource_alias :post
attribute :user_code # untyped — accepts an id initially
collection_for :user_code, validate: :id, resolves_to: :code do
User.all
end
end
# Submitting 42 resolves to user.code and saves that string
form = CreatePostForm.new(Post.new, current_user, { user_code: 42 })
form.save # post.user_code == "ALICE"
Edit forms
For both directions, resource_defaults is automatically overridden to reverse-populate the attribute with the input representation when loading an existing record — so the form pre-fills with the value the user expects to see and edit:
# Code → id form: post.user_id is 42, form pre-fills user_id with "ALICE"
form = CreatePostForm.new(existing_post, current_user)
form.user_id # => "ALICE"
# Id → code form: post.user_code is "ALICE", form pre-fills user_code with 42
form = CreatePostForm.new(existing_post, current_user)
form.user_code # => 42
View integration
Use collection_for_<name> directly in the view to build the select:
<%= f.collection_select :user_id, @form.collection_for_user_id, :id, :name, { include_blank: true } %>
SimpleForm integration
With SimpleForm, pass the collection explicitly via the collection: option:
<%= f.input :user_id, collection: @form.collection_for_user_id %>
Alternatively, define a custom SimpleForm input that auto-discovers collection_for_<name> on the form object, so the view needs no explicit collection reference:
# app/inputs/collection_for_input.rb
class CollectionForInput < SimpleForm::Inputs::CollectionSelectInput
def collection
if object.respond_to?(:"collection_for_#{attribute_name}")
@collection ||= object.public_send(:"collection_for_#{attribute_name}")
else
super
end
end
end
Then declare the field with as: :collection_for:
<%= f.input :user_id, as: :collection_for %>
PORO support
When the resource is a plain Ruby object that does not respond to save — a value object, a service-layer struct, an API client payload — inherit from WellFormed::Struct instead. It has the same interface as WellFormed::ResourceForm but replaces the default save behaviour with a perform method you define yourself.
Form attributes are still auto-assigned to the resource (via individual setters), and before_save/after_save callbacks still work. The AR-specific helpers after_save_commit and save_within_transaction are not available.
class CreateInvoiceForm < WellFormed::Struct
resource_alias :invoice
attribute :recipient_email, :string
attribute :amount_cents, :integer
validates :recipient_email, presence: true
validates :amount_cents, numericality: { greater_than: 0 }
after_validation :normalise_email
after_save :log_issuance
private
def perform
InvoiceService.issue(invoice)
end
def normalise_email
self.recipient_email = recipient_email&.strip&.downcase
end
def log_issuance
Rails.logger.info("Invoice issued to #{recipient_email}")
end
end
submit works the same way — it runs validations, assigns attributes to the resource, calls perform, and returns the resource on success or false on failure.
If there's no meaningful resource to wrap at all, use WellFormed::ActionForm instead.
Translations
WellFormed adds a generic base error when a save or perform fails with no errors already present. The default messages can be overridden in your application's locale files under the form's resource_alias key:
en:
activemodel:
errors:
models:
article: # matches resource_alias :article
could_not_be_saved: "could not be saved"
publish_article: # matches resource_alias :publish_article
could_not_be_performed: "could not be performed"
To override globally for all forms, use the shared messages scope:
en:
activemodel:
errors:
messages:
could_not_be_saved: "could not be saved"
could_not_be_performed: "could not be performed"
Simple variants
Each base class has a Simple counterpart that drops the user argument from the constructor — useful in contexts where there is no current user, such as background jobs, microservices, or data-import pipelines.
| With user | Without user | Constructor |
|---|---|---|
WellFormed::ResourceForm |
WellFormed::SimpleResource |
(resource, params = {}) |
WellFormed::ActionForm |
WellFormed::SimpleAction |
(resource, params = {}) |
WellFormed::Struct |
WellFormed::SimpleStruct |
(resource, params = {}) |
Everything else — attributes, validations, callbacks, collections, nested attributes — works identically. Calling form.user on a Simple class raises NoMethodError.
class SyncProductForm < WellFormed::SimpleResource
resource_alias :product
attribute :name, :string
attribute :price, :decimal
validates :name, presence: true
validates :price, numericality: { greater_than: 0 }
end
form = SyncProductForm.new(Product.new, { name: "Widget", price: 9.99 })
form.save # => true / false
WellFormed::WithUser
If you need to add user support to a custom class that already inherits from one of the Simple variants, prepend WellFormed::WithUser. It overrides the constructor to accept (resource, user, params = {}) and exposes form.user:
class AuditedSyncForm < WellFormed::SimpleResource
prepend WellFormed::WithUser
before_save { AuditLog.record(user, resource) }
end
form = AuditedSyncForm.new(record, current_user, params)
form.user # => current_user
ResourceForm, ActionForm, and Struct are themselves implemented this way — they inherit from their Simple counterpart and prepend WithUser.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/resource_form. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the ResourceForm project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.