1. 6
  1.  

  2. 4

    Pardon my ignorance, but isn’t this just reinventing the concept of a subroutine?

    Maybe I missed something.

    1. 4

      Same question, but for top-level functions.

      1. 3

        I can’t help but feel smug, from the top of my pile of Python’s __call__ methods.

      2. 1

        Yeah, on first read I thought this was going to be another Java parody like the “kingdom of nouns” essay.

        Also I am on record as being strongly against trying to do “service layers” or equivalent with Active Record ORMs (not just the default Rails ORM which is actually named ActiveRecord, but all ORMs built on the Active Record design pattern).

      3. 2

        I’ve been using this pattern of naming and structuring service objects over the past ~year. It’s initially been greeted with some uncertainty and skepticism when I introduce it to new developers, but it tends to grow on folks as they give it a shot and look at how it’s already been used.

        I wrote this up partially to codify some of how I’ve been explaining it to folks ad-hoc, but also to share with the broader community and get input on this way of extracting processes in Ruby/Rails projects.

        1. 3

          Good starting point, but I would strongly advise reducing your dependency on constants, and using the initialization for dependency-injection, so that you can treat these like first-class functions.

          The Dry-rb family of gems has been supporting this style for a while.

          • dry-monads is an ideal Result type for these objects to use, so that you can trivially compose them
          • dry-container gives you a threadsafe object to register keys. This is used to encapsulate the build process of instantiating a class with optional memoization
          • dry-auto_inject builds on that to give you a DSL for injecting container keys during initialization
          • dry-system builds on that to give you automatic, convention-based component registration
          • dry-rails provides some nice integration for Rails controllers
          • Hanami Has been rewritten from the ground up to follow these principles

          So your controller example might look more like

          # app/operations/make_purchase.rb
          class MakePurchase < Operation
            include Deps[:create_order, :process_payment, :fulfill_order]
          
            def call(customer:, product:)
              order = yield create_order.(customer:, product:)
              result = yield process_payment.(payment_method: customer.payment_method, amount: order.cost)
          
              if result.declined?
                Failure[:declined, result]
              else
                fulfill_order.(order:)
              end
            end
          end
          

          Every time the Operation yields, it is checking the monad type. A Failure will immediately halt execution and return the failure result, while a Success is unwrapped for continued processing.

          # app/controllers/purchases_controller.rb
          class PurchasesController < ApplicationController
            schema :create do
              required(:product_id).filled(:integer)
            end
          
            before_action do
              if safe_params&.failure?
                render(:error, errors: safe_params.errors.to_h)
              end
            end
          
            def create
              product = Product.find(safe_params[:product_id])
              case resolve(:make_purchase).(customer: current_user, product:)
              in Success(order)
                redirect_to order
              in Failure[:declined, result]
                render :declined, result
              in Failure(MyApp::Error => err)
                render :server_error, err
              end
            end
          end
          

          You get the Operation by name using resolve. This means that 1. your controller doesn’t need knowledge of how to instantiate MakePurchase, and 2. the specific constant identity is irrelevant. Want to refactor the process in a different Operation? Replace the automatic container key with a manual registration that uses a feature flag to choose the implementation.

          MyApp::Container.register(:make_purchase) do
            if Feature?(:new_purchase)
              NewMakePurchase.new
            else
              MakePurchase.new
            end
          end
          

          This also makes testing really easy. Need to stub out the coordinating dependencies? Inject a proc in your unit test.

          subject :make_purchase do
            described_class.new(
              create_order: ->(**) { Success(fake_product) },
              process_payment: ->(**) { Success(FakePayment.new(payment_method:, amount: Money.new(3200, "USD"))) }
            )
          end
          

          If your dependency graph is more complicated than this, you can also stub the container keys directly

          let(:process_payment) { Success(FakePayment.new(payment_method:, amount: Money.new(3200, "USD"))) }
          
          around :each do |example|
            container.stub(:process_payment, process_payment) { example.run }
          end
          
        2. 1

          I find the font really hard to read and had to disable it to finish the article.

          1. 1

            Luv reader mode