MVP Towards SOA
From MVP to SOA, a journey in scaling
I’m going to look at one sliver of functionality and how it can be scaled to cope with greater and greater demand starting at the MVP stage, to viable startup and ending in profitability and webscale.
The use case I will look at is sending email when a bid is placed in a fictious auction website.
Entity
class Bid < ActiceRecord::Base
belongs_to :buyer, class_name: Customer
def self.using_form(form)
new(form.attributes)
end
end
Our model has no validations. This is so we don’t restrict ourself with regards to what is considered a valid entiry. It is pretty much a simple data object.
Form
class Bids::Create::Form
include ActiveModel::Model
include Virtus
attribute :buyer_id, Integer
attribute :auction_id, Integer
attribute :maximum_bid, Money
validates :buyer_id, presence: true
validates :auction_id, presence: true
validates :maximum_bid, presence: true
end
We create a “form” which to capture, validate and sanatize user input which will be used to create a new Bid entity.
Service object
class Bids::Create
include Wisper::Publisher
def call(form)
if form.valid?
bid = Bids::Bid.using_form(form)
bid.save!
broadcast(:create_bid_successful, bid.id)
else
broadcast(:create_bid_failed, bid.id)
end
end
end
The service will take the form and use it to create a new entity. It will broadcast an event to signal the outcome.
Controller
class Bids::CreateController < ApplicationController
def new
@form = new_form
end
def create
@form = new_form
create_bid = Bids::Create.new(@form)
create_bid.on(:create_bid_successful) { |bid_id| redirect_to bid_path(bid_id) }
create_bid.on(:create_bid_failed) { |bid_id| render action: :new }
create_bid.call
end
private
def new_form
Bids::Create::Form.new(params[:form])
end
end
This is the context which bring each part together. I’ve not included the view the user sees but you can imagine the use of form_for
to show a HTML form which the user can interact with.
We have nice seperation of concerns and it wasn’t too much work.
Now we want to send an email to the seller to notify them of the new bid using ActionMailer.
We could put the mailing code directly in the service object. But what, if in the future, we want to create bid without sending an email.
I see email as a UI and not part of the core business logic, its on the outside of the Hexagon. So instead, in the controller, we can subscribe a listener to our service object which will react to the create_bid_successsful
event:
create_bid.subscribe(Orders::Notifier.new, prefix: 'on')
Listener
class Orders::Notifier
def on_create_bid_successful(bid_id)
bid = Bid.find(bid_id)
Mailer.bid_created(bid).deliver
end
end
and Mailer
class Orders::Notifier::Mailer < ActionMailer
# the usual...
end
PART 2
Now the seller will be notified when a bid is placed on their auction.
This is great and we can deploy this feature to staging for testing. It gets merged in to production and deployed. Time passes and we do the usual stuff to cope with demand; cache as much as possible, add web servers, add database slaves, increase the VM size etc. But the bottle neck is in the Rails app itself.
The first thing we can do is put any work which does not need to happen within the web request on to a background queue.
The first easy win is to send the email asyncrounsly, this gives us two wins: 1.) placing a bid is more responsive 2.) we can use a background queue which can live on another server and have its own resources which do not impact on the web server. The queue can also be scaled independantly of the web app.
To do this we only need to make a few changes:
If your using Rails 4 you can use Activejob. If you are using anything else then Sidekiq and Cellulioid are avalible.
Firstly we add wisper-activejob
to our Gemfile. This allows us to add the sync: true
option when subscribing listeners to have the events handled async. The controller action remains the same expect we add sync: true
when subscribing the Notifier
:
create_bid.subscribe(Orders::Notifier.new, prefix: 'on', async: true)
Excellent, a minor code change and we have async mail sending. Note that thanks to ActiveJob and ActionMailer new async features we could have done this a different way, but note this will work for pre-Rails 4 applications as well. Its important not to focus too much on the actual example of sending email but the idea behind it. It could equally be recording of statistics or creating an audit trail which happens in a listener.
3 years later…
PART 3
Our startup is doing well we have millions of bids per day. The server which runs sidekiq is struggling despite being on dedicated hardware. There are just too many background jobs to be processed. We need a way to scale different types of jobs independantly.
Here we can turn to a SOA archecture. Let imagine the heaviest burden on Sidekiq is sending of emails for bids, if we could break that off we could accomodate more traffic.
First of all we need to deploy RouteMaster, a REST based Event Bus. Our Rails app needs to be able to push events to Routemaster and a seperate service needs to subscribe to those events. The Rails app then provides an end point from which the service can fetch the entity relating to the event.
Rails app -publish event-> RouteMaster -push event-> Subscribed Service <-fetch entity- Rails app
gem 'routemaster-client'
Instead of subscribing the notifier we are going to subscribe a generic listener which will simply push the event on to RouteMaster.
class RouteMasterListener
def on_create_bid_successful(bid_id)
client.created(:bid, "https://my-webapp.com/bids/#{bid_id}")
end
private
def client
end
end
Using the RouteMaster client we publish an event to RouteMaster which will route the event to the subscribed listener which are running in different processes.
So now we need a listening service which will handle the event. It needs a HTTP endpoint for receive the events. For this we could use Grape, Lotus, Sinatra or Rails.
In the apps initalizer we need to subscribe to the endpoint for created bids.
client.subscribe...
And now for the endpoint which will receive new events. Here we have an important choice, we could include an activerecord Bid model which queries the same database as the Rails app or we could expose a JSON endpoint on the Rails app which the service can query. If we share the database this will create a coupling. Changes to the model will need to be sycronised.
Events endpoint
class BidsController < ApplicationController
def create
bid_id = params[:...] <- get from JSON event
# use faraday or hyperclient to fetch bid from Rails app
# and send the email.
end
end
A better option is to use routemaster-drain
which provides us with a rack mountable endpoint for routemaster events to which we can subscribe listeners.
require 'routemaster/drain/basic'
$app = Routemaster::Drain::Basic.new
$app.on(:events_received) do |batch|
batch.each do |event|
if event.type == 'create'
# event created...
json = Faraday.fetch(event.url)
bid = Bid.new(json)
Mailer....
end
end
end
Because we only care about one event and reacting to it in one way I am using $app.on
to register a block for the events_received
event.
You can also use $app.subscribe
to subscribe objects.
In routes.rb we mount the endpoint
#...
routemaster-drain uses Wisper to allow us to subscribe listneners which will receive batches of events.
$app.subscribe(EventHandler.new, prefix: 'on')
Events handler
class EventHandler
def on_events_received(batch)
batch.each do |event|
end
end
end
However now we don’t have access to the Bid
model or the database (we wouldn’t want to couple to the database anyway). Therefore we need to query the web app via a RESTful HTTP request.
class Bid
def self.find(bid_id)
# do REST call to Rails app...
end
def self.url_for(bid_id)
"http://bidsapp.com/bids/#{bid_id}"
end
end
The beauty of duck typing, our listener does not change.
If you needed to fetch Bids from other web services you would proberbly want to split Bid
off in to its own gem, bids-client
.
What is important is we use hypermedia, i.e. we refer to entities by a URL. This allows us to include links to other resources without the client needing to know where they reside before hand.
The mailer class is moved from to the original Rails app in to our “bids web service” since this is where we will send our email from.
Now back to our original Rails app we need to add an endpoint which will return the bid as JSON.
class BidsController
def show
bid = Bid.find(bid_id)
# use some gem which can create a json response.
end
end
This might return something like:
_links:
buyer { url: https://webapp.com/users/3 }
Now with this setup we can start to scale. RouteMaster provides some really nice tools.
What we might end up doing is breaking off the entire “bids” concept in to the new service, so the Rails app talks to the service instead of the database. So in essence everything under the Bids
namespace can be serviceized, this is another great reason for namespacing in this way. In DDD this might be what you would consider a bounded content. We end up with bids-server
and bids-client
. This would move us closer to Fine gained, RESTful Hypermedia.
As you can see the main point is we can do this in stages and make gains along the way, there is no big rewrite. But a gradual move towards a SOA archecture.
I am only really touching the surface of SOA here, but this gives us a path towards it. You shouldn’t tart with SOA but if success happens it helps to have a path without too much resistance.