๐ฎ BetterController
A Ruby gem for building modern Rails controllers with a declarative DSL, Hotwire/Turbo support, and ViewComponent integration.
๐ก Why BetterController?
After seeing too many Rails projects with business logic scattered across controllers, I created BetterController. It's part of the Better gems family with a simple mission: enforce separation of concerns and provide a clear standard for every project. Lean controllers, services where they belong, and a DSL that makes everything readable.
โจ Features
- Declarative Action DSL - Define controller actions with a clean, expressive syntax
- Hotwire/Turbo Support - Built-in support for Turbo Frames and Turbo Streams
- ViewComponent Integration - Seamless rendering of ViewComponents with page configurations
- Flexible Response Handling - Unified handling of HTML, Turbo Stream, and JSON responses
- Error Handling - Comprehensive error classification and response formatting
- BYOS (Bring Your Own Services/Serializers) - Use any service pattern or serializer you prefer
๐ฆ Installation
Add to your Gemfile:
gem 'better_controller'Then run:
bundle installOptional Dependencies
For full Turbo and ViewComponent support:
gem 'turbo-rails', '>= 1.0'
gem 'view_component', '>= 3.0'๐ Quick Start
Basic Setup
Include BetterController in your controller:
class UsersController < ApplicationController
include BetterController
action :index do
service Users::IndexService
end
action :show do
service Users::ShowService
end
action :create do
service Users::CreateService
on_success do
html { redirect_to users_path }
turbo_stream do
prepend :users_list
update :users_count
end
end
on_error :validation do
render_page status: :unprocessable_entity
end
end
endService Integration
BetterController works seamlessly with any service pattern you prefer. Services should return a result hash:
# Use any service pattern: Interactor, Trailblazer, simple PORO, etc.
class Users::IndexService
def call
users = User.all.order(created_at: :desc)
{
success: true,
collection: users,
page_config: {
type: :index,
title: 'Users',
items: users
}
}
end
end๐ฏ Action DSL
Defining Actions
Use the action method to define controller actions:
action :index do
service Users::IndexService
endService Configuration
Specify which service handles the action:
action :show do
service Users::ShowService
# Optional: transform params before passing to service
params_mapping do |params|
{ id: params[:id], include_posts: true }
end
end๐ค Response Handlers
Define how to respond based on success or failure:
action :create do
service Users::CreateService
on_success do
html { redirect_to :index, notice: 'User created' }
turbo_stream do
prepend :users_list, partial: 'users/user'
update :flash
end
json { render json: @result }
end
on_error :validation do
html { render_page status: :unprocessable_entity }
turbo_stream do
replace :user_form
update :form_errors
end
json { render json: @result, status: :unprocessable_entity }
end
on_error :not_found do
html { redirect_to users_path, alert: 'User not found' }
end
endโ ๏ธ Error Types
Built-in error classifications:
-
:validation- ActiveRecord::RecordInvalid -
:not_found- ActiveRecord::RecordNotFound -
:authorization- Authorization failures -
:any- Catch-all for other errors
๐ผ๏ธ Page and Component Rendering
Render ViewComponents directly:
action :dashboard do
component DashboardComponent
end
action :help do
page HelpPage
end๐ Action DSL Reference
Complete list of all available parameters in the action block:
โ๏ธ Service Configuration
| Parameter | Type | Description |
|---|---|---|
service(klass, method:) |
Class, Symbol | Service class to call. Default method: :call
|
params_key(key) |
Symbol | Key for strong parameters (e.g., :user) |
permit(*attrs) |
Array | Permitted attributes for strong parameters |
action :create do
service Users::CreateService, method: :execute
params_key :user
permit :name, :email, :role, address: [:street, :city]
end๐ผ๏ธ Page and Component
| Parameter | Type | Description |
|---|---|---|
page(klass) |
Class | Page class that generates page_config via #to_config
|
component(klass, locals:) |
Class, Hash | ViewComponent to render directly |
page_config(&block) |
Block | Modifier block for page_config from service |
action :dashboard do
service Users::DashboardService
page Users::DashboardPage # Fallback if service has no viewer
end
action :profile do
component Users::ProfileComponent, locals: { show_avatar: true }
end
action :admin_index do
service Users::IndexService
page_config do |config|
config[:title] = "Admin Users"
end
end๐ค Response Handlers
| Parameter | Type | Description |
|---|---|---|
on_success(&block) |
Block | Handler for successful service result |
on_error(type, &block) |
Symbol, Block | Handler for specific error type |
Error types: :validation, :not_found, :authorization, :any
action :create do
service Users::CreateService
on_success do
html { redirect_to users_path, notice: 'Created!' }
turbo_stream do
prepend :users_list
update :flash
end
json { render json: @result, status: :created }
end
on_error :validation do
html { render_page status: :unprocessable_entity }
turbo_stream { replace :user_form }
end
on_error :not_found do
html { redirect_to users_path, alert: 'Not found' }
end
on_error :any do
html { render_page status: :internal_server_error }
end
end๐ Callbacks
| Parameter | Type | Description |
|---|---|---|
before(&block) |
Block | Callback executed before the action |
after(&block) |
Block | Callback executed after the action (receives result) |
action :update do
before { @original_user = User.find(params[:id]).dup }
service Users::UpdateService
after { notify_changes(@original_user, @result[:resource]) }
end๐ Authentication and Authorization
| Parameter | Type | Description |
|---|---|---|
skip_authentication(value) |
Boolean | Skip authentication for this action (default: true) |
skip_authorization(value) |
Boolean | Skip authorization for this action (default: true) |
action :public_profile do
skip_authentication
skip_authorization
service Users::PublicProfileService
endโก Turbo Support
Turbo Frame Handler
Handle Turbo Frame requests with explicit control:
on_success do
html { render_page }
turbo_frame do
component Users::ListComponent, locals: { title: 'Users' }
end
endThe turbo_frame {} handler supports:
-
component(klass, locals: {})- Render a ViewComponent -
partial(path, locals: {})- Render a partial -
render_page(status: :ok)- Render using page config -
layout(true/false)- Control layout rendering (default: false)
When no turbo_frame {} handler is defined, it falls back to html {}.
Turbo Stream Actions
Build Turbo Stream responses declaratively:
on_success do
turbo_stream do
append :notifications, partial: 'shared/notification'
prepend :items_list
replace :item_counter
update :flash
remove :loading_spinner
end
endTurbo Frame Detection
Check request context in your actions:
def show
if turbo_frame_request?
render partial: 'user_card', locals: { user: @user }
else
render :show
end
endAvailable Helpers
turbo_frame_request? # Is this a Turbo Frame request?
turbo_stream_request? # Is this a Turbo Stream request?
current_turbo_frame # Get the Turbo Frame ID
turbo_native_app? # Is this from a Turbo Native app?Stream Helpers
Build individual streams:
stream_append(:list, partial: 'item')
stream_prepend(:list, partial: 'item')
stream_replace(:item, partial: 'item')
stream_update(:counter, partial: 'counter')
stream_remove(:notification)
stream_before(:item, partial: 'new_item')
stream_after(:item, partial: 'new_item')๐งฉ ViewComponent Integration
Page Config Rendering
When your service returns a page_config, BetterController automatically resolves and renders the appropriate component:
# Service returns:
{
success: true,
page_config: {
type: :index,
title: 'Users',
items: users
}
}
# BetterController looks for:
# Templates::Index::PageComponentComponent Rendering Helpers
Render components directly:
render_component UserCardComponent, locals: { user: @user }
render_component_to_string AvatarComponent, locals: { user: @user }
render_component_collection users, UserRowComponent, item_key: :userDefault Locals
Components automatically receive:
-
current_user- If available -
page_config- The page configuration -
result- The service result -
resource- Single resource from result -
collection- Collection from result
โ๏ธ Configuration
Create an initializer:
# config/initializers/better_controller.rb
BetterController.configure do |config|
# API version included in all responses (default: 'v1')
config.api_version = 'v1'
# ViewComponent namespace for page types
config.html_page_component_namespace = 'Templates'
# Pagination settings
config.pagination_enabled = true
config.pagination_per_page = 25
# Error handling
config.error_handling_log_errors = true
config.error_handling_detailed_errors = Rails.env.development?
# Turbo settings
config.turbo_enabled = true
config.turbo_auto_flash = true
config.turbo_auto_form_errors = true
# HTML partials
config.html_flash_partial = 'shared/flash'
config.html_form_errors_partial = 'shared/form_errors'
end๐ Search/Filter Pattern
Handle initial page load and subsequent filter updates:
action :index do
service Users::IndexService
on_success do
html { render_page }
turbo_stream do
replace :users_table, partial: 'users/table'
update :users_count
update :active_filters
update :pagination
end
end
endThe HTML request renders the full page, while Turbo Stream requests update only the changed elements.
๐ API Controllers
For JSON APIs, use BetterControllerApi:
class Api::UsersController < ApplicationController
include BetterControllerApi
def index
users = User.all
respond_with_success(users)
end
def show
user = User.find(params[:id])
respond_with_success(user)
rescue ActiveRecord::RecordNotFound => e
respond_with_error(e, status: :not_found)
end
def create
user = User.create!(user_params)
respond_with_success(user, status: :created)
rescue ActiveRecord::RecordInvalid => e
respond_with_error(e, status: :unprocessable_entity)
end
endResponse Format
All API responses follow a consistent structure with data and meta:
Success response:
{
"data": { "id": 1, "name": "John" },
"meta": { "version": "v1" }
}Error response:
{
"data": {
"error": {
"type": "ActiveRecord::RecordNotFound",
"message": "Couldn't find User with id=999"
}
},
"meta": { "version": "v1" }
}The meta.version is configurable (see Configuration section).
๐ ๏ธ Utilities
Parameter Validation
class UsersController < ApplicationController
include BetterController::Utils::ParameterValidation
requires_params :create, :name, :email
param_schema :update, {
name: { required: true, type: String },
age: { type: Integer },
role: { in: %w[admin user guest] }
}
endPagination
class UsersController < ApplicationController
include BetterController::Utils::Pagination
def index
users = paginate(User.all)
respond_with_pagination(users)
end
endLogging
class UsersController < ApplicationController
include BetterController::Utils::Logging
def create
log_info('Creating user', user_email: params[:email])
# ...
rescue => e
log_exception(e)
raise
end
end๐งช Testing
BetterController is fully tested with RSpec. Run the test suite:
# Unit tests
bundle exec rspec spec/better_controller
# Integration tests (require Rails)
INTEGRATION_TESTS=true bundle exec rspec spec/integration spec/generatorsCurrent coverage: 98%+ with 587 examples.
๐ Requirements
- Ruby >= 3.0
- Rails >= 6.0
- Optional: turbo-rails >= 1.0
- Optional: view_component >= 3.0
๐ค Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/my-feature) - Write tests for your changes
- Ensure all tests pass (
bundle exec rspec) - Ensure code style compliance (
bundle exec rubocop) - Commit your changes (
git commit -am 'Add new feature') - Push to the branch (
git push origin feature/my-feature) - Create a Pull Request
๐ License
This gem is available as open source under the terms of the MIT License.
๐ค Author
Alessio Bussolari - alessio.bussolari@pandev.it
- GitHub: @alessiobussolari
- Repository: https://github.com/alessiobussolari/better_controller