WellFormed
WellFormed is a lightweight form object library for Rails. Inherit from WellFormed::ResourceForm for standard create/update flows, WellFormed::ActionForm for custom actions — including operations on plain Ruby objects that don't use ActiveRecord persistence — or WellFormed::SimpleAction when there's no current user. All variants accept a resource, an optional 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
Install the gem and add to the application's Gemfile by executing:
bundle add well_formedIf bundler is not being used to manage dependencies, install the gem by executing:
gem install well_formedUsage
class CreateArticleForm < WellFormed::ResourceForm
resource_alias :article
attribute :title, :string
attribute :body, :string
validates :title, presence: true
validates :body, presence: true
private
def perform
assign_attributes_to(resource)
resource.save
end
end# app/controllers/articles_controller.rb
def create
@form = CreateArticleForm.new(Article.new, current_user, article_params)
if @form.save
redirect_to @form.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 (article = @form.submit)
render json: article, status: :created
else
render json: {errors: @form.errors.messages}, status: :unprocessable_content
end
endclass 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
endConstructor
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::ErrorsOverriding 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
private
def perform
assign_attributes_to(resource)
resource.save
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
endarticle.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.full_messages # => ["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 directlyPersistence — save and save!
save is the public entry point. It:
- Runs validations — returns
falseimmediately if invalid - Runs any
before_perform/after_performcallbacks - Calls your private
submitmethod, which you define on the form
You are responsible for assigning attributes to the resource and persisting it inside submit. The assign_attributes_to helper does the assignment, filtering out any attributes the resource has no setter for:
class CreateArticleForm < WellFormed::ResourceForm
attribute :title, :string
validates :title, presence: true
private
def perform
assign_attributes_to(resource)
resource.save
end
endform = CreateArticleForm.new(article, current_user, { title: "Hello", body: "World" })
form.save # => true / false
form.save! # raises WellFormed::RecordInvalid if invalid or perform returns falsy
form.submit # => resource if saved, false otherwiserescue WellFormed::RecordInvalid => e
e.record # => the form object
e.message # => "Validation failed: Title can't be blank"
e.record.errors # => ActiveModel::Errors
endFor side effects around the save — sending emails, creating audit logs, etc. — use before_perform, after_perform, and after_perform_commit callbacks.
Unmatched attributes
assign_attributes_to silently skips form attributes that have no matching setter on the resource — 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. 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.
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.
Surfacing model validation errors — merge_errors
By default, if submit returns a falsy value with no errors on the form, a generic :base error ("could not be saved") is added so that errors is never silently empty.
When you want model-level validation errors surfaced on the form — for example in an API using Halitosis::ErrorsSerializer — call merge_errors inside submit:
class Api::CreateUserForm < WellFormed::ResourceForm
resource_alias :user
attribute :name, :string
attribute :email, :string
validates :name, presence: true
validates :email, presence: true
# No format validation here — the User model owns that rule
private
def perform
assign_attributes_to(resource)
result = resource.save
merge_errors(resource) unless result
result
end
end# User model
class User < ApplicationRecord
validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/, message: "is invalid" }
endSubmitting email: "not-an-email" passes form validation but fails at the model. merge_errors copies the errors 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.full_messages) if errors.any?
end
endBlocks 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_perform and after_perform
Use before_perform to run logic before perform is called. Use after_perform to run logic once perform returns truthy.
class CreateArticleForm < WellFormed::ResourceForm
attribute :title, :string
before_perform :set_created_by
after_perform :notify_subscribers
private
def set_created_by
resource.created_by = user
end
def notify_subscribers
NotificationMailer.new_article(resource).deliver_later
end
endBlocks are also accepted:
before_perform { resource.created_by = user }A before_perform callback can halt the save by calling throw :abort, in which case save returns false and perform is never called:
before_perform { throw :abort unless user.can_publish? }Commit callbacks — after_perform_commit
after_perform_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_perform_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_perform_commit :notify_subscribers
private
def notify_subscribers
NotificationMailer.new_article(resource).deliver_later
end
endTransactions — save_within_transaction
Call save_within_transaction to wrap the entire save (including all callbacks) in a database transaction. If submit returns falsy, 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_perform :create_audit_log # runs inside the transaction
after_perform_commit :notify_subscribers # runs after the transaction commits
private
def perform
assign_attributes_to(resource)
resource.save
end
def create_audit_log
AuditLog.create!(action: :created, record: article, user: user)
end
def notify_subscribers
NotificationMailer.new_article(article).deliver_later
end
endIf AuditLog.create! raises, the article save is rolled back with it. after_perform_commit callbacks only fire once the transaction successfully commits.
Action forms
Inherit from WellFormed::ActionForm for any operation that isn't a standard ActiveRecord create/update — publishing, archiving, sending a notification, or driving a plain Ruby object that has no save method. You define perform instead of save, and call submit from the controller.
Action forms use RecordIdentity to provide persisted?, id, and to_param directly on the form, so the resource doesn't need to implement them.
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
endCall 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 haltsPORO resources
ActionForm also works when the resource is a plain Ruby object — a value object, a service struct, an API client payload — since it provides persisted?, id, and to_param on the form itself and never calls save on the resource.
class CreateSubscriptionForm < WellFormed::ActionForm
resource_alias :subscription
attribute :email, :string
attribute :name, :string
validates :email, presence: true
validates :name, presence: true
private
def perform
subscription.email = email
subscription.name = name
subscription.subscribe!
end
end# app/controllers/api/subscriptions_controller.rb
def create
form = CreateSubscriptionForm.new(Subscription.new, current_user, subscription_params)
if form.submit
render json: form.resource, status: :created
else
render json: {errors: form.errors.messages}, status: :unprocessable_content
end
endSubscription here is a plain Ruby class — no persisted?, no save. The form handles all the interface concerns.
Callbacks — before_perform and after_perform
before_perform and after_perform work identically to the same callbacks on resource forms:
class PublishArticleForm < WellFormed::ActionForm
before_perform :check_permissions
after_perform :audit_log
def perform
article.publish!
end
def check_permissions
throw :abort unless user.can_publish?(article)
end
def audit_log
AuditLog.record(user, :published, article)
end
endBlocks are also accepted:
before_perform { throw :abort unless user.can_publish?(article) }Nested attributes
Nested form objects are built in to WellFormed::ResourceForm and WellFormed::ActionForm.
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
endMacros
| 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) |
delegated_attribute_for :name |
Flattens a has_one / belongs_to association's attributes directly onto the parent form |
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, BillingAddressFormPassing 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]
)
endFor 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]
)
endInitialising 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.
Delegated attributes
Use delegated_attribute_for when you want a has_one / belongs_to association's fields to appear flat on the parent form — no nested form object, no fields_for, just plain attributes and validations on the same form.
class CheckoutForm < WellFormed::ResourceForm
resource_alias :order
attribute :customer_name, :string
validates :customer_name, presence: true
delegated_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
private
def perform
assign_attributes_to(resource)
resource.save
end
endThe block declares attributes (with types) and validations exactly as you would on any form. Internally:
- A single
billing_address_attributeshash attribute is registered on the form. - Individual virtual accessors (
street,city,postcode) pack values into that hash. -
form.attributesreturns{ "billing_address_attributes" => { "street" => "...", ... } }, soassign_attributes_tocallsresource.billing_address_attributes=naturally via AR'saccepts_nested_attributes_for. - When an attribute is not in the submitted params, the reader delegates to the resource's association —
form.streetreturnsresource.billing_address.street.
# Flat params — street, city, postcode at the top level
form = CheckoutForm.new(Order.new, current_user, {
customer_name: "Alice",
street: "1 Main St",
city: "Springfield",
postcode: "12345"
})
form.street # => "1 Main St"
form.valid? # => true
form.save # => true (creates order + billing_address via accepts_nested_attributes_for)On update, attributes not present in the submitted params are read through from the existing association, so unsubmitted fields are not cleared:
form = CheckoutForm.new(existing_order, current_user, { customer_name: "Updated" })
form.street # => existing_order.billing_address.street (not nil)
form.save # => true (billing_address unchanged)Controller — strong params
Permit the flat attributes at the top level:
def order_params
params.require(:order).permit(:customer_name, :street, :city, :postcode)
endThe model must declare accepts_nested_attributes_for for the association so that billing_address_attributes= is available:
class Order < ApplicationRecord
has_one :billing_address
accepts_nested_attributes_for :billing_address
endValidations
validates calls inside the block are deferred and replayed on the parent form class after the virtual readers are defined, so error keys (street, city, etc.) work correctly with form.errors and view helpers.
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
endcollection_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)
endValidation
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)
endValidation 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
endCode-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 == 42resolves_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 # => 42View 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
endThen declare the field with as: :collection_for:
<%= f.input :user_id, as: :collection_for %>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 = {}) |
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 / falseWellFormed::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_perform { AuditLog.record(user, resource) }
end
form = AuditedSyncForm.new(record, current_user, params)
form.user # => current_userResourceForm and ActionForm are themselves implemented this way — they inherit from their Simple counterpart and prepend WithUser.
Plugins
| Gem | Description |
|---|---|
| well_formed-pundit |
Pundit authorization — adds authorize!, policy, and policy_scope helpers to any WellFormed form |
| well_formed-paper_trail |
PaperTrail versioning — automatically sets whodunnit to the form's user around every save and perform, with a paper_trail_whodunnit macro for custom values |
| well_formed-dry_types |
dry-types coercion — declare typed attributes with dry_attribute; coercion runs before validation with configurable error messages |
See Also
-
Halitosis — JSON serialization library for Rails APIs. Declare attributes, HAL-style links, permissions, and nested relationships on serializer classes; render resources and collections with built-in support for sorting, filtering, and pagination. Use
Halitosis::ErrorsSerializerto serializeActiveModel::Errorsfrom a WellFormed form directly into a JSON:API-style errors response.
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.