Episode #519 – Supporting Different Service Versions with OO – Emily Stolfo
Video transcript & code
Modern web applications don't exist in a vacuum. They have to interface with data stores, SaaS APIs, and internal microservices. An application's universe isn't fixed forever, either: any sufficientl long-lived application will eventually have to accomodate upgrades to external APIs. Or it may have to navigate larger transitions as one service is substituted for another. But how can we structure our code to support multiple versions of a service?
Today's guest chef Emily Stolfo has one answer to that question. Emily works at MongoDB in Berlin, where she is responsible for their Ruby project. She has degrees in both computer science and art history, is an adjunct faculty of Columbia University, and recently took a sabbatical to apprentice with a master bread baker in the South of France. In today's episode, Emily uses her experience from maintaining the Ruby MongoDB bindings to demonstrate a technique for cleanly supporting multiple API versions at once.
Let's say you are writing some code to support different versions of an external service and you won't know the version until runtime. You have to be backwards compatible and as forward-compatible as possible in your code. Your code must be as simple, legible and maintanable as possible. You also might have to add some deprecation warnings at some point.
We'll explore one design technique that can help you support different versions of a service and allow you to easily maintain the code, in particular when you want to deprecate an older version of the service.
Because I work on the MongoDB Ruby driver, I'm going to use the example of an Operation class that sends an insert message to a server. In the driver code, I don't know what version the server is until I send the operation.
Here is how I select a server to receive the write:
server = select_master_server
and then I create the Operation
object
op = Operation::Insert.new({ name: "Emily" })
then call #execute
on the Operation
object and pass in the server on which the operation should be executed.
result = op.execute(server)
I expect to get a result
object back, on which I can call methods like #success
?
if !result.success?
raise OperationFailure.new(result)
end
Now that we've seen the Operation
object used, let's take a look at the class itself and focus on the #execute
method.
module Operation
class Insert
def execute(server)
msg = { insert: @document }
response = server.send_message(msg)
Result.new(response)
end
end
end
Let's say each version of the server requires that the message containing the insert be formatted differently.
How can I handle this in my #execute
method?
Assuming we can call a method on the server asking what features it supports, we can write it pretty simply with an if-else.
module Operation
class Insert
def execute(server)
if server.supports_write_commands?
msg = { command: "insert", payload: { doc: @document }}
else
msg = { insert: @document }
end
response = server.send_message(msg)
Result.new(response)
end
end
end
op = Operation::Insert.new({ name: "Emily" })
result = op.exeucte(server)
But what if the format of the response from the server can also differ depending on the version?
Let's say the response format is a hash, and it will contain the key "ok
" in both server versions we support;
however, the value for "ok
" is 1 for the legacy server and true for the newer server.
op = Operation::Insert.new({ name: "Emily" })
result = op.exeucte(server)
# response format is { "ok" => 1 } or { "ok" => true }
Then we want to call #success
? on the result and have it parse the response so we can use it in the code we saw before:
if !result.succes?
raise OperationFailure.new(result)
end
To support this, you have to put the "or" logic also in the response accessors methods.
module Operation
class Result
def success?
@response["ok"] == 1 || @response["ok"] == true
end
end
end
So that's fine, though it's getting a little complicated. The logic branching depending on server versions is spread out between files and classes, which is a bit worrisome. And just when I was coming to terms with the mild complexity, I find out that a new version of the server is coming out that has yet a different message and response format to support streams of data. What am I going to do?
Do I add an elsif
to my Insert
class?
module Operation
class Insert
def execute(server)
if server.supports_doc_stream?
msg = [ @document ]
elsif server.supports_write_commands?
msg = { command: "insert", payload: { doc: @document }}
else
msg = { insert: @document }
end
response = server.send_message(msg)
Result.new(response)
end
end
end
And another validation in the #success
? method of the Result
object? Note that the response has a "success
" key now instead
of an "ok" key.
module Operation
class Insert
class Result
def success?
@response["ok"] == 1 || @response["ok"] == true || @response["success"] == true
end
end
end
end
result = Operation::Insert.new({ name: "Emily" }).execute(server)
# response format is { "ok" => 1 } or { "ok" => true } or { "success" => true }
if !result.success?
raise OperationFailure.new(result)
end
This is beginning to feel a bit too complex now. And surprise! A few weeks later, I'm told that I can deprecate the older version of the server we support. Let's see if I can identify all the branches of logic I can delete.
module Operation
class Insert
def execute(server)
if server.supports_doc_stream?
msg = [ @document ]
elsif server.supports_write_commands?
msg = { command: "insert", payload: { doc: @document }}
else
msg = { insert: @document }
end
response = server.send_message(msg)
Result.new(response)
end
end
end
module Operation
class Insert
class Result
def success?
@response["ok"] == 1 || @response["ok"] == true || @response["success"] == true
end
end
end
end
becomes:
module Operation
class Insert
def execute(server)
if server.supports_doc_stream?
msg = [ @document ]
else
msg = { command: "insert", payload: { doc: @document }}
end
response = server.send_message(msg)
Result.new(response)
end
end
end
module Operation
class Insert
class Result
def success?
@response["ok"] == 1 || @response["ok"] == true
end
end
end
end
Deleting this code is tricky. Wait a minute, did I delete the correct logic in the Result
class?
Does the response have the "ok
" key for the newest server version? I need to read the specs and maybe server source code,
which is in C++ (!!) to figure it out.
module Operation
class Insert
class Result
def success?
@response["ok"] == 1 || @response["ok"] == true
end
end
end
end
The newest server version actually has the "success
" key, so I made a mistake when dropping support for the legacy version.
Now when I get a response from the newest server in the form:
{ "success" => true }
,
result.success?
will return false
, when it's actually true! An OperationFailure
would be erroneously raised.
if !result.succes?
raise OperationFailure.new(result)
end
I'd obviously like to design this in a much cleaner way so that the code is more legible and so that it's easier to deprecate
older versions of the server.
I propose breaking up the different objects and instantiating them based on the server version in a more general Insert#execute
method.
I also propose creating different Result
objects, that each Insert object will know to instantiate.
I create a Legacy Operation
module Operation
class LegacyInsert
def execute(server)
msg = { insert: @document }
response = server.send_message(msg)
Result.new(response)
end
end
end
and a Legacy Result
module Operation
class LegacyInsert
class Result
def success?
@response["ok"] == 1
end
end
end
end
If we continue on this refactoring path, we would end up with a Command class as well:
module Operation
class CommandInsert
def execute(server)
msg = { command: "insert", payload: { doc: @document }}
response = server.send_message(msg)
Result.new(response)
end
end
end
and a Command Result
module Operation
class CommandInsert
class Result
def success?
@response["ok"] == true
end
end
end
end
I could choose between adding the code to support the most recent server version directly in the top-level Insert object, or creating a separate object for it. I'm going to stick with keeping the code in the top-level object.
module Operation
class Insert
def execute(server)
if server.supports_doc_stream?
msg = [ @document ]
elsif server.supports_write_commands?
CommandInsert.new(@document).execute(server)
else
LegacyInsert.new(@document).execute(server)
end
response = server.send_message(msg)
Result.new(response)
end
end
end
module Operation
class Insert
class Result
def success?
@response["success"] == true
end
end
end
end
I'm happy with these changes. It presents clearer logic for myself as well as for colleagues. Let's see what will happen now if I want to remove the code supporting the oldest version of the server.
module Operation
class Insert
def execute(server)
if server.supports_doc_stream?
LegacyInsert.new(@document).execute(server)
else
CommandInsert.new(@document).execute(server)
end
response = server.send_message(msg)
Result.new(response)
end
end
end
Well, that was easy! And I'm confident I didn't accidentally delete any necessary branches of logic.
So there must be a catch, right? Unfortunately, you end up creating many more objects than as if you just branched in your code but it's up to you if you weigh this as a stronger concern over developer efficiency, legibility, and ease of maintenance.
Responses