Extracting Service Objects for Cleaner and More Maintainable Code

Hello, fellow software designers! Today, we’re going to explore a powerful refactoring technique known as Extracting Service Objects. This technique helps you simplify your models and controllers, improve code maintainability, and adhere to the Single Responsibility Principle (SRP). Let’s dive into what service objects are, why you should use them, and how to extract them effectively.

What Does “Extract Service Objects” Mean?

Extracting Service Objects involves moving specific business logic or operations out of models or controllers and into dedicated service classes, often called service objects. This practice helps to:

  • Simplify the Original Class: By removing complex logic or multiple responsibilities.
  • Enhance Reusability: Service objects can be reused across different parts of the application.
  • Improve Testability: Service objects can be independently tested.
  • Follow SOLID Principles: Particularly the Single Responsibility Principle (SRP).

Why Extract Service Objects?

Over time, as your application grows, models or controllers can become “fat” — containing too much logic or too many responsibilities. This makes the code harder to understand, maintain, and test. Extracting service objects addresses this issue by isolating specific logic into its own class.

How to Extract Service Objects

  1. Identify the Business Logic or Operations: Look for complex or domain-specific logic inside your models or controllers that are not strictly related to their primary responsibility.
  2. Create a Service Object: Create a new class that encapsulates the identified logic.
  3. Move the Logic to the Service Object: Refactor the logic out of the model or controller and into the new service object.
  4. Use the Service Object: Replace the extracted logic in your model or controller with a call to the new service object.

Example: Extracting Service Objects

Let’s look at a practical example. Suppose you have a controller action that handles creating an order and calculating the total price with discounts, taxes, and shipping fees.

Original “Fat” Controller

class OrdersController < ApplicationController
  def create
    order = Order.new(order_params)
    
    if order.save
      # Calculate total price with discounts, taxes, and shipping
      discount = calculate_discount(order)
      tax = calculate_tax(order)
      shipping = calculate_shipping(order)
      total_price = order.subtotal - discount + tax + shipping

      order.update(total_price: total_price)
      
      render json: { order: order, success: true, message: :order_created }
    else
      render json: { success: false, errors: order.errors }
    end
  end

  private

  def calculate_discount(order)
    # Complex discount calculation logic here
  end

  def calculate_tax(order)
    # Complex tax calculation logic here
  end

  def calculate_shipping(order)
    # Complex shipping calculation logic here
  end

  def order_params
    params.require(:order).permit(:item_id, :quantity, :user_id)
  end
end

Here, the create action is handling multiple responsibilities, making it hard to maintain.

Step 1: Extract Service Objects

Let’s extract the logic for calculating the total price into a service object.

Service Object: OrderTotalCalculatorService
# app/services/order_total_calculator_service.rb
class OrderTotalCalculatorService
  def initialize(order)
    @order = order
  end

  def execute
    discount = calculate_discount
    tax = calculate_tax
    shipping = calculate_shipping
    total_price = @order.subtotal - discount + tax + shipping

    total_price
  end

  private

  attr_reader :order

  def calculate_discount
    # Logic to calculate discount
  end

  def calculate_tax
    # Logic to calculate tax
  end

  def calculate_shipping
    # Logic to calculate shipping
  end
end

Step 2: Refactor the Controller to Use the Service Object

Refactor the controller to delegate the total price calculation to the service object:

class OrdersController < ApplicationController
  def create
    order = Order.new(order_params)
    
    if order.save
      # Use the service object to calculate the total price
      total_price = OrderTotalCalculatorService.new(order).execute
      order.update(total_price: total_price)
      
      render json: { order: order, success: true, message: :order_created }
    else
      render json: { success: false, errors: order.errors }
    end
  end

  private

  def order_params
    params.require(:order).permit(:item_id, :quantity, :user_id)
  end
end

Step 3: Further Extract Smaller Services if Needed

If the methods inside OrderTotalCalculatorService are still complex, extract them into smaller services, like DiscountCalculatorService, TaxCalculatorService, and ShippingCalculatorService.

Benefits of Extracting Service Objects

  1. Improved Readability: Each service has a single responsibility and a clear purpose.
  2. Enhanced Reusability: Service objects can be reused in different parts of the application.
  3. Simplified Testing: Each service can be tested in isolation.
  4. Easier Maintenance: Changes can be made in one place without affecting other parts of the application.
  5. Better Adherence to SOLID Principles: Especially SRP and OCP.

Conclusion

Extracting service objects is a powerful technique to improve the structure, readability, and maintainability of your code. It keeps your models and controllers clean and focused while encapsulating complex or domain-specific logic in dedicated classes.

Stay tuned for more insights into software design principles and patterns!

Thôi Lo Code Đi Kẻo Sếp nạt!!