Ideas and suggestions about architecture for hanami projects
- 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
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 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.
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
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
For loading system dependencies you can use 2 ways:
- put all this code to
config/initializers/*
- use 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.
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
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 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.
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:
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.
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