Model-View-Controller is a design pattern which absolutely dominated web frameworks. On the first look it provides a great and logical separation between our application components. When we apply some basic principles (like 'fat models, slim controllers') to our application, we can live happily very long with this basic fragmentation.

However, when our application grows, our skinny controllers become not so skinny over time. We can't test in isolation, because we're highly coupled with the framework. To fix this problem, we can use service objects as a new layer in our design.

Entry point

I bet many readers had some experience with languages like C++ or Java. This languages have a lot in common, yet are completely different. But one thing is similar in them - they have well defined entry point in every application. In C++ it's a main() function. The example main function in C++ application looks like this:

#include <iostream>
// Many includes...

int main(int argc, char *argv[]) {
  // Fetch your data.
  // Ex. Input data = Input.readFromUser(argc, argv);

  Application app = Application(data);
  app.start();

  // Cleanup logic...
  return 0;
}

If you run your application (let it be ./foo), main function is called and all arguments after it (./foo a b c) are passed in argv as strings. Simple.

When C++ application grows, nobody sane puts logic within main. This function only initializes long-living objects and runs a method like start in above example.

But why we should be concerned about C++ when we're Rails developers?

Controller actions are entry points

As title states, Rails has multiple entry points. Every controller action in Rails is the entry point! Additionaly, it handles a lot of responsibilities (parsing user input, routing logic [like redirects], logging, rendering… ouch!).

We can think about actions as separate application within our framework - each one with its private main. As I stated before, nobody sane puts logic in main. And how it applies to our controller, which in addition to it's responsibilities takes part in computing response for a client?

Introducing service objects

That's where service objects comes to play. Service objects encapsulates single process of our business. They take all collaborators (database, logging, external adapters like Facebook, user parameters) and performs a given process. Services belongs to our domain - They shouldn't know they're within Rails or webapp!

We get a lot of benefits when we introduce services, including:

  • Ability to test controllers - controller becomes a really thin wrapper which provides collaborators to services - thus we can only check if certain methods within controller are called when certain action occurs,

  • Ability to test business process in isolation - when we separate process from it's environment, we can easily stub all collaborators and only check if certain steps are performed within our service.

  • Lesser coupling between our application and a framework - in an ideal world, with service objects we can achieve an absolutely technology-independent domain world with very small Rails part which only supplies entry points, routing and all 'middleware'. In this case we can even copy our application code without Rails and put it into, for example, desktop application.

  • They make controllers slim - even in bigger applications actions using service objects usually don't take more than 10 LoC.

  • It's a solid border between domain and the framework - without services our framework works directly on domain objects to produce desired result to clients. When we introduce this new layer we obtain a very solid border between Rails and domain - controllers see only services and should only interact with domain using them.

Example

Let's see a basic example of refactoring controller without service to one which uses it. Imagine we're working on app where users can order trips to interesting places. Every user can book a trip, but of course number of tickets is limited and some travel agencies have it's special conditions.

Consider this action, which can be part of our system:

class TripReservationsController < ApplicationController
  def create
    reservation = TripReservation.new(params[:trip_reservation])
    trip = Trip.find_by_id(reservation.trip_id)
    agency = trip.agency

    payment_adapter = PaymentAdapter.new(buyer: current_user)

    unless current_user.can_book_from?(agency)
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:agency_rejection)
    end

    unless trip.has_free_tickets?
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:tickets_sold)
    end

    begin
      receipt = payment_adapter.pay(trip.price)  
      reservation.receipt_id = receipt.uuid

      unless reservation.save
        logger.info "Failed to save reservation: #{reservation.errors.inspect}"
        redirect_to trip_reservations_page, notice: TripReservationNotice.new(:save_failed)
      end

      redirect_to trip_reservations_page(reservation), notice: :reservation_booked
    rescue PaymentError
      logger.info "User #{current_user.name} failed to pay for a trip #{trip.name}: #{$!.message}"
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:payment_failed, reason: $!.message)
    end
  end
end

Although we packed our logic into models (like agency, trip), we still have a lot of corner cases - and our have explicit knowledge about them. This action is big - we can split it to separate methods, but still we share too much domain knowledge with this controller. We can fix it by introducing a new service:

class TripReservationService
  class TripPaymentError < StandardError; end
  class ReservationError < StandardError; end
  class NoTicketError < StandardError; end
  class AgencyRejectionError < StandardError; end

  attr_reader :payment_adapter, :logger

  def initialize(payment_adapter, logger)
    @payment_adapter = payment_adapter
    @logger = logger
  end

  def process(user, trip, agency, reservation)
    raise AgencyRejectionError.new unless user.can_book_from?(agency)
    raise NoTicketError.new unless trip.has_free_tickets?

    begin
      receipt = payment_adapter.pay(trip.price)  
      reservation.receipt_id = receipt.uuid

      unless reservation.save
        logger.info "Failed to save reservation: #{reservation.errors.inspect}"
        raise ReservationError.new
      end
    rescue PaymentError
      logger.info "User #{user.name} failed to pay for a trip #{trip.name}: #{$!.message}"
      raise TripPaymentError.new $!.message
    end
  end
end

As you can see, there is a pure business process extracted from a controller - without routing logic.

Our controller now looks like this:

class TripReservationsController < ApplicationController
  def create
    user = current_user
    trip = Trip.find_by_id(reservation.trip_id)
    agency = trip.agency
    reservation = TripReservation.new(params[:trip_reservation])

    begin
      trip_reservation_service.process(user, trip, agency, reservation)
    rescue TripReservationService::TripPaymentError
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:payment_failed, reason: $!.message)
    rescue TripReservationService::ReservationError
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:save_failed)
    rescue TripReservationService::NoTicketError
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:tickets_sold)
    rescue TripReservationService::AgencyRejectionError
      redirect_to trip_reservations_page, notice: TripReservationNotice.new(:agency_rejection)
    end

    redirect_to trip_reservations_page(reservation), notice: :reservation_booked
  end

  private
  def trip_reservation_service
    TripReservationService.new(PaymentAdapter(buyer: current_user), logger)
  end
end

It's much more concise. Also, all the knowledge about process are gone from it - now it's only aware which situations can occur, but not when it may occur.

A word about testing

You can easily test your service using a simple unit testing, mocking your PaymentAdapter and Logger. Also, when testing controller you can stub trip_reservation_service method to easily test it. That's a huge improvement - in a previous version you would've been used a tool like Capybara or Selenium - both are very slow and makes tests very implicit - it's a 1:1 user experience after all!

Conclusion

Services in Rails can greatly improve our overall design as our application grow. We used this pattern combined with service-based architecture and repository objects in Chillout.io to improve maintainability even more. Our payment controllers heavy uses services to handle each situation - like payment renewal, initial payments etc. Results are excellent and we can be (and we are!) proud of Chillout's codebase. Also, we use Dependor and AOP to simplify and decouple our services even more. But that's a topic for another post.

What are your patterns to increase maintainability of your Rails applications? Do you stick with your framework, or try to escape from it? I wait for your comments!

comments powered by Disqus