Interfaces for logical migrations

August 2023

This post explains how you can use interfaces to make data model and database migrations easier.

Imagine you run a B2B company and Account represents how you want to bill each of your clients. Account is both a table in your database and an object in memory that has:

  • a name
  • a balance property for how much balance is owed by the account
  • an owner property representing the person to email the bills to
  • permissions representing which users have access to this Account

In Ruby1, that might looks like this:

class Account
  def name(); end
  def owner(); end
  def balance(); end
  def permissions(); end
end

But over time, requirements change:

  • The finance and marketing departments of your biggest client want their bill broken down in two so that they can own separate profit and loss statements.
  • They still want to have the same owner that receives one bill but with the subtotal.

You realize, that you want to represent things with:

  • Two Accounts, one for finance, one for marketing where you track the separate balances.
  • One Organization for the entire company which pays the final bill.

You would like to have both of these objects in your database.

Looking at the new database schema, you might think that you need to run a migration right away. And this would be bad for two reasons:

  1. Freezing existing code: There is existing code to migrate that is getting enhancements and bug fixes daily. A database migration like the one described usually freezes the codebase.
  2. Pausing new code: The Security team is trying to redo how permissions work. They agree that permissions is something that conceptually belongs at the Organization level. But they can't wait until Orgnization exists to start their work.

But if you add a level of indirection, nobody needs to wait for the database migration. You can logically migrate the codebase before you the database migration.

You start by introducing an interface for Organizations, called IOrganization. If an object implements IOrganization, it is as-if they were an Organization:

module IOrganization
  def owner(); end
  def accounts(); end
  def permissions(); end
end

Then, you create a temporary in-memory object, AccountAsOrg that makes an Account look like an IOrganization to the rest of the codebase:

class Account

  class AccountAsOrg
    def initialize(account)
      @account = account
    end

    include IOrganization
    def owner()
      @account.owner()
    end
    def accounts()
      return [@account]
    end
    def permissions()
      @account.permissions()
    end
  end
  def organization()
    return AccountAsOrg.new(self)
  end
	
  # rest of Account logic...
  def name(); end
  def balance(); end
end

All new code can target IOrganization which works for the existing AccountAsOrg and will also work for Orginization when that is ready:

(1) Existing code can migrate in place

For the existing code, you can start to logically migrate it to work with IOrganization wherever you think that is more appropriate:

# Old Billing module:
def send_bill!(account: Account)
  owner = account.owner()
  balance = account.balance()
  email_body = <<~TEXT
  Your next bill is:
    Total: #{balance}
  TEXT
  send_email!(owner.email, email_body)
end
# calling the function:
send_bill!(account)


# New Billing module:
def send_bill!(organization: IOrganization)
  owner = organization.owner()
  accounts = organization.accounts()
  subtotals = accounts.map(&:balance).join("\n")
  total_balance = accounts.map(&:balance).reduce(:+)	
  email_body = <<~TEXT
    Line items:
      #{subtotals}
    Total: #{total_balance}
  TEXT
  send_email!(owner.email, email_body)
end
# calling the function:
send_bill!(account.organization())

(2) New code can directly target the interface

Instead of working with account.permissions(), the Security team can directly target organization.permissions():

module Security
  # this module never references Account

  def check_permissions_integrity(org: IOrganization)
    permissions = org.permissions()
    # ...
  end
end

# at the very edge of the module:
check_permissions_integrity(account.organization())

Logical migration first, database migration later

Later, you can do the actual database migration and create a table with all the Organizations:

  • Each Account points to one Organization and vice-versa.
  • Each Organization points to one or multiple Accounts.

The new class Organization implements IOrganization, so you can just start passing it wherever IOrganization was expected:

class Organization
  include IOrganiation
  def owner(); end
  def accounts()
    # lookup accounts in the database and return them
  end
end

organization = Organization.new(...)

# In the Security codebase
check_permissions_integrity(organization)

# In the Billing module
send_bill!(organization)

and account.organization() returns Organization instead of the temporary AccountAsOrganiztion:

class Account
  def organization()
    # looks up the Organization and returns that
    Organization.new(...)
  end
  ...
end

Notice that the existing call-sites using account.organization() don't need to migrate:

# Billing code works:
send_bill!(account.organization())

# Security's code works:
check_permissions_integrity(account.organization())


Footnotes

  1. In Java, C#, TypeScript we would use interface, in Clojure protocol, in Haskell typeclass