In Progress
Unit 1, Lesson 1
In Progress

Episode #519 – Supporting Different Service Versions with OO – Emily Stolfo

Most applications don’t exist in a vacuum. They have to interact with various external services—which may change their interfaces, or need to be replaced. How do you gracefully and elegantly support multiple versions of an external API? Join guest chef Emily Stolfo for one approach to this problem.

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