Why I Often Choose Interfaces Over Inheritance

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.

Written in July 2025.
Kir Shatrov

Kir Shatrov helps businesses to grow by scaling the infrastructure. He writes about software, scalability and the ecosystem. Follow him on Twitter to get the latest updates.