DEV Community

Refactoring for Readability in Ruby: a Showcase

Dmitrii Krasnov on April 25, 2024

The problem Within my 10 years of developer experience, over and over again I see an unnecessarily complex code: methods span tens of li...
Collapse
 
epigene profile image
Augusts Bautra

Thanks for sharing this, Dimitrii, popped up in my google feed. I'll read through on Monday, but talking about how to factor Rails code beyond MVC directories is sorely needed.

Collapse
 
epigene profile image
Augusts Bautra

OK, here are my thoughts after having read the post. Once again, thanks for sharing, Dimitrii.

  • May be out of scope, but the modeling of openings and appointments as pseudo-STI via :kind seems sub-optimal. Why not have standalone models - Opening and Appointment? Would be great if we could agree that openings and their one appointment start and end strictly at 30-min steps, for example 10:00, never 10:05 etc.
  • Good job identifying that there's a split of logic between controller and model. I try to follow "Skinny controller" paradigm (well, skinny-everything, really), so ideally a controller action merely handles HTTP and wraps some service call:
def index
  slot_lookup_outcome = 
    AppointmentSlotSecretary.call(practitioner_id: params[:practitioner_id])
    # OR
    AppointmentSlotSecretary.call(**index_params.permit!.to_h)

  if slot_lookup_outcome.success?
    render json: slot_lookup_outcome.slots, serializer: Api::V1::AppointmentSlotsSerializer
  else
    render json: slot_lookup_outcome.errors, ...
  end    
end
Enter fullscreen mode Exit fullscreen mode
  • Where to put authorization checks VS process validity checks is a hard topic. Consider some "PublishBook" operation. Not only does it need to check whether the request-making actor has permission to publish books (or this particular book at least), but also whether the book can be published (has passed spell-check etc.). On the one hand Separation of Concerns (SoC) makes sense here in that it's easier to develop and test the operation assuming authorization was passed, the "if we got to operation logic, it was authorized" philosophy. But on the other hand leaving it in the controller oftentimes makes it hard to understand, especially if authorization is complex (it can get complex for creation operations where several records are used as "parents"). In those cases maybe a special subset of operations - Authorizators - need to be introduced.
  • On "Knowing how to parse [date param] should be the business operation's responsibility". I disagree. I believe uncle Bob would use the "if it were the 80s and business consisted of a dude with a phone and a spreadshseet, would he need this?" test. The answer is No. Param parsing is a side-effect of using HTTP, not business domain, so it's fine to leave it in the controller. Alternatively, you can approach this problem from Operation class API design perspective. What is better initialize(.., start_date: Date or initialize(.., start_date: String. This may vary from situation to situation. There are no solutions, only trade-offs.
  • I very much agree with your Operation conventions - only kwargs, only one public #call method, YARD docs. But I go a step further - move the params to the initializer. This way the instance gets to hold all the relevant data and less needs to be passed around with arguments:
def initialize(practitioner_id:, start_date:)
  @practitioner_id = practitioner_id, 
  ...
end

def call
  slots
end

private

attr_reader :practitioner_id, :start_date

def practitioner
  @practitioner ||= Practitioner.find(practitioner_id)
end
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vizvamitra profile image
Dmitrii Krasnov • Edited

Thank you very much for such a detailed feedback! Let me try to address all your points one by one

On the data model issues

Agree, the data model doesn't seem good to me as well, but I'm not the one who modeled it and I don't know all the details. Maybe they had their reasons, maybe that's only a part of the picture, who knows?

On a presentation/application boundary

I'm glad we're on the same here! I usually also split the UI responsibilities a bit further,into user-facing part (the controller action) and application-facing part (Action class). You can find an example of such a class here

On where to place authorization

Agree, authorization is a corner case. I'm still not 100% sure here, but my current thinking is that the permissions/roles are a part of the application logic and thus should stay within it, not outside. Rails ecosystem provides gems with simple mechanisms to authorize right within controllers, but having it there forces controllers to know about your application logic and system state, which creates a coupling between your presentation and application layers, which I'm not a fan of. This might be good at the start, but bad later when it comes to organizing the code into subdomains, cause controllers will know too much.

But once again, I'm not 100% sure. I've never developed a really complex authorization system, and as for the case you've mentioned, it'll be pretty easy to implement it right within the operation:

module Books
  class Publish
    # ...
    # @raise [ActiveRecord::RecordNotFound]
    # @raise [Books::NotPublishableError]
    #
    def call(author_id:, book_id:)
      author = Author.find(author_id)

      # This way we check if the book belongs to the author
      book = author.books.find(book_id) 
      raise NotPublishableError if !book.publishable?

      publish(book, author)
    end

    # ...
  end
end
Enter fullscreen mode Exit fullscreen mode

Sorry, I need to board the plane, will continue later

Thread Thread
 
vizvamitra profile image
Dmitrii Krasnov • Edited

The date param.

I think you're right, it is a side effect and it should be parsed in the controller. God, such a minor thing for this example and such a lot of considerations! I've already thought about controller's responsibility to set request context (timezone), parsing webhook data being a responsibility of an infrastructure and not the application layer, complex inputs that doesn't map to ruby data types and should be parsed within the application layer, benefits of unification, benefits of simplification and delaying the decisions...

On passing params through an initializer

I understand why it seems cleaner to move the arguments to an initializer, but my decision to pass arguments through the #call method is deliberate. The main reason for this is that I reserve the initializer for dependency injection and configs. If eventually the logic in get_slots will become so complex that I'll decide to extract it into a separate class, or if the slot size will become a part of the application configuration, they'll become external dependencies of this class and I'll inject them as following:

class GetAvailableSlots
  def initialize(get_slots: Practitioners::GetAppointmentSlots.new)
    @_get_slots = get_slots
  end

  private

  attr_reader :_get_slots

  def get_slots(practitioner, start_date)
    _get_slots.call(practitioner:, start_date:, days_to_fetch:)
  end
end
Enter fullscreen mode Exit fullscreen mode

or

class GetAvailableSlots
  def initialize(slot_length: Rails.application.config.appointment_slot_length)
    @_slot_length = slot_size.minutes
  end

  private

  attr_reader :_slot_length

  def split_into_slots(opening)
    num_slots = (opening.ends_at - opening.starts_at).round / _slot_length
    num_slots.times.map { |i| opening.starts_at + i * _slot_length }
  end
end
Enter fullscreen mode Exit fullscreen mode

(I never use instance variables directly cause they are read-only and there's no mechanism in Ruby to protect the instance variable from being changed when addressed with @)

The two benefits of this approach are:

  • In your tests, you can easily stub all the external dependencies by passing procs. In Ruby, an object with an instance method #call acts exactly as a proc:
Rspec.describe "Practitioners::GetAvailableSlots" do
  subject(:get_available_slots) { described_class.new(get_slots: -> { [] }) }
end
Enter fullscreen mode Exit fullscreen mode
  • This one popped up for me after using such an approach consistently for a while. Listing all the external dependencies in one place makes them more visible, which made me think more about what my code depends on and, with time, become a better programmer. I suggest you to check that talk by Tim Riley for the full picture
Thread Thread
 
epigene profile image
Augusts Bautra • Edited

On passing params through an initializer, cont.d
To be frank, I think you're complexing things by drawing a distinction between "dep-injections and config" VS "real params". All that underscore-prefixing.. Embrace KISS, Power of One etc. etc. Kwargs give you all the flexibility you need.

class GetAvailableSlots
  DAYS_TO_FETCH = 7
  SLOT_SIZE = 30.minutes

  # @param practitioner_id [Integer]
  # @param start_date [String]
  # @param slot_querier [Class] one of slot querying service classes
  # @param slot_length [Integer] intended slot length, in seconds 
  def initialize(practitioner_id:, start_date:, slot_querier: GetAppointmentSlots, slot_length: SLOT_SIZE) 
    @practitioner_id = practitioner_id
    @start_date = start_date
    @slot_querier = slot_querier
    @slot_length = slot_length
  end

  # @return [Hash{String=>Array<String>}]
  # @raise [ActiveRecord::RecordNotFound]
  # @raise [Date::Error]
  def call
    slots ... 
  end

  private

  attr_reader :practitioner_id, :start_date, :starting_slots, :slot_length

  def slots
    @slots ||= slot_querier.new(practitioner: practitioner, ...).call
  end
end
Enter fullscreen mode Exit fullscreen mode

And I don't follow your point about tests, init params are just as easy to specify or omit. In my example, you could even define an ad-hoc querier class to inject:

Rspec.describe "Practitioners::GetAvailableSlots", instance_name: :service do
  let(:service) { described_class.new(**options) }

  describe "#call" do
    subject(:call) { described_class.new(get_slots: -> { [] }) }

    context "when initialized with a custom slot querier class" do
      let(:options) { {practitioner_id: practitioner.id, start_date: 1.week.since.to_date, slot_querier: custom_slot_querier} }

      let(:custom_slot_querier) do
        c = Class.new(GetAppointmentSlots) do
          # slot-getting logic
        end

        stub_const("MyCustomQuerier", c)
      end

      it "returns expected slots" do
        is_expected.to eq(some_slots)
      end
    end  
  end  
end
Enter fullscreen mode Exit fullscreen mode

Update: Having watched Tim's presentation, I see why the desire to have init-call param split. I cooked up a response why it's a bad idea.

Collapse
 
katafrakt profile image
Paweł Świątkowski

This is a great refactoring story, thanks for sharing!

I do, however, similar to @epigene, disagree with parsing the date from string inside this business class. It's a detail coming from infrastructure. The object should receive already parsed Date object.

To illustrate the point: let's assume this get called from a webhook from some service that passes the date as a UNIX timestamp. Now you need to change your business logic modelling class only because of that, but the actual logic does not change.

Collapse
 
unjoker profile image
Max Cervantes

I like it when the object could operate on it's data, rather than being acted upon. From that point of view the end result is an anemic biz object

Collapse
 
vizvamitra profile image
Dmitrii Krasnov

Thank you for your feedback. The approach I'm using is definitely not common, that's one of the reasons I'm sharing it. Same topic was popped up by a person under my announcement of that post on reddit, I'll leave the link here, feel free to join the discussion.

reddit.com/r/ruby/comments/1cd3i1l...

Collapse
 
katafrakt profile image
Paweł Świątkowski

I think it's more common than you think ;) I have seen similar approaches in pretty much every more mature Rails codebase I worked on.

Talking about anemic business object is a big misunderstanding what's going on in this code. It's not a business object, rather a business transaction wrapped in OOP code. Nothing wrong with that and DDD books describe this approach too.

Thread Thread
 
vizvamitra profile image
Dmitrii Krasnov

I'm glad to hear that ^_^ In 80% of my past jobs the legacy code I've inherited was anything but readable

Collapse
 
gnuduncan profile image
Riccardo Lucatuorto

Thanks, I usually do also this

    def call(practitioner_id:, start_date:, days_to_fetch: DAYS_TO_FETCH, slot_size: SLOT_SIZE)
Enter fullscreen mode Exit fullscreen mode

I find this usefull