Cookie Consent by Free Privacy Policy Generator 📌 Refactoring for Readability in Ruby: a Showcase


✅ Refactoring for Readability in Ruby: a Showcase


💡 Newskategorie: Programmierung
🔗 Quelle: dev.to

The code below is a business operation that answers the question: "What are the available appointment slots for a given practitioner within a week, starting from a given date?".

Initially, the logic was implemented as a single method within a Practitioner class. That code was working and was fully covered with tests, but is was not very readable, so I decided to refactor it.

I wanted to achieve the following:

1) The logic should be extracted from it's initial context and encapsulated in a separate class.
2) The class should follow the [slightly simplified version of the] coding style I describe in my rails code organization showcase.
3) The logic inside that class should be split into several methods that maintain a single level of abstraction.

With this showcase, I want to demonstrate the importance & the benefits of maintaining a single level of abstraction in your code. Compare the two implementations and ask yourself which you would like to work with

The Setup

The application in general solves a problem of connecting individual doctors with patients.

The part of it we're interested in here has 2 models: Practitioner & Event. Practitioner holds the doctors, Event -- the events related to the practitioner's available time. There are two types of events: "opening" and "appointment":

  • Events with kind = "opening" indicate that a given practitioner has marked some time window as available for appointments. The time window is always a multiple of 30 minutes, e.g. 10:00 - 11:30 or 10:00 - 10:30. 30 minutes is the size of a standard appointment slot, so a single opening event might contain multiple slots.

  • Events with kind = "appointment" indicate that a patient has booked a particular time slot, which is always 30 minutes.

Event model has the following fields:

  • practitioner_id:integer
  • kind:string
  • starts_at:datetime
  • ends_at:datetime
# app/models/practitioner.rb
#
class Practitioner < ApplicationRecord
  has_many :events
  has_many :openings, -> { opening }, class_name: 'Event'
  has_many :appointments, -> { appointment }, class_name: 'Event'
end

# app/models/event.rb
#
class Event < ApplicationRecord
  enum kind: { opening: 'opening', appointment: 'appointment' }
  belongs_to :practitioner
end

The business operation is being called from the controller action, so it's one of the entry points to the application logic.

Initial implementation

# app/controllers/api/v1/practitioners/appointment_slots_controller.rb
#
module Api
  module V1
    module Practitioners
      class AppointmentSlotsController < ApplicationController
        def index
          practitioner = Practitioner.find(params[:practitioner_id])
          slots = practitioner.available_slots(params[:date])

          render json: slots, serializer: Api::V1::AppointmentSlotsSerializer
        end
      end
    end
  end
end

# app/models/practitioner.rb
#
class Practitioner < ApplicationRecord
  # ...

  def available_slots(date)
    date = Date.parse(date)
    availabilities = 7.times.to_h { |i| [date + i.days, []] }

    openings = openings.where(starts_at: (date..(date + 1.week)))
    appointments = appointments.where(starts_at: (date..(date + 1.week)))

    openings.each do |opening|
      slots = []

      slot = opening.starts_at
      loop do
        slots << slot

        slot = slot + 30.minutes
        break if slot >= opening.ends_at
      end

      slots = slots.reject { |slot| appointments.any? { _1.starts_at == slot } }

      availabilities[opening.starts_at.to_date] += slots
    end

    availabilities
      .transform_keys { |date| date.strftime("%Y-%m-%d") }
      .transform_values { |slots| slots.sort.map { _1.strftime("%H:%M") } }
  end
end

The Refactoring process

After meditating for some time on the initial implementation, I've realized that the problem of getting available time slots might be split into 3 sub-problems:

  • How to get the slots for a given practitioner?
  • How to reject the taken slots?
  • How to format the result?

If I want to maintain a single level of abstraction, the main method of the refactored code should have those three steps, easily readable, and as few other stuff as possible. Let's sketch it:

def call(...)
  slots = get_slots(...)
  available_slots = reject_taken(slots)
  format_result(available_slots)
end

The other important thing to notice is that the initial implementation has the logic of the operation split between the controller action and the Practitioner#available_slots method: controller fetches the practitioner, method does the rest. People are used to writing this kind of code because all rails guides encourage it, so we often do things like this without thinking, but there actually are things to reconsider here. Knowing how to find the resource(s) the operation operates on is conceptually a part of the business operation itself, this logic might become complex with time, so in all but the trivial cases I try to put it into my business operations. Updating the sketch accordingly:

def call(practitioner_id:, ...)
  practitioner = Practitioner.find(practitioner_id)

  slots = get_slots(practitioner, ...)
  available_slots = reject_taken(slots, ...)
  format_result(available_slots, ...)
end

The last thing that substitutes the responsibilities of the operation is the input parsing. User provides the date as a string, controller extracts that from the params, but in my opinion knowing how to parse it should be the business operation's responsibility. In that particular case, it might seem arguable, but if you think of other business operations your system might have, some of them may receive more complex data as an input (e.g. a data from a Stripe callback), knowing how to parse that data is clearly a part of a domain knowledge and therefore it should not stay in the controller. Returning to our example, I choose to parse the date within a business operation cause I believe that the more unification you have in your code base -- the better. But that's a topic for a separate blog, if not the whole conference talk.

With all that said, I update the sketch for the last time:

def call(practitioner_id:, start_date:)
  practitioner = Practitioner.find(practitioner_id)
  start_date = Date.parse(start_date)

  slots = get_slots(practitioner, start_date, ...)
  available_slots = reject_taken(slots, ...)
  format_result(available_slots, ...)
end

Now that I've figured out the high-level structure of the operation and the complete set of it's responsibilities, I can start implementing it. The [personal] conventions I follow here are:

  • Each business operation gets it's own class named with a verb
  • Operations are organized into the [subdomain/resource/operation] pattern. I don't mention other subdomains in this blog so I'll skip the subdomain part and just put the code to /app/services, which is a good place for the code you don't know which subdomain to fit into yet.
  • Operations have the only public method, which is always #call
  • Operations always get their inputs as keyword arguments
  • #call is documented with YARD

I also try to identify the parameters hard-coded into the code and extract them into constants for visibility. In this example, I've extracted the length of the interval we fetch and the size of the slot.

The code becomes more readable when the related things are close to each other, so I've moved the fetching of openings/appointments into the low-level methods, closer to where they are being used. Sometimes this can't be done for optimization reasons, but our case is not like that.

And, finally, I try to simplify the code wherever I can, traces of which you may аштв in the resulting code.

While reading the resulting implementation, I encourage you to ask yourself, how do you feel about it, compared to the initial one. Do you like it? Does this code require less effort to understand? How simple it'll be to change it? What would you make differently? Let me know in the comments below

The Result

# app/controllers/api/v1/practitioners/appointment_slots_controller.rb
#
module Api
  module V1
    module Practitioners
      class AppointmentSlotsController < ApplicationController
        def index
          slots = ::Practitioners::GetAvailableSlots.new.call(
            practitioner_id: params[:practitioner_id],
            start_date: params[:date]
          )

          render json: slots, serializer: Api::V1::AppointmentSlotsSerializer
        end
      end
    end
  end
end

# app/services/practitioners/get_available_slots.rb
#
module Practitioners
  class GetAvailableSlots
    DAYS_TO_FETCH = 7
    SLOT_SIZE = 30.minutes

    # @param practitioner_id [Integer]
    # @param start_date [String]
    #
    # @return [Hash{String=>Array<String>}]
    # @raise [ActiveRecord::RecordNotFound]
    # @raise [Date::Error]
    #
    def call(practitioner_id:, start_date:)
      start_date = Date.parse(start_date)
      practitioner = Practitioner.find(practitioner_id)

      get_slots(practitioner, start_date)
        .then { reject_taken(practitioner, _1) }
        .then { format_result(_1, start_date) }
    end

    private

    def get_slots(practitioner, start_date)
      openings = practitioner.openings.where(starts_at: fetch_interval(start_date))

      openings.flat_map { split_into_slots(_1) }
    end

    def reject_taken(practitioner, slots)
      interval = ((slots.min)..(slots.max))
      appointments = practitioner.appointments.where(starts_at: interval)

      slots.reject { |slot| appointments.any? { _1.starts_at == slot } }
    end

    def format_result(slots, start_date)
      DAYS_TO_FETCH.times.to_h do |i|
        date = start_date + i.days
        day_slots = slots.select { (date...(date + 1.day)).cover?(_1) }

        [date.strftime('%Y-%m-%d'), day_slots.sort.map { _1.strftime('%H:%M') }]
      end
    end

    # Opening might stretch across multiple time slots, in this case we sptit 
    # it into chunks of 30 minutes: opening<starts_at=10:00, ends_at=11:30>
    # becomes [10:00, 10:30, 11:00]
    #
    def split_into_slots(opening)
      num_slots = (opening.ends_at - opening.starts_at).round / SLOT_SIZE
      num_slots.times.map { |i| opening.starts_at + i * SLOT_SIZE }
    end

    def fetch_interval(start_date)
      start_date..(start_date + DAYS_TO_FETCH.days)
    end
  end
end
...

✅ Refactoring for Readability in Ruby: a Showcase


📈 63.73 Punkte

✅ Refactoring Ruby: Introduce Null Object


📈 28.7 Punkte

✅ Showcase 2024: Xbox Games Showcase in 30 Sprachen


📈 24.49 Punkte

✅ Trimming JavaScript: ditch verbosity, gain readability


📈 22.79 Punkte

✅ Readability and Naming Things


📈 22.79 Punkte

✅ Enhancing Readability of Python Code via Annotations


📈 22.79 Punkte

✅ Go Code Roast #2: readability.js port


📈 22.79 Punkte

✅ Humanizing Word Error Rate for ASR Transcript Readability and Accessibility


📈 22.79 Punkte

✅ Firefox Reader View in your terminal - readability-cli - remove bloat from HTML pages


📈 22.79 Punkte

✅ PHP: basic loops vs. built-in helpers vs. readability


📈 22.79 Punkte

✅ Firefox 73 Arrives With Default Zoom Level and Readability Backplate


📈 22.79 Punkte

✅ LAPS now has passphrases, better readability & more


📈 22.79 Punkte

✅ Crafting React Components: Readability, Reusability, and Efficiency


📈 22.79 Punkte

✅ Optimizing GitLab CI for Readability and Maintainability : From 1K to 600 Lines!


📈 22.79 Punkte

✅ IT Resume Dos and Don'ts: Formatting for Readability


📈 22.79 Punkte

✅ Choosing Your Readability & Maintainability Path


📈 22.79 Punkte

✅ How to structure your code for readability


📈 22.79 Punkte

✅ How to Customize Slack Fonts Using Stylus for Improved Readability


📈 22.79 Punkte

✅ Readability Score Analysis


📈 22.79 Punkte

✅ Google Revamps Google News for Readability, Adds Dedicated 'Fact Check' Block


📈 22.79 Punkte

✅ Readability Englisch


📈 22.79 Punkte

✅ Mastering Typography in UI Design: Enhancing Readability and User Experience


📈 22.79 Punkte

✅ Get to know Flesh Kincaid Readability Score 📃


📈 22.79 Punkte

✅ Type Hinting and Annotations in Python: Improving Code Readability and Understanding


📈 22.79 Punkte

✅ Decoding Readability Score? Meaning, Use-cases & Approaches


📈 22.79 Punkte

✅ ruby-grape Gem auf Ruby format Cross Site Scripting


📈 20.46 Punkte

✅ private_address_check Ruby Gem bis 0.4.x auf Ruby Socket TOCTOU Race Condition


📈 20.46 Punkte

✅ Choosing Ruby: What made me choose Ruby as my primary programming language.


📈 20.46 Punkte

✅ yajl-ruby gem 1.3.0 auf Ruby yajl_encode.c Yajl::Parser.new.parse Denial of Service


📈 20.46 Punkte

✅ Semiology in Ruby (What are Ruby Symbols) ?


📈 20.46 Punkte

✅ espeak-ruby Gem bis 1.0.2 auf Ruby lib/espeak/speech.rb speak/save/bytes/bytes_wav erweiterte Rechte


📈 20.46 Punkte

✅ Pixar ruby-jss Gem up to 1.5.x on Ruby XML Document Remote Privilege Escalation


📈 20.46 Punkte











matomo

Datei nicht gefunden!