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
https://www.facebook.com/events/305615329893245

Ac3e162318c73347bef4d20b1bb7f7f3?s=128

Yevhen "Eugene" Kuzminov

June 20, 2017
Tweet

Transcript

  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!