Building Modular Rails Applications: A Deep Dive into Rails Engines Through Active Storage Dashboard
I’ve been building Rails applications for the last 10 years on a daily base and almost all of them use active storage now. Users are uploading files and then the questions start rolling in from the team and they are always the same:
“How much storage are we actually using?” “Can we see which files aren’t attached to anything anymore?” “What types of files are users uploading the most?” “Is there a way to browse through all our stored files?”
I usually open the Rails console, write a few queries, and get the answers for the team or for the stakeholders. But you know this isn’t sustainable. What I need is a proper dashboard, something visual, something that non-technical team members can use, something that doesn’t require SSH access to production servers.
This is exactly the problem I faced, and it led me down a fascinating journey into the world of Rails engines, ultimately resulting in the creation of Active Storage Dashboard, a mountable Rails engine that provides a modern interface for monitoring and managing Active Storage data. Always the same screen for every app, over and over. Easy to mount and easy to use.
Introducing Active Storage Dashboard
Active Storage Dashboard is more than just a simple admin interface. It’s a fully-featured Rails engine that seamlessly integrates into any Rails application, providing immediate visibility into your file storage ecosystem. With zero external dependencies (pure vanilla JavaScript and CSS), it offers a beautiful, animated interface that feels natural in modern web applications.
The gem provides comprehensive insights including:
- Real-time storage statistics and usage metrics
- Browsable interfaces for blobs, attachments, and variant records
- Advanced filtering capabilities for finding specific files
- Direct download functionality from the dashboard
- Maintenance tasks for cleaning up orphaned files
- Support for both table and card view layouts
- Beautiful visualizations of content type distributions
Active Storage Dashboard is a Rails engine, and understanding Rails engines opens up a whole new world of possibilities for Ruby developers so I used this experience to share my journey and to invite you to do the same and try to use engines more often.
Rails Engines: The Heroes of Modular Design
Rails engines are perhaps one of the most powerful yet underutilized features of the Ruby on Rails framework. At their core, engines are miniature Rails applications that can be mounted inside a host Rails application. They can come complete with their own models, views, controllers, routes, and assets—essentially everything a Rails app has, but designed to be pluggable and reusable.
Think of Rails engines as the Ruby equivalent of microservices, but without the operational complexity. They allow you to build self-contained features that can be shared across multiple applications or extracted from existing monoliths when certain functionalities become too complex or need to be reused.
The Anatomy of a Rails Engine
Looking at the Active Storage Dashboard’s directory structure reveals the beautiful symmetry between engines and regular Rails applications:
app/
├── controllers/
├── helpers/
└── views/
config/
├── routes.rb
lib/
├── active_storage_dashboard.rb
├── active_storage_dashboard/
│ ├── engine.rb
│ └── version.rb
This structure should feel immediately familiar to any Rails developer. The app
directory contains the MVC components, config
holds the routes, and lib
contains the engine’s core logic. The key difference is the presence of the engine.rb
file, which is where the magic happens.
The Engine Class: Where Configuration Meets Convention
Let’s examine the heart of any Rails engine: the engine class itself.
module ActiveStorageDashboard
class Engine < ::Rails::Engine
isolate_namespace ActiveStorageDashboard
initializer "active_storage_dashboard.url_options" do |app|
ActiveStorageDashboard::Engine.routes.default_url_options =
app.routes.default_url_options
end
config.active_storage_dashboard = ActiveSupport::OrderedOptions.new
config.before_initialize do
config.active_storage_dashboard.each do |key, value|
ActiveStorageDashboard.public_send("#{key}=", value)
end
end
end
end
The isolate_namespace
directive is crucial to ensures that all of the engine’s components (models, controllers, helpers) are namespaced, preventing naming conflicts with the host application. This isolation is what allows engines to be truly modular and reusable.
Building a Production-Ready Engine: Lessons learned from Active Storage Dashboard
Creating a Rails engine that’s both powerful and easy to use requires careful attention to several key areas. Let me walk you through the design decisions and implementation details that make Active Storage Dashboard a joy to integrate and use (or at least I hope so).
1. Routing and URL Generation
One of the trickiest aspects of building engines is handling routing correctly. Active Storage Dashboard uses a sophisticated approach to ensure URLs are generated properly regardless of where the engine is mounted:
# In routes.rb
ActiveStorageDashboard::Engine.routes.draw do
root to: 'dashboard#index'
resources :blobs, only: [:index, :show] do
member do
get 'download(/:disposition)', action: :download, as: :download
end
end
resources :attachments, only: [:index, :show] do
member do
get 'download(/:disposition)', action: :download, as: :download
end
end
resources :variant_records, only: [:index, :show]
end
Notice the optional disposition parameter in the download routes. This allows for flexible file handling—files can be downloaded as attachments or displayed inline in the browser.
2. Configuration Flexibility and Authentication Strategies
A well-designed engine should be configurable without being complicated, and security is often the first concern when integrating admin tools. Active Storage Dashboard provides multiple authentication strategies, allowing developers to choose the approach that best fits their application’s existing security infrastructure.
Controller-Based Authentication
The engine allows host applications to customize the base controller class, enabling seamless integration with existing authentication systems:
module ActiveStorageDashboard
mattr_accessor :base_controller_class, default: "ActionController::Base"
end
This approach means that if your application already has an AdminController
with authentication filters, you can simply configure the engine to inherit from it:
# In config/application.rb or config/environments/production.rb
config.active_storage_dashboard.base_controller_class = "AdminController"
This pattern is particularly powerful because it automatically inherits all the authentication logic, authorization rules, and even layout configurations from your existing admin infrastructure. Your AdminController
might look something like:
class AdminController < ApplicationController
before_action :authenticate_admin!
before_action :set_admin_timezone
layout 'admin'
private
def authenticate_admin!
redirect_to login_path unless current_user&.admin?
end
end
By setting this as the base controller, every Active Storage Dashboard controller automatically requires admin authentication without any additional configuration.
Route-Level Authentication Constraints
For applications that prefer to handle authentication at the routing layer, Active Storage Dashboard works seamlessly with Rails route constraints. This approach is often cleaner and more explicit:
# config/routes.rb
authenticate :user, -> (user) { user.admin? } do
mount ActiveStorageDashboard::Engine, at: "/active-storage-dashboard"
end
For Devise users, the integration is even more sophisticated. You can use complex constraints that check both session and warden authentication:
# config/routes.rb
constraints lambda { |req|
req.session[:user_id].present? ||
(req.env['warden'] && req.env['warden'].user(:user))
} do
mount ActiveStorageDashboard::Engine, at: "/active-storage-dashboard"
end
3. Cross-Rails Version Compatibility
One of the biggest challenges in building Rails engines is maintaining compatibility across different Rails versions. Active Storage Dashboard handles this gracefully, detecting features available in different Rails versions and adapting accordingly:
if defined?(ActiveStorage::VariantRecord)
@variant_records = paginate(ActiveStorage::VariantRecord.order(id: :desc))
else
@variant_records = []
end
This approach ensures the engine works seamlessly from Rails 5.2 all the way to Rails 8.x, degrading gracefully when newer features aren’t available.
4. Zero External Dependencies Philosophy
In an ecosystem often plagued by dependency hell, Active Storage Dashboard takes a radical approach: zero external dependencies. The entire UI is built with vanilla JavaScript and CSS, featuring smooth animations, responsive layouts, and modern design patterns—all without requiring jQuery, Bootstrap, or any other framework.
This decision pays dividends in several ways:
- Faster load times
- No version conflicts with host applications
- Easier maintenance and debugging
- Better long-term stability
5. Database-Agnostic Implementation
The engine’s statistics and querying features are carefully designed to work across different database adapters. Here’s how the dashboard handles timeline data generation across SQLite, MySQL, and PostgreSQL:
def adapter_specific_timeline_data
adapter = ActiveRecord::Base.connection.adapter_name.downcase
if adapter.include?('sqlite')
result = ActiveRecord::Base.connection.execute(
"SELECT strftime('%Y-%m', created_at) as month, COUNT(*) as count " +
"FROM #{table_name} GROUP BY strftime('%Y-%m', created_at)"
)
elsif adapter.include?('mysql')
result = ActiveRecord::Base.connection.execute(
"SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count " +
"FROM #{table_name} GROUP BY DATE_FORMAT(created_at, '%Y-%m')"
)
else # PostgreSQL
result = ActiveRecord::Base.connection.execute(
"SELECT TO_CHAR(created_at, 'YYYY-MM') as month, COUNT(*) as count " +
"FROM #{table_name} GROUP BY TO_CHAR(created_at, 'YYYY-MM')"
)
end
end
Efficient Pagination Without Gems
Rather than depending on gems like Kaminari or WillPaginate, the engine implements its own lightweight pagination:
def paginate(scope, per_page = 20)
@page = [params[:page].to_i, 1].max
scope.limit(per_page).offset((@page - 1) * per_page)
end
This approach keeps the engine lightweight while providing all the pagination features users need.
Smart File Preview Handling
The engine intelligently handles file previews based on content type and Rails version capabilities:
def previewable_blob?(blob)
return false unless blob.present?
content_type = blob.content_type
image_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return true if image_types.include?(content_type)
if defined?(ActiveStorage::Blob.new.preview) &&
blob.respond_to?(:previewable?) &&
blob.previewable?
return true
end
false
end
Rake Tasks for Maintenance
One of the most requested feature, that wansn’t present in the first versions was the ability to purge orphan files. I found a rake task to be the most efficient way to tackle this problem since every other solution would have required to use a queue system to schedule a massive deletion. I provided powerful maintenance tasks that can be run via command line:
namespace :active_storage do
namespace :dashboard do
desc "Purge blobs that have no attachments"
task purge_orphans: :environment do
ActiveStorageDashboard::OrphanPurger.call
end
desc "Re-analyze blobs that are not yet analyzed"
task reanalyze: :environment do
ActiveStorageDashboard::Analyzer.call
end
desc "Recreate missing or outdated variants"
task regenerate_variants: :environment do
ActiveStorageDashboard::VariantRegenerator.call
end
end
end
These tasks showcase how engines can provide not just UI features but also operational tools for maintaining application health.
The Broader Impact: Why Rails Engines Matter
The success of gems like Active Storage Dashboard points to a larger trend in the Rails ecosystem: the need for modular, reusable components that can be easily integrated into existing applications. Rails engines fulfill this need perfectly, offering several advantages:
1. Rapid Feature Development
Engines allow teams to develop features in isolation, with their own test suites and dependencies. This parallel development model can significantly speed up delivery times for complex features. We are using this pattern to build a bunch of specific-features that are shared in all our rails applications. This is basically allowing us to replace expensive microservices with engines.
2. Code Reusability Across Projects
Once built, an engine can be used across multiple applications. Imagine building an authentication engine, a payment processing engine, or an admin dashboard engine once and reusing it across all your projects.
3. Gradual Monolith Decomposition
For teams looking to break apart monolithic applications, engines provide a stepping stone. Features can be extracted into engines while still running in the same process, allowing for gradual decomposition without the operational overhead of microservices.
4. Open Source Contribution Opportunities
Engines make it easier to extract and share functionality with the community. A feature that starts as an internal tool can be polished and released as an open-source engine, benefiting the entire Rails ecosystem. This is the case of active_storage_dashboard
Best Practices for Engine Development I Learned
Based on my experience building Active Storage Dashboard and studying other successful engines, here are key best practices for engine development:
1. Namespace Everything
Always use isolate_namespace
and ensure all your classes, modules, and database tables are properly namespaced. This prevents conflicts and makes your engine truly reusable.
module ActiveStorageDashboard
class Engine < ::Rails::Engine
isolate_namespace ActiveStorageDashboard
end
end
This isolation extends beyond just Ruby classes. Consider these namespacing strategies:
- Database tables: Prefix all tables with your engine name (e.g.,
active_storage_dashboard_settings
) - CSS classes: Use a unique prefix for all styles (e.g.,
.asd-dashboard
,.asd-card
) - JavaScript functions: Wrap all JS in a namespaced object
- Helper methods: Ensure helpers don’t conflict with common names
- Route helpers: The engine’s routes are automatically namespaced
Remember that even seemingly unique names can conflict. A method like format_bytes
might exist in the host application, so I suggesto you to namespace it within its own helper module.
2. Provide Configuration Options
Make your engine configurable but provide sensible defaults. Users should be able to get started quickly while having the flexibility to customize as needed.
Beyond basic configuration, consider these patterns:
module ActiveStorageDashboard
# Class-level configuration
mattr_accessor :base_controller_class, default: "ActionController::Base"
mattr_accessor :per_page, default: 20
mattr_accessor :enable_delete, default: false
# Configuration block pattern
def self.configure
yield self
end
# Configuration validation
def self.validate_configuration!
unless base_controller_class.constantize < ActionController::Base
raise ConfigurationError, "base_controller_class must inherit from ActionController::Base"
end
end
end
This allows for elegant configuration:
ActiveStorageDashboard.configure do |config|
config.base_controller_class = "AdminController"
config.per_page = 50
config.enable_delete = Rails.env.development?
end
3. Document Thoroughly
Include comprehensive README documentation with clear installation instructions, configuration options, and usage examples. Consider including screenshots for UI-heavy engines.
Great documentation should include:
- Quick Start Guide: Get users running in under 5 minutes
- Detailed Installation: Cover edge cases and troubleshooting
- Configuration Reference: Document every option with examples
- Screenshots/GIFs: Visual elements dramatically improve comprehension
- Upgrade Guides: Help users migrate between versions
- API Documentation: If your engine provides programmatic interfaces
- Contributing Guidelines: Encourage community involvement
4. Keep Dependencies Minimal
Every dependency you add is a potential source of conflicts for users. When possible, implement functionality yourself rather than pulling in additional gems.
Active Storage Dashboard demonstrates this principle by implementing:
- Custom pagination instead of Kaminari/WillPaginate
- Vanilla JS instead of jQuery or Stimulus
- Pure CSS instead of Bootstrap or Tailwind
- Native Rails helpers instead of additional view libraries
When you must add dependencies:
- Pin to specific versions cautiously
- Document why each dependency is necessary
- Consider making optional features that require additional gems
- Regularly audit and update dependencies
5. Design for Extension
Provide hooks and extension points where users might want to customize behavior. Use Rails’ built-in patterns like concerns and callbacks to make extension natural.
module ActiveStorageDashboard
module Controllers
module Blobs
extend ActiveSupport::Concern
included do
before_action :set_blob, only: [:show, :download]
after_action :track_download, only: [:download]
end
# Users can override these methods in their own controllers
def blob_scope
ActiveStorage::Blob.all
end
private
def track_download
# Override this method to add download tracking
end
end
end
end
6. Handle Errors Gracefully
Engines should never crash the host application. Implement robust error handling:
def load_timeline_data
begin
# Complex database query
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error "Timeline data error: #{e.message}"
@timeline_data = []
rescue => e
Rails.logger.error "Unexpected error: #{e.message}"
generate_fallback_data
end
end
7. Security First
Never trust user input and always consider security implications:
- Sanitize all user inputs
- Use strong parameters
- Implement CSRF protection
- Validate file types and sizes
- Never expose internal IDs in URLs when possible
- Rate limit expensive operations
8. Semantic Versioning
Follow semantic versioning religiously:
- MAJOR: Breaking changes
- MINOR: New features (backward compatible)
- PATCH: Bug fixes
Document breaking changes clearly in your CHANGELOG.
Engines as a Path to Better Software Architecture
Active Storage Dashboard demonstrates that Rails engines aren’t just a nice-to-have feature. They’re a powerful tool for building maintainable and reusable solutions to common problems. By embracing engines, we can build better software architectures that are both monolithic in deployment and modular in design.
The journey of building this engine taught me that the Rails ecosystem still has much to offer in terms of architectural patterns and best practices. Engines provide a middle ground between monolithic applications and microservices, offering the benefits of modularity without the operational complexity.
Go open source if you can
For developers looking to level up their Rails skills, I encourage you to explore engines. Start by extracting a feature from an existing application into an engine. Build tools that solve problems you face repeatedly. Share your solutions with the community. The Rails ecosystem thrives when developers create and share modular, reusable components.
The next time you find yourself building a feature that could benefit other applications, whether it’s an admin dashboard, an authentication system, or a domain-specific tool, consider building it as an engine. You might just create the next essential tool that thousands of developers will thank you for.
Active Storage Dashboard is available on GitHub at https://github.com/giovapanasiti/active_storage_dashboard, and I welcome contributions, feedback, and stories about how you’re using it in your applications. Together, we can continue to push the boundaries of what’s possible with Rails engines and create a more modular, maintainable future for Rails applications.