Ruby, OOP, Events and the Tell Don't Ask Principle
Background
Ignoring the existence of events in a program leads to harder to understand/debug code. Events implicitly exist in all programs and if not explicitly utilized do not go away.
It is obvious that events can help you write loosely coupled class and allow multiple objects to subscribe to a single event. However, events can also help you conform to the Tell, Don’t Ask Principle.
Tell, Don’t Ask Principle
Instead of asking an object a question about it’s state, making a descision, and proceeding forward we should strive to tell an object what to do.
Procedural code gets information then makes decisions. Object-oriented code tells objects to do things.
— Alec Sharp
Let’s look at some simple examples of a Rails controller action:
Normal Rails Controller
Here is an example you are sure to recognize as it is the normal way of writing a create action for a Rails controller.
class TasksController < ApplicationController
def create
@task = Task.new(params[:task])
if @task.save
respond_to do |format|
format.html { redirect_to task_path( @task ) }
end
else
respond_to do |format|
format.html { render :new }
end
end
end
end
This implementation is not good because we are examining the state of the created task to determine what to do next. Not to mention, lots of business logic, which has nothing to do with the HTTP protocol, is happening in the controller. How are you going to test it?
Rails Controller Action With a Service Class Extracted
Now, we will extract that create logic to a service class the controller can use.
class CreateTaskService
attr_reader :task
def initialize(attributes)
@attributes = attributes
end
def call
@task = Task.create(attributes)
end
def success?
task.valid?
end
protected
attr_reader :attributes
end
class TasksController < ApplicationController
def create
create_service.call
if create_service.success?
task = create_service.task
respond_to do |format|
format.html { redirect_to task_path(task) }
end
else
respond_to do |format|
format.html { render :new }
end
end
end
protected
def create_service
@create_service ||= CreateTaskService.new(params[:task])
end
end
While we know that extracting complex logic out of the controller into another class is a good idea, this particular implementation is just plain ugly. The problem with this implementation is the controller is asking the service class about it’s state and then telling it to do additional tasks.
Rails Controller Action With a Service Class Extracted Utilizing Events
Why not let the service class tell the controller what happened?
In the next example we will use the wisper gem to allow the controller to subscribe to events the service class may publish.
class CreateTaskService
include Wisper::Publisher
def initialize(attributes)
@attributes = attributes
end
def call
task = Task.create(attributes)
if task.valid?
publish :success, task
else
publish :validation_error, task
end
end
protected
attr_reader :attributes
end
class TasksController < ApplicationController
def create
create_service.on :success do |task|
respond_to do |format|
format.html { redirect_to task_path(task) }
end
end
create_service.on :validation_error do |task|
respond_to do |format|
format.html do
@task = task
render :new
end
end
end
create_service.call
end
protected
def create_service
@create_service ||= CreateTaskService.new(params[:task])
end
end
This is a much better implementation. Without sacrificing the relatively linear flow of the code, we have obeyed the tell, don’t ask principle. In addition, we have explicitly acknowledged the presence of events, which makes the code easier to understand.
The ease of comprehension benefit may not seem like a big advantage in the prior example. However, imagine the case where you are 5 levels deep in an object graph that is employing a strategy pattern. Without the use of events to message directly to the outer most object your only option is proxy methods. Good luck tracing that quickly.
As a bonus, things get even better if we need some orthogonal piece of work to occur when the task is successfully created:
class TasksController < ApplicationController
def create
create_service.subscribe( task_email_listener,
on: :success,
with: :task_created )
create_service.on :success do |task|
# ...
end
create_service.on :validation_error do |task|
# ...
end
create_service.call
end
protected
def create_service
@create_service ||= CreateTaskService.new(params[:task])
end
def task_email_listener
TaskEmailListener.new
end
end
class TaskEmailListener
def task_created( task )
TaskMailer.assignment_email( task ).deliver
end
end
Wow, we have now accomplished sending an email without tightly coupling it to the CreateTaskService
or using ActiveRecord callbacks. This means the CreateTaskService
can be used other places in our code without sending an email. This also means when we create a Task in the Rails console, we will not accidentally send an email.