Interfaces for logical migrations
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 thisAccount
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:
- 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.
- 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 untilOrgnization
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 Organization
s, 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 oneOrganization
and vice-versa. - Each
Organization
points to one or multipleAccount
s.
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
- In Java, C#, TypeScript we would use
interface
, in Clojureprotocol
, in Haskelltypeclass
↩