hanami-architecture
Ideas and suggestions about architecture for hanami projects
Table of Contents
- Application rules
- Actions
- View
- API
- Serializers
- HTML
- Forms
- View objects
- IoC containers
- How to load all dependencies
- How to load system dependencies
Import
object- Testing
- Interactors, operations and what you need to use
- When you need to use it
- Hanami-Interactors
- Dry-transactions
- Testing
- Domain services
- Service objects, workers
- Models
- Command pattern
- Repository
- Entity
- Changesets
- Event sourcing
Application rules
All logic for displaying data should be in applications.
If your application include custom middleware, it should be in apps/app_name/middlewares/ folder
Actions
Actions it's just a transport layer of hanami projects. Here you can put:
- request logic
- call business logic (like services, interactors or operations)
- Sereliaze response
- Validate data from users
- Call simple repository logic (but you need to understand that it'll create tech debt in your project)
module Api::Controllers::Issue
class Show
include Api::Action
include Import['tasks.interactors.issue_information']
params do
required(:issue_url).filled(:str?)
end
# bad, business logic here
def call(params)
if params[:action] == 'approve'
TaskRepository.new.update(params[:id], { approved: true })
ApproveTaskWorker.perform_async(params[:id])
else
TaskRepository.new.update(params[:id], { approved: false })
end
redirect_to routes.moderations_path
end
# good, we use intecator for updating task and sending some to background
def call(params)
TaskStatusUpdater.new(params[:id], params[:action]).call
redirect_to routes.moderations_path
end
end
end
We will cover Import
object in Import
object section.
API
Serializer
Try to use https://github.com/nesaulov/surrealist with hanami-view presenters. For example:
# in apps/v1/presenters/entities/user.rb
require 'hanami/view'
module V1
module Presenters
module Entities
class User
include Surrealist
include Hanami::Presenter
json_schema do
{
id: Integer,
first_name: String,
last_name: String,
email: String
}
end
end
end
end
end
# in apps/v1/presenters/users/show.rb
module V1
module Presenters
module Users
class Show
include Surrealist
json_schema do
{
status: String,
user: Entities::User.defined_schema
}
end
attr_reader :user
# @example Base usage
#
# user = User.new(name: 'Anton')
# V1::Presenters::Users::Show.new(user).surrealize
# # => { "status": "ok", "user": { "name": "Anton" } }
def initialize(user)
@user = Entities::Price.new(user)
end
def status
'ok'
end
end
end
end
end
HTML
Forms
View objects
IoC containers
IoC containers is preferred way to work with project dependencies.
We suggest to use dry-containers for working with containers:
# in lib/container.rb
require 'dry-container'
class Container
extend Dry::Container::Mixin
register('core.http_request') { Core::HttpRequest.new }
namespace('services') do
register('analytic_reporter') { Services::AnalyticReporter.new }
register('url_shortener') { Services::UrlShortener.new }
end
end
Use string names as a keys, for example:
Container['core.http_lib']
Container['repository.user']
Container['worders.approve_task']
You can initialize dependencies with different config:
# in lib/container.rb
require 'dry-container'
class Container
extend Dry::Container::Mixin
register('events.memory_sync') { Hanami::Events.initialize(:memory_sync) }
register('events.memory_async') { Hanami::Events.initialize(:memory_async) }
end
How to load all dependencies
How to load system dependencies
For loading system dependencies you can use 2 ways:
- put all this code to
config/initializers/*
- use dry-system
Dry-system
This libraty provide a simple way to load your dependency to container. For example you can load redis client or API clients here. Check this links as a example:
- https://github.com/ossboard-org/ossboard/tree/master/system
- https://github.com/hanami/contributors/tree/master/system
After that you can use container for other classes.
Import
object
For loading dependencies to other classes use dry-auto_inject
gem. For this you need to create Import
object:
# in lib/container.rb
require 'dry-container'
require 'dry-auto_inject'
class Container
extend Dry::Container::Mixin
# ...
end
Import = Dry::AutoInject(Container)
After that you can import any dependency in to other class:
module Admin::Controllers::User
class Update
include Admin::Action
include Import['repositories.user']
def call(params)
user = user.update(params[:id], params[:user])
redirect_to routes.user_path(user.id)
end
end
end
Testing
For testing your code with dependencies you can use two ways.
The first, DI:
let(:action) { Admin::Controllers::User::Update.new(user: MockUserRepository.new) }
it { expect(action.call(payload)).to be_success }
The second, mock:
require 'dry/container/stub'
Container.enable_stubs!
Container.stub('repositories.user') { MockUserRepository.new }
let(:action) { Admin::Controllers::User::Update.new }
it { expect(action.call(payload)).to be_success }
We suggest using mocks only for not DI dependencies like persistent connections.
Interactors, operations and what you need to use
Interactors, operations and other "functional objects" needs for saving your buisnes logic and they provide publick API for working with domains from other parts of hanami project. Also, from this objects you can call other "private" objects like service or lib.
When you need to use it
Hanami-Interactors
Interactors returns object with state and data:
# in lib/users/interactors/signup
require 'hanami/interactor'
class Users::Intecators::Signup
include Hanami::Interactor
expose :user
def initialize(params)
@params = params
end
def call
find_user!
singup!
end
private
def find_user!
@user = UserRepository.new.create(@params)
error "User not found" unless @user
end
def singup!
Users::Services::Signup.new.call(@user)
end
end
result = User::Intecators::Signup.new(login: 'Anton').call
result.successful? # => true
result.errors # => []
Links:
Dry-transactions
Domain services (simple way)
Use interactors. Interactors are top level and verbs. A feature is directly mapped 1:1 with a use case/interactor.
Router => Action => Interactor
# Bad
class A::Nested::Namespace::PublishStory
end
# Good
class PublishStory
end
Put all interactors to lib/bookshelf/interactors
folder. And also, you can call services, repositories, etc from interactors.
Domain services (hard way)
We have applications for different logic. That's why we suggest using DDD and split you logic to separate domains. All these domains should be in /lib
folder and looks like:
/lib
/users
/interactors
/libs
/books
/interactors
/libs
/orders
/interactors
/libs
Each domain have "public" and "private" classes. Also, you can call "public" classes from apps and core finctionality (lib/project_name/**/*.rb
folder) from domains.
Each domain should have a specific namespace in a container:
# in lib/container.rb
require 'dry-container'
class Container
extend Dry::Container::Mixin
namespace('users') do
namespace('interactors') do
# ...
end
namespace('services') do
# ...
end
# ...
end
end
Each domain should have public interactor objects for calling from apps or other places (like workers_) and private objects as libraries:
module Admin::Controllers::User
class Update
include Admin::Action
# wrong, private object
include Import['users.services.calculate_something']
# good, public object
include Import['users.interactor.update']
def call(params)
# ...
end
end
end