Hexagonal
A simple gem to provide structure and guidance for writing hexagonal ruby applications.
Installation
Add this line to your application's Gemfile:
gem 'hexagonal'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hexagonal
Why?!?!
Rails applications are usually really fast to build at the beginning, but due to high coupling, as they mature they begin to calcify. Eventually adding any new features and fixing bugs becomes a pain. Developers get caught in callback hell. Nothing can be tested in isolation. Test suites take >10 minutes to run. This is also sometimes true of non-Rails Ruby apps. Some people will suggest that Rails Engines are a better solution for breaking up complexity. I say that engines and Hexagonal can be used together. Engines don't solve the problem of domain objects being tightly coupled to the database and often to each other through callbacks for example.
Hexagonal is an abstraction of the way that I've been building my latest Ruby applications. It's inspired by Matt Wynne, Brandur, grouper, agileplanner, Victor Savkins and many discussions with @soulim. Hexagonal provides base classes for everything required to build a small modular, Ruby application. See Structure section below.
Usage
When using Rails, the following generators are available. When not using Rails, please see the examples folder for how to extend the provided classes correctly.
Generate a resource
This will generate a repository, policy, mediators and runners for all CRUD actions for a specified resource.
rails generate hexagonal:resource [RESOURCE_NAME]
Generate a repository
rails generate hexagonal:repository [REPOSITORY_NAME]
Generate a runner
rails generate hexagonal:runner [RUNNER_NAME]
Generate a mediator
rails generate hexagonal:mediator [MEDIATOR_NAME]
Generate a policy
rails generate hexaganal:policy [POLICY_NAME]
Generate a worker
rails generate hexagonal:worker [WORKER_NAME]
Generate a job
rails generate hexagonal:job [JOB_NAME]
Generate a decorator
rails generate hexagonal:decorator [MODEL_NAME]
Structure
Here is the basic app structure along with some implementation examples. Not all of these objects need to inherited/extended from Hexagonal. Services, Jobs and Workers are not planned to be part of the gem.
Runners (app/runners)
These are my own creation. They are responsible for model materialization, authorization (authentication still happens in the controller) and running parameter validation
class CreateJobRunner < Hexagonal::Runners::CreateRunner
private
def form
@form ||= JobForm.new(attributes)
end
def mediator
@mediator ||= CreateJobMediator.new(user, form.attributes)
end
end
Mediators (app/mediators)
A Mediator is a design pattern encapsulating how a set of objects interact The mediators take care of saving/updating/deleting/etc and calling out to workers (for longer jobs, like looking up social media data) or jobs (for shorter jobs, like sending email)
class CreateJobMediator < Hexagonal::Mediators::CreateMediator
def target
@target ||= Job.new(attributes)
end
private
def default_attributes
{ created_by_id: user.id, account_id: user.account_id }
end
def repository
@repository ||= JobRepository.new
end
end
Forms (app/forms)
These contain parameter validation logic.
class JobForm
include Hexagonal::Form
attribute :title, String
attribute :remote_working_allowed, Boolean, default: true
validates :title, presence: true
end
Decorators (app/decorators)
These are used to add an object-oriented presentation layer. Decorators use the draper gem.
class JobDecorator < Draper::Decorator
decorates :job
delegate_all
def address
[street, city, country].compact.join(', ')
end
end
Workers (app/workers)
Sidekiq workers to handle longer running tasks (to avoid slow requests). The workers themselves have barely any code inside. They just materialize any models required and then call the required service.
class ContactImportWorker
include Sidekiq::Worker
def perform(user_id)
User.find(user_id).tap do |user|
ContactImportService.new(user).call
end
end
end
Jobs (app/jobs)
Sucker Punch jobs. Sucker punch handles background tasks in a single process using asynchronous Ruby. It's good for keeping costs down on heroku. I find sidekiq to generally be overkill for most tasks (email sending for example). Sucker Punch workers are the same as Sidekiq workers. Just materialize models and call the correct service
class SignupConfirmationJob
include SuckerPunch::Job
def perform(user)
UserMailer.signup_confirmation(user).deliver
end
end
Services (app/services)
When the app needs to interact with any third party service then a service object is used. They are called either from workers or mediators. They handle the details of things like email sending, lookup up social media data, importing/syncing contacts, polling IMAP, etc.
class ContactImportService
def initialize(user)
@user = user
end
def call
# some complex logic to pull contacts from social media
end
end
Responses (app/responses)
These handle responding to the client. They are almost all just simple delegators that help me to avoid duplicating code in controllers.
class CreateResponse < SimpleDelegator
def created_successfully(object)
respond_with object
end
def creation_failed(object)
render :errors, object.errors.as_json
end
end
Repositories (app/repositories)
Used to access the database. I'm trying to gradually decouple the app completely from ActiveRecord. It keeps the queries private instead of leaking storage API details into the app.
class JobRepository
include Hexagonal::Repository
def find_by_creator_id(creator_id)
adapter.where(creator_id: creator_id)
end
end
Adapters (app/adapters)
Adapters communicate between specific storage implementations and repositories. So far there is only an ActiveRecordAdapter. When I comes time to switch to something else, perhaps sequel, then I will just need to define a new adapter and plug it into the base repository. Adapters also need to define a Unit Of Work in order to be able to roll back groups of changes. With SQL this is just a wrapper around a Transaction.
Errors (app/errors)
These define business specific errors rather than just using the standard ones. Also map database specific errors to business ones so that the database can be switched out easily.
Policies (app/policies)
These handle authorization.
class JobPolicy
def initialize(user, job)
@user = user
@job = job
end
def delete?
job.created_by == user
end
private
attr_reader :user, :job
end
Example
Here is an example Rails API controller using hexagonal
class JobsController < ApplicationController::Base
before_filter :authenticate_user!
def index
filter_runner.run
end
def show
find_runner.run
end
def create
create_runner.run
end
def update
update_runner.run
end
def destroy
delete_runner.run
end
private
def find_runner
FindJobRunner.new(find_one_response, current_user, params[:id])
end
def find_one_response
FindOneResponse.new(self)
end
def filter_runner
FilterJobsRunner.new(find_all_response, current_user, params)
end
def create_runner
CreateJobRunner.new(create_response, current_user, params[:job])
end
def create_response
CreateResponse.new(self)
end
def update_runner
UpdateJobRunner
.new(update_response, current_user, params[:id], params[:job])
end
def update_response
UpdateResponse.new(self)
end
def delete_runner
DeleteRunner.new(delete_response, current_user, params[:id])
end
def delete_response
DeleteResponse.new(self)
end
end
Supported Rubies
2.0.x, 2.1.x, JRuby 1.7.x
Contributing
- Fork it ( https://github.com/[my-github-username]/hexagonal/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request