├── README.md └── images └── project.png /README.md: -------------------------------------------------------------------------------- 1 | # hanami-architecture 2 | 3 | Ideas and suggestions about architecture for hanami projects 4 | 5 | ## Table of Contents 6 | 7 | * Application rules 8 | * Actions 9 | * View 10 | * API 11 | * Serializers 12 | * HTML 13 | * Forms 14 | * View objects 15 | * IoC containers 16 | * How to load all dependencies 17 | * How to load system dependencies 18 | * `Import` object 19 | * Testing 20 | * Interactors, operations and what you need to use 21 | * When you need to use it 22 | * Hanami-Interactors 23 | * Dry-transactions 24 | * Testing 25 | * Domain services 26 | * Service objects, workers 27 | * Models 28 | * Command pattern 29 | * Repository 30 | * Entity 31 | * Changesets 32 | * Event sourcing 33 | 34 | ## Application rules 35 | All logic for displaying data should be in applications. 36 | 37 | If your application include custom middleware, it should be in apps/app_name/middlewares/ folder 38 | 39 | ### Actions 40 | Actions it's just a transport layer of hanami projects. Here you can put: 41 | 1. request logic 42 | 2. call business logic (like services, interactors or operations) 43 | 3. Sereliaze response 44 | 4. Validate data from users 45 | 5. Call simple repository logic (but you need to understand that it'll create tech debt in your project) 46 | 47 | ```ruby 48 | module Api::Controllers::Issue 49 | class Show 50 | include Api::Action 51 | include Import['tasks.interactors.issue_information'] 52 | 53 | params do 54 | required(:issue_url).filled(:str?) 55 | end 56 | 57 | # bad, business logic here 58 | def call(params) 59 | if params[:action] == 'approve' 60 | TaskRepository.new.update(params[:id], { approved: true }) 61 | ApproveTaskWorker.perform_async(params[:id]) 62 | else 63 | TaskRepository.new.update(params[:id], { approved: false }) 64 | end 65 | 66 | redirect_to routes.moderations_path 67 | end 68 | 69 | # good, we use intecator for updating task and sending some to background 70 | def call(params) 71 | TaskStatusUpdater.new(params[:id], params[:action]).call 72 | redirect_to routes.moderations_path 73 | end 74 | end 75 | end 76 | ``` 77 | 78 | We will cover `Import` object in [`Import` object](https://github.com/davydovanton/hanami-architecture#import-object) section. 79 | 80 | ### API 81 | 82 | #### Serializer 83 | 84 | Try to use https://github.com/nesaulov/surrealist with hanami-view presenters. For example: 85 | 86 | ```ruby 87 | # in apps/v1/presenters/entities/user.rb 88 | 89 | require 'hanami/view' 90 | 91 | module V1 92 | module Presenters 93 | module Entities 94 | class User 95 | include Surrealist 96 | include Hanami::Presenter 97 | 98 | json_schema do 99 | { 100 | id: Integer, 101 | first_name: String, 102 | last_name: String, 103 | email: String 104 | } 105 | end 106 | 107 | end 108 | end 109 | end 110 | end 111 | 112 | # in apps/v1/presenters/users/show.rb 113 | 114 | module V1 115 | module Presenters 116 | module Users 117 | class Show 118 | include Surrealist 119 | 120 | json_schema do 121 | { 122 | status: String, 123 | user: Entities::User.defined_schema 124 | } 125 | end 126 | 127 | attr_reader :user 128 | 129 | # @example Base usage 130 | # 131 | # user = User.new(name: 'Anton') 132 | # V1::Presenters::Users::Show.new(user).surrealize 133 | # # => { "status": "ok", "user": { "name": "Anton" } } 134 | def initialize(user) 135 | @user = Entities::Price.new(user) 136 | end 137 | 138 | def status 139 | 'ok' 140 | end 141 | end 142 | end 143 | end 144 | end 145 | ``` 146 | 147 | ### HTML 148 | 149 | #### Forms 150 | 151 | #### View objects 152 | 153 | ## IoC containers 154 | [IoC containers](https://gist.github.com/blairanderson/8072d951a480a590f0bd) is preferred way to work with project dependencies. 155 | 156 | We suggest to use [dry-containers](http://dry-rb.org/gems/dry-container/) for working with containers: 157 | 158 | ```ruby 159 | # in lib/container.rb 160 | require 'dry-container' 161 | 162 | class Container 163 | extend Dry::Container::Mixin 164 | 165 | register('core.http_request') { Core::HttpRequest.new } 166 | 167 | namespace('services') do 168 | register('analytic_reporter') { Services::AnalyticReporter.new } 169 | register('url_shortener') { Services::UrlShortener.new } 170 | end 171 | end 172 | ``` 173 | 174 | Use string names as a keys, for example: 175 | ```ruby 176 | Container['core.http_lib'] 177 | Container['repository.user'] 178 | Container['worders.approve_task'] 179 | ``` 180 | 181 | You can initialize dependencies with different config: 182 | 183 | ```ruby 184 | # in lib/container.rb 185 | require 'dry-container' 186 | 187 | class Container 188 | extend Dry::Container::Mixin 189 | 190 | register('events.memory_sync') { Hanami::Events.initialize(:memory_sync) } 191 | register('events.memory_async') { Hanami::Events.initialize(:memory_async) } 192 | end 193 | ``` 194 | 195 | ### How to load all dependencies 196 | 197 | ### How to load system dependencies 198 | For loading system dependencies you can use 2 ways: 199 | 1. put all this code to `config/initializers/*` 200 | 2. use [dry-system](http://dry-rb.org/gems/dry-system/) 201 | 202 | #### Dry-system 203 | 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: 204 | * https://github.com/ossboard-org/ossboard/tree/master/system 205 | * https://github.com/hanami/contributors/tree/master/system 206 | 207 | After that you can use container for other classes. 208 | 209 | ### `Import` object 210 | For loading dependencies to other classes use `dry-auto_inject` gem. For this you need to create `Import` object: 211 | 212 | ```ruby 213 | # in lib/container.rb 214 | require 'dry-container' 215 | require 'dry-auto_inject' 216 | 217 | class Container 218 | extend Dry::Container::Mixin 219 | 220 | # ... 221 | end 222 | 223 | Import = Dry::AutoInject(Container) 224 | ``` 225 | 226 | After that you can import any dependency in to other class: 227 | 228 | ```ruby 229 | module Admin::Controllers::User 230 | class Update 231 | include Admin::Action 232 | include Import['repositories.user'] 233 | 234 | def call(params) 235 | user = user.update(params[:id], params[:user]) 236 | redirect_to routes.user_path(user.id) 237 | end 238 | end 239 | end 240 | ``` 241 | 242 | ### Testing 243 | For testing your code with dependencies you can use two ways. 244 | 245 | The first, DI: 246 | ```ruby 247 | let(:action) { Admin::Controllers::User::Update.new(user: MockUserRepository.new) } 248 | 249 | it { expect(action.call(payload)).to be_success } 250 | ``` 251 | 252 | The second, mock: 253 | ```ruby 254 | require 'dry/container/stub' 255 | 256 | Container.enable_stubs! 257 | Container.stub('repositories.user') { MockUserRepository.new } 258 | 259 | let(:action) { Admin::Controllers::User::Update.new } 260 | 261 | it { expect(action.call(payload)).to be_success } 262 | ``` 263 | 264 | We suggest using mocks only for not DI dependencies like persistent connections. 265 | 266 | ## Interactors, operations and what you need to use 267 | 268 | 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. 269 | 270 | ### When you need to use it 271 | 272 | ### Hanami-Interactors 273 | Interactors returns object with state and data: 274 | ```ruby 275 | # in lib/users/interactors/signup 276 | require 'hanami/interactor' 277 | 278 | class Users::Intecators::Signup 279 | include Hanami::Interactor 280 | expose :user 281 | 282 | def initialize(params) 283 | @params = params 284 | end 285 | 286 | def call 287 | find_user! 288 | singup! 289 | end 290 | 291 | private 292 | 293 | def find_user! 294 | @user = UserRepository.new.create(@params) 295 | error "User not found" unless @user 296 | end 297 | 298 | def singup! 299 | Users::Services::Signup.new.call(@user) 300 | end 301 | end 302 | 303 | result = User::Intecators::Signup.new(login: 'Anton').call 304 | result.successful? # => true 305 | result.errors # => [] 306 | ``` 307 | 308 | Links: 309 | * https://github.com/hanami/utils/blob/master/lib/hanami/interactor.rb 310 | 311 | ### Dry-transactions 312 | 313 | ## Domain services (simple way) 314 | Use interactors. Interactors are top level and verbs. A feature is directly mapped 1:1 with a use case/interactor. 315 | 316 | ``` 317 | Router => Action => Interactor 318 | ``` 319 | 320 | ```ruby 321 | # Bad 322 | class A::Nested::Namespace::PublishStory 323 | end 324 | 325 | # Good 326 | class PublishStory 327 | end 328 | ``` 329 | 330 | Put all interactors to `lib/bookshelf/interactors` folder. And also, you can call services, repositories, etc from interactors. 331 | 332 | ## Domain services (hard way) 333 | 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: 334 | 335 | ``` 336 | /lib 337 | /users 338 | /interactors 339 | /libs 340 | 341 | /books 342 | /interactors 343 | /libs 344 | 345 | /orders 346 | /interactors 347 | /libs 348 | ``` 349 | 350 | 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. 351 | 352 | ![hanami-project](https://github.com/davydovanton/hanami-architecture/blob/master/images/project.png?raw=true) 353 | 354 | Each domain should have a specific namespace in a container: 355 | 356 | ```ruby 357 | # in lib/container.rb 358 | require 'dry-container' 359 | 360 | class Container 361 | extend Dry::Container::Mixin 362 | 363 | namespace('users') do 364 | namespace('interactors') do 365 | # ... 366 | end 367 | 368 | namespace('services') do 369 | # ... 370 | end 371 | 372 | # ... 373 | end 374 | end 375 | ``` 376 | 377 | Each domain should have public interactor objects for calling from apps or other places (like workers_) and private objects as libraries: 378 | 379 | ```ruby 380 | module Admin::Controllers::User 381 | class Update 382 | include Admin::Action 383 | # wrong, private object 384 | include Import['users.services.calculate_something'] 385 | 386 | # good, public object 387 | include Import['users.interactor.update'] 388 | 389 | def call(params) 390 | # ... 391 | end 392 | end 393 | end 394 | ``` 395 | 396 | ## Service objects, workers 397 | 398 | ## Event sourcing 399 | -------------------------------------------------------------------------------- /images/project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davydovanton/hanami-architecture/a60bf96154358433bb6732ae453e434214b12910/images/project.png --------------------------------------------------------------------------------