Skip to content

Instantly share code, notes, and snippets.

@rlivsey
Created July 16, 2012 21:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save rlivsey/3125054 to your computer and use it in GitHub Desktop.
Save rlivsey/3125054 to your computer and use it in GitHub Desktop.
Separation of concerns sketch
# when a task is saved we want to:
# * store in database
# * index in elasticsearch
# * notify resque to queue up sending emails
# * etc...
#
# when a task is completed by someone we want to:
# * update in the database
# * notify resque to send email to other assignees
#
# How to wire all this stuff up so the PORO's don't have to know
# about the database etc...
## Rails action
# I'm fairly happy with the controller
class TasksController < ApplicationController
# eg POST /tasks/123/complete
# or PUT / tasks/123/assignees/456 with {status: "completed"}
# etc...
def complete
# add responder as listener, or could subscribe etc...
# task could be the actual task, or pass through the ID
TaskCompleter.new(TaskCompletedResponder.new(self)).complete_task(task, person)
end
class TaskCompletedResponder < SimpleDelegator
def succeeded(task)
# render success JSON etc...
end
def denied(task)
# render permission error
end
def failed(task)
# render failure JSON etc...
end
end
end
## TaskCompleter
# this starts off ok, but I'm not 100% happy that it knows about indexing & persistence & emailing
# but if this doesn't know about these things, who does?
# can we inject such concepts into the completer without complicating the controller?
# IE how to hookup ports/adaptors at this level?
class TaskCompleter
def initialize(listener)
@listener = listener
end
def complete_task(task, person)
# if task_id instead of passing in the actual task
# unless TaskRepository.find(task_id)
# @listener.not_found # or raise exception, interface starting to get wide?
# return
# end
# permissions stuff here too maybe?
unless TaskPermissions.new(task).completable_by?(person)
@listener.denied(task)
return
end
# update the PORO, if good then trigger off persistence stuff?
if task.complete_by(person)
# do these belong here? where do persistence / services live?
# they could be registered as listeners / subscribers but it doesn't feel like
# it should be the controller/users job to wire these up
TaskRepository.task_completed(task, person)
# maybe these can be listeners in the controller?
# but again, should the user of this class know about wiring up search indexing?
Resque.enqueue(TaskCompletedEmailJob, task.id, person.id)
TaskSearch.index_task(task)
@listener.succeeded(task)
else
@listener.failed(task)
end
end
end
## PORO's
class Person
attr_accessor :name
attr_accessor :email
end
class Assignee
attr_accessor :person
attr_accessor :completed_at
def completed?
!! self.completed_at
end
end
class Task
attr_accessor :description
attr_accessor :assignees
attr_reader :completed_at
def initialize(description, assignees)
@description = description
@assignees = assignees
end
def completed?
self.completed_at? || @assignees.all?{|a| a.completed? }
end
# obviously this can be refined
# return true/false on success?
# raise exceptions for AlreadyCompleted or NotAssignee etc..?
# but general idea is that it updates the in memory representation of the task
# and knows not a jot about persistence etc...
def complete_by(person, time=Time.now)
return if completed?
if assignee = @assignees.detect{|a| a.person == person }
return if assignee.completed?
assignee.completed_at = time
# if this were ActiveRecord, we'd probably persist now
# assignee.save
end
if completed?
self.completed_at = time
# again if this were AR we could save
# self.save
# we could have after_save callbacks to index in ElasticSearch
# and trigger Resque job to send email, but it's not this classes responsibility
end
true
end
end
## Persistence / Services
# these are illustrative more than anything
# general concept of persistence, searching, email/queuing all handled by their own units of code
# DAO/DataMapper style pattern?
class TaskRepository
include Repository::Mongo
collection_name 'tasks'
# ...
def self.find(id)
data = collection.find_one( ... )
# assemble the task, use Virtus to clean this up etc...
Task.new(data["description"], data["assignees"].map{|a_data| Assignee.new(a_data) })
end
def self.task_completed(task, assignee, time)
collection.update({_id: task.id, 'assignees._id' => assignee.id},
{:$set => {"assignees.$.completed_at": time}})
end
# ...
end
class TaskSearch
include Search
index_name 'tasks'
# ...
def self.index_task(task)
index.store(task.id, json_for(task))
end
# ...
end
class TaskCompletedEmailJob
def self.perform(task_id, person_id)
# send the email to everyone except the person who marked as complete
task = TaskRepository.find(task_id)
task.assignees.each do |assignee|
next if assignee.person_id == person_id
TaskMailer.new(task, assignee).deliver
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment