Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Hexagonal Rails, how we are doing it

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →

Hexagonal Rails, how we are doing it

A quick description of What Hexagonal rails is;
And how we solved several problems with a similar approach

Avatar for Bert Goethals

Bert Goethals

June 16, 2014

Other Decks in Programming

Transcript

  1. • Send email from model, or controller? • Enqueue from

    model, or controller? • How about Single Responsibility? • Keep DRY?
  2. Separate Domain from Implementation • Form Objects • Request Objects

    • Service Objects • Domain Objects • Mappers
  3. Separate Domain from Implementation • Form Objects • Request Objects

    • Service Objects • Entities • Mappers (Active Record, Controllers, …)
  4. 6 years • Little test, lack of specs • Huge

    application, not DRY at all • Ad-hoc design
  5. Need • Low quality and regressions • Desire for mobile

    app, API • Document the application • Hiring
  6. • API implements our future vision • 1 API endpoint

    = 1 service object • Service object FIRST used by old controllers • Spec the services
  7. 1 module TrustedPlatform 2 module Command 3 class CreatesComment <

    AuditLogBase 4 5 def self.command_name 6 'creates_comment' 7 end 8 9 def self.required_keys 10 super + [:resource] 11 end 12 13 def self.command_permitted?(context) 14 context.actor.permitted_to?( :view, context.resource ) 15 end 16 17 def self.actions 18 [ 19 Action::CreatesComment, 20 Action::SubscribesToResource, 21 Action::NotifiesMentioned, 22 Action::UpdatesNewsfeed, 23 CloseTransaction, 24 Action::NotifiesResourceSubscribers, 25 ] 26 end 27 28 end 29 end 30 end
  8. 1 module TrustedPlatform 2 module Action 3 class CreatesComment <

    Base 4 11 def call 12 handle_closed_comment_thread 13 handle_empty_comment_text 14 comment = create_comment(create_comment_arguments) 15 if comment 16 logger.info "Saved comment #{comment.id} to the DB" 17 context[:comment] = comment 18 add_to_newsfeed_queue 19 add_to_mention_queue 20 end 21 end 22 23 private 24 25 def handle_closed_comment_thread 26 return unless resource.respond_to?(:comments_closed?) && resource.comments_closed? 27 halt_action! message: "comment thread is closed", raise: ClosedThread 28 end 29 30 def handle_empty_comment_text 31 return if comment_text.strip.length > 0 32 context[:comment] = build_invalid_comment 33 halt_action! message: "empty comment", raise: InvalidContext 34 end 35 36 def add_to_newsfeed_queue 37 if comment.moderated? && resource.publish_comment_in_newsfeed? 38 context.add_to_newsfeed_queue(newsfeed_message) 39 end 40 end 71 72 def create_comment(arguments) 73 Comment.create!(arguments)
  9. 1 module TrustedPlatform 2 module Action 3 class UpdatesNewsfeed <

    Base 4 5 module NewsfeedProxy 24 end 25 26 def self.action_name 27 'updates_newsfeed' 28 end 29 30 def call 31 return unless context.respond_to?(:newsfeed_queue) 32 context.newsfeed_queue.each do |message| 33 NewsfeedProxy.post(message) 34 end 35 end 36 37 end 38 end 39 end
  10. Commands & Actions • Validates • Authenticates • Logs (optionally)

    • Transaction (optionally) • Implements 1 unit of domain logic • Interfaces with storage • Re-usable
  11. 1 class CommentsController < ApplicationController 2 3 before_filter :find_comment, except:

    [:create, :change_subscription, :index] 4 before_filter :find_commentable, only: [:create, :change_subscription, :index] 5 10 def create 11 command = TrustedPlatform::Command::CreatesComment.new( 12 resource: @commentable, 13 actor: current_profile, 14 comment_text: params[:comment][:text], 15 comment_formatting: params[:comment][:formatting], 16 ) 17 context = command.call 18 comment = context.comment 19 render partial: 'list_and_form', locals: {hide_input: true, commentable: @commentable, highlight_item: comment } 20 track @commentable, object: comment, app: @commentable.app, action: 'comment_on' 21 22 rescue TrustedPlatform::Action::CreatesComment::ClosedThread => e 23 render partial: 'list_and_form', locals: {hide_input: true, commentable: @commentable } 24 end 25 65 73 end
  12. 1 module Api 2 module Social 3 class CommentsController <

    BaseController 4 5 def self.presenter 6 ::Social::CommentsPresenter 7 end 8 9 def index 10 render_api Query::Social::Comments.new(index_params).call 11 end 12 13 def create 14 context = Command::Social::CreatesComment.new(create_params).call 15 render_api context.comment 16 end 17 18 def delete 19 fail NotImplementedError 20 end 21 22 private 23 24 def find_params 25 { resource: box, actor: current_profile } 29 end 30 31 def create_params 32 comment = params.fetch(:comments){ [{}] }.first 33 { comment_text: comment.fetch(:text) { '' }, 35 resource: box, actor: current_profile } 38 end 39 end 40 end 41 end
  13. 1 module TrustedPlatform 2 module Query 3 module Messaging 4

    class Conversations < ArelBase 5 6 def self.available_scopes 7 [ 8 TrustedPlatform::Query::Scope::Messaging::TopicConversations, 9 TrustedPlatform::Query::Scope::Messaging::LabelConversations 10 ] 11 end 12 13 def base_collection 14 ::Messaging::Conversation 15 .joins(:content_relations) 16 .viewed_by(actor) 17 end 18 19 private 20 31 def run_for_list! 32 [ 33 conversations_from_uuids, 34 QueryRunners::ArelCollectionRunner.new( 35 sorter: sorter, 36 limit: limit, 37 cursor: cursor 38 ) 39 ] 40 end 90 91 def sorter 92 Sorter::ArelSortInfo.new( 93 table_name: ::Messaging::Conversation.table_name, 94 attributes_config: { 95 updated_at: :timestamp, 96 resource_uuid: :uuid, 97 }, 98 direction: :desc 99 ) 100 end 106 end 107 end 108 end 109 end
  14. 1 class Messaging::ConversationPresenter < BaseItemPresenter 2 alias_attribute :conversation, :item 3

    11 def preview 12 actor_presenter = ResourcePresenter.new(@actor, most_relevant_actor) 13 PreviewPresenter.new(actor, 14 name: actor_presenter.preview.name, 15 description: [subject.to_s, most_relevant_message.body], 16 thumbnail: actor_presenter.preview.thumbnail.href 17 ) 18 end 19 33 def state 34 'published' 35 end 36 37 def subject 38 if conversation.subject.blank? 39 'No title' 40 else 41 conversation.subject 42 end 43 end 44 45 def created_at 46 conversation.created_at 47 end 48 78 def participants_uuids 79 @_participants_uuids ||= participants_for(conversation).collection.each do |resource| 80 collection.linked_resources << resource if resource 81 end.map(&:uuid) 82 end 168 end
  15. 1 class Messaging::ConversationsPresenter < BaseCollectionPresenter 2 3 alias_method :conversations, :collection

    4 def self.item_presenter 5 ::Messaging::ConversationPresenter 6 end 7 8 attr_accessor :linked_messages, :linked_resources, :linked_attachments 9 55 def participants_for(conversation) 56 load_participants 57 @participants_map[conversation.id] 58 end 59 60 def new_participants_for(message) 61 load_participants 62 @new_participants_map[message.id] 63 end 64 65 def load_participants 66 Ugly stuff happens here 77 end 148 end
  16. What is there still to do? • Better Naming conventions

    & structure • Queries are not as well defined as Commands • Associations in Query results
  17. Advantages • Command/Query separation • Know EXACTLY what the application

    does • Can test commands/actions in isolation • Each layer can hide complexity
  18. Open Questions? • Should Commands use Query objects? • Should

    Commands accept “primitives”? • Should Commands return collections like Queries? • Is it OK to return AR objects, or should we use entities?
  19. What about • The Rails way ✔ • Hexagonal Rails

    Applications ✔ • What we did ✔