snax

making a web app rewrite work

One of my friends was recently involved in a very large Rails rewrite project that failed pretty abjectly. Meanwhile, at CNET, we just deployed a total overhaul of a crufty old Rails app, replacing it with a new, pleasant, better-designed app. And Chad Fowler has been writing about big rewrites for a while. It must be in the water.

move move move

It is critical, during a rewrite, to get new code into production as fast as possible. Plan for this. Immediate, visible results are more persuasive and motivating then any projected maintenance benefit or performance increase. And deploying code will expose bad design decisions right away, rather than months down the road.

But this means we have to suffer some pain up front to keep the project flying, because the new application will have to work in parallel with the old one for quite some time. Our new app will have to maintain data compatibility with the old. We will have to connect to the same database as the old…and use the same schema.

noooo, our fancy new domain model, noooo

RDBM systems are separate from applications for a reason. We need to use this separation to our advantage. Instead of trying to maintain API compatibility while fragmenting the data store, we can use the database as the interoperability layer, and focus on deploying new features. Old site written in PHP? Doesn’t matter. The database still talks only SQL.

Using the old schema will not break our fancy new domain model. It is relatively straightforward, if tedious, to graft a new domain model onto a partially orthogonal legacy schema.

models, unite!

Say we run EBay (hey, we’re rich!). But money != developer_happiness, and our old domain model, from 1896, has buyers and sellers. We just want users now. How do we treat buyers and sellers as a single model? They might even live in separate databases!

How about:

class Buyer < ActiveRecord::Base
  set_table_name "tbuytslq"
  alias_attribute :name, :buyer_name
end

class Seller < ActiveRecord::Base
  # MySQL can jump across databases, see below
  set_table_name "salsdb.seljhnlp"
  alias_attribute :name, :nm
end

What’s going on? Besides some legacy table configuration, we have to first encapsulate the old domain so that we can merge it into the new one. The alias calls make the legacy models expose a unified API. We can then work with them in our new model without pain. (If the tables contained the same information, but organized it differently, e.g., separate first_name and last_name fields, some helper methods would take care of the conversion in and out.)

pure magic?

Now for something cool:

class User # no inheritance

  def self.method_missing *args
    sources = [Buyer, Seller]
    if args.first.to_s =~ /^(find|create|new)/
      begin
        self.new(sources.unshift.send *args)
      rescue
        retry unless sources.empty?
        raise
      end
    else
      raise "Not supported: #{args.first}"
    end
  end

  attr_accessor :proxy

  def initialize(_proxy)
     @proxy = _proxy
  end

  def method_missing *args
    proxy.send(*args)
  end

  # new business logic here

end

We can delegate the ActiveRecord operations of our new model to multiple old models, and keep the new business logic unified. Crazyness! (We might want to add some id munging, so that for example find("b10") returns a Buyer-User, and find("s10") returns a Seller-User.)

If we need to add fields to User which are not present in both Buyer and Seller, we will have to give it a table (probably in yet another database) and descend from ActiveRecord. The proxy trick will need updating:

class User < ActiveRecord::Base

  belongs_to :buyer
  belongs_to :seller

  def method_missing *args
    (buyer or seller).send(*args)
  end

  # new business logic here

end

Some method chaining and other hackery will be required to make find() work when we haven’t yet build the aggregate instance for the legacy instance we are interested in, but that will depend on specifics of the schemas.

Regarding those associated legacy instances, MySQL can make table joins and generally act sane across disparate databases, as long as they run in the same server. I don’t know about Postgres. Oracle can make table joins across disparate servers (damn; something good in Oracle), but might require some fudging to get working. If it’s impossible to get the right joins together to make an :include work in our new aggregate model, we will have to rely on aggressive caching to handle performance issues.

Of course, you can go the other way, too, and split an overly complicated model into multiple simpler ones. Just set the table name and use undef_method to mask off irrelevant fields.

back to work

Now we can set up our Apache routes to forward the appropriate requests to the new app, copy over our CSS, and start rolling out new features while leaving the old app running and in place. When we have completely replaced some functionality in the old app, we can flip a switch in Apache and let the new app take over.

And if we need to use business logic in the old app from the new app, we can make POST requests to our (old) self. I’m not even kidding. This is what the web is for.

Of course a real-life scenario will be much more complicated than this, and the code above is somewhere between pseudo-code and something we can deploy. But we are on our way.

Will some annoying changes have to be made to both apps for interoperability purposes? Probably. Will it be slower than a pristine, solo deployment? Definitely. But it will work, and it will work now, and someday when the old application is completely replaced, the database can be migrated and the legacy schema interactions can be dropped. The users won’t notice a thing—they’ve been using the rewrite code since forever.

Baby steps; always baby steps.

update

There are some more obscure features in Rails that may help you with your legacy mapping. Check the composed_of field aggregator as well as delegate.