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

Organizing an architecture of your Ruby on Rails app with Trailblazer 2.0

Organizing an architecture of your Ruby on Rails app with Trailblazer 2.0

CIklum Kharkiv Speakers' Corner


Yevhen "Eugene" Kuzminov

June 20, 2017

More Decks by Yevhen "Eugene" Kuzminov

Other Decks in Programming


  1. Organizing an architecture of your Ruby on Rails app with

    Trailblazer 2.0 Yevhen Kuzminov, MobiDev
  2. MobiDev:/$ whoami Yevhen Kuzminov |> Team Leader |> PHP 2009

    |> Ruby 2014 |> Elixir 2016
  3. What’s wrong with Rails Way™?

  4. http://rwdtow.stdout.in

  5. Most Rails developers do not write object-oriented Ruby code. They

    write MVC-oriented Ruby code by putting models and controllers in the expected locations. Most will add utility modules with class-methods in lib/, but that’s it. It takes 2-3 years before developers realize: “Rails is just Ruby. I can create simple objects and compose them in ways that Rails does not explicitly endorse! Mike Perham (the author and maintainer of Sidekiq) https://www.mikeperham.com/2012/05/05/five-common-rails-mistakes/
  6. Sharp Knives https://m.signalvnoise.com/provide-sharp-knives-cc0a22bf7934 Is it… ? • creating “simple solutions”

    for complicated issues, that caused by previous “simple solution” • thus each iteration is making entire system even more complex • but they call it a “sharp knife” and it’s all your fault to end up without 2 fingers!
  7. But actual goal of all these “sharp knives” is …

    to hide complexity and write less code in controller
  8. ActiveRecord callbacks Consequences: • Tight coupling • Fat Model •

    Indirect business logic flow (I know you write BL in Model :) ) Rails Way solution - SUPPRESS Notification.suppress do Relationship.create(follower: self, followed: user_to_follow) end https://medium.com/spritle-software/rails-5-activerecord-suppress-a-step-too-far-d7ec2e4ed027
  9. Strong Parameters with Nested Attrs Model contains multiple • Validation

    rules • accept_nested_attributes_for with conditions • Tight coupling of models Controller contains multiple • Strong params definitions per each action • Complex nested “require” and “permit”
  10. Monkey Patching, ActiveSupport Let’s rant one more time about 2.hours.ago

    Don’t get me wrong: • I like how it looks and how easy it is to write • What I don’t like ◦ Hidden internal complexity ◦ Shared state (timezone from current thread, internal call to Time.now) ◦ Architectural influence ◦ The need to “fix” this behaviour (via TimeCop gem)
  11. Rails or Ruby ? http://railshurts.com/quiz/

  12. View Helpers • config.action_controller.include_all_helpers - The default configuration behavior is

    that all view helpers are available to each controller • How big is your “application_helper.rb” ?
  13. TDD is dead • If it does not play well

    with Rails Way - blame it • Unit tests are not about testing, but about code architecture • Unit test is the best independent consumer of your code • If you can’t unit test your code - smth is wrong in your code, but not with Unit tests idea • Capybara test are good, but slow and require refactoring as soon as your HTML/CSS changes http://david.heinemeierhansson.com/2014/tdd-is-dead-long-live-testing.html
  14. You wanted a banana but what you got was a

    gorilla holding the banana and the entire jungle Allusion to Unit test vs Application test Joe Armstrong (creator of Erlang)
  15. TDD is dead! I suppose S.O.L.I.D too ?

  16. https://twitter.com/andrzejkrzywda/status/736265294516264960/photo/1

  17. What I want is... • configuration over confusion • boilerplate

    over magic
  18. Trailblazer at last, sorry for the long Rails Way intro,

    but you need to feel the pain! http://trailblazer.to, created by Nick Sutterer
  19. https://twitter.com/wojtha/status/736999191487057920

  20. Trailblazer… who uses it? http://bestgems.org/gems/trailblazer

  21. Concept based project structure ├── app │ ├── concepts │

    │ ├── blog_post │ │ │ ├── cell │ │ │ │ ├── create.rb │ │ │ │ ├── ... │ │ │ ├── contract │ │ │ │ ├── create.rb │ │ │ │ └── ... │ │ │ ├── operation │ │ │ │ ├── create.rb │ │ │ │ ├── ... │ │ │ └── view │ │ │ ├── create.erb │ │ │ └── ...│ | ├── controllers │ │ └── blog_posts_controller.rb │ └── models │ ├── blog_post.rb
  22. Cells - reusable view model • an object that can

    render a template • faster than ActionView • has its properties “contract” Navigation.new({ active: 1 }, current_user: current_user).()
  23. class Navigation < Trailblazer::Cell property :active def signed_in? options[:current_user] and

    model.active end def email options[:current_user].email end def avatar_url hexed = Digest::MD5.hexdigest(email) "https://www.gravatar.com/avatar/#{hexed}?s=36" End def show render # renders app/navigation/view/show.erb end end #show.erb <nav class="top-bar"> <ul> <li>GUIDES</li> <li> <% if signed_in? %> <img src="<%= avatar_url %>"> <% else %> SIGN IN <% end %> </li> </ul> </nav>
  24. Testing Cell class NavigationCellTest < Minitest::Spec it "renders avatar when

    user provided" do html = Navigation.(nil, current_user: User.find(1)).() html.must_match "Signed in: nick@trb.to" end end
  25. Reform - request form • Specify params • Data types

    • Coersion • Populaion • Validation form = AlbumForm.new(model) form.validate form.save
  26. Reform - example class SongForm < Reform::Form property :title, default:

    “Untitled Song” property :written_at, type: Types::Form::DateTime property :country, writeable: false property :song_uuid, virtual: true validates :written_at, presence: true def title=(value) super sanitize(value) # value is raw form input. end end
  27. Reform - example #2 class AlbumForm < Reform::Form include Composition

    property :id, on: :album property :title, on: :album property :songs, on: :cd property :cd_id, on: :cd, from: :id validates :title, presence: true end form = AlbumForm.new(album: album, cd: CD.find(1))
  28. Operation - the heart of Trailblazer • Orchestrate business logic

    • Not a god object • Wires all dependencies • Reusable independent from context BlogPost::Create.(params, current_user: current_user)
  29. Really common Operation pattern class BlogPost::Create < Trailblazer::Operation extend Contract::DSL

    contract do # All that Reform stuff goes here with properties and validates end step Policy::Guard( :authorize! ) step Model( BlogPost, :new ) step Contract::Build() step Contract::Validate() step Contract::Persist() step :notify! def authorize!(options, current_user:, **) current_user.signed_in? end def notify!(options, current_user:, model:, **) BlogPost::Notification.(current_user, model) end end
  30. class BlogPost::Create < Trailblazer::Operation step :authorize! step :model! step :persist!

    step :notify! def authorize!(options, current_user:, **) current_user.signed_in? end def model!(options, **) options["model"] = BlogPost.new end def persist!(options, params:, model:, **) model.update_attributes(params) model.save end def notify!(options, current_user:, model:, **) options["result.notify"] = BlogPost::Notification.(current_user, model) end end
  31. Operation Result def update result = BlogPost::Create.(params, current_user: current_user) if

    result.success? do flash[:notice] = "#{result["model"].title} has been saved" return redirect_to blog_post_path(result["model"].id) end end
  32. Railway

  33. Railway: mix of Either Monad and ROP

  34. Failure track module BlogPost class Create < Trailblazer::Operation success :hello_world!

    step :how_are_you? success :enjoy_your_day! failure :tell_joke! def hello_world!(options, *) end def how_are_you?(options, params:, **) params[:happy] == "yes" end def enjoy_your_day!(options, *) end def tell_joke!(options, *) end end end
  35. Testing Operation it do result = BlogPost::Create.( { name: "Hello"

    } ) expect(result.success?).to be_truthy # or smth like this expect(result["model"].seo_url).to eq “happy-1” end
  36. Stop doing if/then/else Start doing encapsulation

  37. Cell builder class CommentCell < Cell::ViewModel include ::Cell::Builder builds do

    |model, options| if model.is_a?(Post) PostCell elsif model.is_a?(Comment) CommentCell end end end CommentCell.(Post.find(1)) #=> creates a PostCell.
  38. Operation builder class Update < Trailblazer::Operation step Nested( :build! )

    def build!(options, current_user:nil, **) current_user.admin? ? Create::Admin : Create::NeedsModeration end end Update.(params, current_user: current_user)
  39. API done right with Representable

  40. Just reuse Operations + JSON (de)serialization

  41. Representable looks like ActiveModelSerializer, but... song_json = SongRepresenter.new(song).to_json #=> {"id":1,"title":"Fallout"}

    song = Song.new # empty Object song_object = SongRepresenter.new(song).from_json(song_json)
  42. Representable example class SongRepresenter < Representable::Decorator include Representable::JSON property :id,

    name: 'niceId' property :title, getter: ->(opts) { nice_title } property :artist do property :id property :name end property :studio, decorator: StudioRepresenter, class: Studio collection :mixes, decorator: SongMixRepresenter, class:SongMix end
  43. Trailblaser Endpoints (Work in progress) # Controller code result =

    Song::Create.( { title: "SVT" }, "current_user" => User.root ) Trailblazer::Endpoint.new.(result) do |m| m.success { |result| puts "Model #{result["model"]} saved" } m.unauthenticated { |result| puts "You ain't root!" } end http://trailblazer.to/gems/operation/2.0/endpoint.html
  44. Your own custom “Endpoints” - Controller def update result =

    ::User::Profile::Update.(params, user: current_user) render_json_operation_result(result) end
  45. Your own custom “Endpoints” - Concern def render_json_operation_result(result) if result.success?

    && result["model"].blank? return render_json_ok elsif result.success? && result["model"].present? return render_json_ok(result["model"]) elsif result.failure? && result["model"].blank? && result["model.action"] == :find_by return render_json_not_found ...
  46. Your own custom “Endpoints” - Concern ... elsif result.failure? &&

    result["result.contract.default"] && result["result.contract.default"].failure? errors = result['contract.default'].errors.full_messages << result['error'] errors_array = errors.uniq.reject(&:blank?) return render_json_request_error(errors_array) elsif result.failure? && result["model"].present? return render_json_request_error([result['error']]) else return render_json_error('Unknown operation result', 500) end end
  47. Tips and tricks to make Trailblazer a friend of Rails

  48. Duplicated operation steps step Contract::Build() step Contract::Validate() step :find_user step

    :send_instructions puts "PIPETREE:" puts self["pipetree"].inspect [ >operation.new >contract.build >contract.default.validate >find_user >send_instructions >contract.build >contract.default.validate >find_user >send_instructions ] Change Operation class and Class would be reloaded -> steps called one more time
  49. Duplicated operation steps - solution initialize_pipetree! step Contract::Build() step Contract::Validate()

    step :find_user step :send_instructions puts "PIPETREE:" puts self["pipetree"].inspect [ >operation.new >contract.build >contract.default.validate >find_user >send_instructions ]
  50. Require dependencies • Operations, Cells, Models etc. are loaded by

    trailblazer_loader gem • It has specific order • Models are loaded before Operations • If you need specific Operations loading order explicit require_dependency • It is needed if one Operation inherit from another one require_dependency("#{File.dirname(__FILE__)}/sign_up_form.rb")
  51. Operation Contract is Reform Form, that is in turn a

    Twin • Don't forget that Contract is a Disposable::Twin • All the great things like a Composition, default, nilify etc. could be used
  52. Shortcut helper # Controller code... def reset_password_form result = ForgotPassword::ResetForm.(params)

    @form, @model, @errors = form_model(result) if result.failure? flash[:error] = @errors end end module TrailblazerHelpers extend ActiveSupport::Concern def form_model(result) errors = result['contract.default'].errors.full_messages<<result['error'] errors_text = errors.uniq.join('. ') return [result["contract.default"], result["model"], errors_text] end end
  53. In a nutshell: what Trbr changes in my Rails app?

    • Model contains only Queries, Scopes and Relations. • No callbacks in Models! • Controller actions have only Operation calls and result render • No business logic in Controller, any data manipulation • No Strong Params used in Controller • All validation in only inside Operations • View helpers not used • All view logic placed in Cells • Shared view logic in Decorators • Mostly Cell and Operation builders used instead of if/then/else in Controller or view
  54. That’s all Yevhen Kuzminov kyzminov@gmail.com http://stdout.in @iJackUA --- More about

    Trailblazer http://trailblazer.to Go! Upgrade your Rails apps!