From time to time I've been getting "Kir, why did you use interfaces here?" about Ruby and Python codebases I've worked on. It's time to write a post that could serve as an extended GitHub comment with the answer "why".
The Problem with Inheritance
Let's use a real-life example of a repository object. We want to have a repository that has multiple entry points like load{_multi}_by_{id,sku}
. This object should also provide a mock version to be used in tests.
Let's start with implementing it using inheritance. I'm going to use Ruby, but the same reasoning applies to Python as well.
class BaseProductRepository
def load_by_id(id)
raise NotImplementedError
end
def load_by_sku(sku)
raise NotImplementedError
end
def load_multi_by_ids(ids)
raise NotImplementedError
end
def load_multi_by_skus(skus)
raise NotImplementedError
end
end
class ProductRepository < BaseProductRepository
def load_by_id(id)
# implementation
end
def load_by_sku(sku)
# implementation
end
# ... the rest of methods
end
class MockProductRepository < BaseProductRepository
def initialize(products)
# takes a list of products and stores them in a hash
# ...
end
def load_by_id(id)
# implementation
end
def load_by_sku(sku)
# implementation
end
# ... the rest of methods
end
This repository evolves over time, and another developer adds load_by_tenant_id_and_id
as a way to scope the loaded resource by a tenant ID to avoid security issues of accidentally loading another tenant's product.
That developer might add load_by_tenant_id_and_id
to ProductRepository
or to BaseProductRepository
depending on their taste and awareness. But then there's nothing in place to check that MockProductRepository
complies with the base class. You may only find out later when that new method gets called and NotImplementedError
is raised. Surprise!
To some degree, you get zero value from BaseProductRepository
here. You might as well just have ProductRepository
and MockProductRepository
not have any base class at all.
The Power of Interfaces
I'm going to use Sorbet for this example, but what I'm pointing out is not Sorbet specific and could be implemented with RBS types if you don't like Sorbet.
class BaseProductRepository
extend T::Sig
extend T::Helpers
interface!
sig { abstract.params(id: Integer).returns(Product) }
def load_by_id(id); end
sig { abstract.params(sku: String).returns(Product) }
def load_by_sku(sku); end
sig { abstract.params(ids: T::Array[Integer]).returns(T::Array[Product]) }
def load_multi_by_ids(ids); end
sig { abstract.params(skus: T::Array[String]).returns(T::Array[Product]) }
def load_multi_by_skus(skus); end
end
class ProductRepository
extend T::Sig
include BaseProductRepository
sig { override.params(id: Integer).returns(Product) }
def load_by_id(id)
# implementation
end
sig { override.params(sku: String).returns(Product) }
def load_by_sku(sku)
# implementation
end
# ... rest of methods
end
class MockProductRepository
extend T::Sig
include BaseProductRepository
# ...
end
Now, if you dare to forget to implement some of those methods on MockProductRepository
, the type check will not pass.
The same applies to Python via Protocol
interfaces:
from typing import Protocol, List
class BaseProductRepository(Protocol):
def load_by_id(self, id: int) -> Product:
...
def load_by_sku(self, sku: str) -> Product:
...
def load_multi_by_ids(self, ids: List[int]) -> List[Product]:
...
def load_multi_by_skus(self, skus: List[str]) -> List[Product]:
...
This leaves no way for an implementation of BaseProductRepository
to forget some of those methods, especially when the interface evolves over time.
Bonus: Organizing Interfaces in Ruby
I don't necessarily love Base
as a keyword, and the fact that it lives in the parent namespace.
I grew to like the approach that was introduced at Shopify by a team that was really into typing and happened to be an early Sorbet adopter. The approach goes like this:
module ProductRepository
interface!
sig { abstract.params(id: Integer).returns(Product) }
def load_by_id(id); end
sig { abstract.params(sku: String).returns(Product) }
def load_by_sku(sku); end
# ...
class Impl
include ProductRepository
def load_by_id(id)
# ...
end
def load_by_sku(sku)
# ...
end
end
end
# ProductRepository::Impl can be used anywhere in the codebase.
# If needed, somewhere in test/support:
class MockProductRepository
include ProductRepository
# ...
end
This approach provides the benefit of the main implementation living in the same file as the interface, without having both BaseProductRepository
and ProductRepository
in the parent namespace.