Duck Typing and Inheritance in Ruby
Polymorphism is the ability of different objects to respond to the same message. The sender calls process_payment and doesn’t care who’s on the other end: a credit card, a bank transfer, or a crypto wallet. Each receiver knows how to respond in its own way.
In Ruby, there are two main ways to achieve this, and deciding which to use shapes a large part of your design:
- Duck typing, where unrelated objects share an implicit public interface.
- Classical inheritance, where a group of subclasses inherit shared behavior from an abstract superclass.
Both techniques solve different problems. Duck typing reduces dependencies between classes that share nothing except the role they play in a conversation. Inheritance, applied correctly, organizes a set of genuinely related types under a shared algorithm. Mixing them up, as we’ll see, is costly.
Let’s walk through each one by designing a payment processing system.
Duck Typing
Why Ruby favors duck typing
In languages with strong static typing, a variable carries a type tag that the compiler checks. In Ruby, that “tag” is implicit and behavior-based: if an object responds to the messages we need, it works for us.
If it walks like a duck and quacks like a duck, it’s a duck. It doesn’t matter what class it is.
This language flexibility lets us build across-class interfaces (interfaces that don’t belong to any single class but exist as agreements between different classes) and reduce dependencies on classes in favor of dependencies on messages.
The challenge is learning to see those hidden interfaces and building them with the same intentionality used when designing a class.
The antipattern: case on class
Imagine a system that processes payments of various types. An early version, written without much thought, might look like this:
class PaymentProcessor def process(payment_methods, order) payment_methods.each do |method| case method when CreditCard method.charge_card(order.total, order.currency) when PayPal method.send_invoice(order.total, order.buyer_email) when BankTransfer method.initiate_transfer(order.total, order.iban) when CryptoWallet method.broadcast_transaction(order.total, order.wallet_address) end end endendAt first glance it seems reasonable. Each payment method has its own API and PaymentProcessor orchestrates everything. The problem is how much this class knows. Count the dependencies:
- The classes
CreditCard,PayPal,BankTransfer, andCryptoWalletby name. - A different specific method for each one.
- The arguments each method requires.
- Which attributes of
orderapply to each payment type.
Every new payment method forces you to open this class and add a when branch. Every change to an external processor’s API (renaming charge_card to charge, for example) breaks PaymentProcessor. And since this pattern spreads, as soon as another part of the system needs to react to the payment type, the same case will appear repeated elsewhere.
The code is clearly asking for a type that hasn’t been named yet.
Drawing this conversation makes the problem obvious:
PaymentProcessor CreditCard PayPal BankTransfer CryptoWallet | | | | | |--- charge_card --->| | | | |--- send_invoice ---------------->| | | |--- initiate_transfer ------------------------> | | |--- broadcast_transaction ------------------------------------->|PaymentProcessor holds a different conversation with each receiver. Four arrows, four message names, four different sets of arguments. Every time a new payment method appears, you have to draw another arrow and open the case.
Finding the hidden duck
When a method starts with a case that distinguishes between classes, all arguments arrive for the same reason. That reason is what defines the duck.
What does process want from each of its arguments? It wants the payment to be executed. It doesn’t care whether it’s a card, a transfer, or crypto under the hood. It just wants each object to know how to process itself given an order.
That’s the message that defines the duck: process_payment(order). And the objects that implement it are all Payments, even though no class in the system carries that name.
class PaymentProcessor def process(payments, order) payments.each { |payment| payment.process_payment(order) } endend
class CreditCardPayment def process_payment(order) charge_card(order.total, order.currency) end
private
def charge_card(amount, currency) # call to the card gateway endend
class PayPalPayment def process_payment(order) send_invoice(order.total, order.buyer_email) end
# send_invoice and other private methods for the PayPal integrationend
class BankTransferPayment def process_payment(order) initiate_transfer(order.total, order.iban) end
# initiate_transfer and other private methods for the banking integrationend
class CryptoWalletPayment def process_payment(order) broadcast_transaction(order.total, order.wallet_address) end
# broadcast_transaction and other private methods for the on-chain integrationendPaymentProcessor no longer knows the four concrete classes. It knows it receives objects that respond to process_payment, and trusts each one to do the right thing. Adding a fifth payment method doesn’t require modifying PaymentProcessor: just create a new class that implements process_payment.
The conversation, redrawn, is radically simpler:
PaymentProcessor Payment (duck) | | |--- process_payment -->| |<------- :ok ----------|A single arrow, a single message, one abstract receiver. The four concrete receivers from the previous diagram have collapsed into a single role: Payment. The diagram reflects the code.
The Payment duck doesn’t exist as a syntactic entity in the code (there’s no Payment module or superclass), but it is a participant in the design. It’s an abstraction agreed upon by contract, not by hierarchy.
Other signs of a hidden duck
The case on class is the most visible signal, but the same antipattern appears in other forms:
# Identical to the case, only the syntax differsif payment.is_a?(CreditCard) payment.charge_card(amount)elsif payment.is_a?(PayPal) payment.send_invoice(amount)endis_a? and kind_of? (they’re synonyms) do exactly what the case does: they decide which message to send based on the class. The syntax changes, but the coupling stays the same.
A subtler variation looks like this:
if payment.respond_to?(:charge_card) payment.charge_card(amount)elsif payment.respond_to?(:send_invoice) payment.send_invoice(amount)endAt first glance it seems more flexible (no class names). But the code still expects very specific objects. What object would respond to charge_card other than CreditCard? The dependency on the concrete class is still there, just disguised.
The antipattern is the same in all three cases: the sender decides which message to send based on who the receiver is. When you see this, there’s a duck hiding behind it.
Trusting the ducks
Once you’ve named the duck and implemented the interface, the next step is to trust it. The sender shouldn’t check types again. If someone passes an object that responds to process_payment, that object is a Payment.
The temptation to add defensive checks (raise unless payment.respond_to?(:process_payment)) is the inertia of static typing bleeding into dynamic code. The more you distrust, the more rigid the system becomes and the less you benefit from the flexibility that makes Ruby worth using.
This doesn’t mean giving up on guarantees. It means moving them somewhere else: into tests.
Documenting with tests
A duck type is a virtual agreement. It has no representation in the code, so it can erode easily: someone adds a new class, forgets to implement process_payment, and the system blows up in production.
The way to enforce the contract is to test it. RSpec has shared_examples for exactly this:
RSpec.shared_examples "a payment" do it "responds to process_payment" do expect(subject).to respond_to(:process_payment) end
it "accepts an order and processes it" do order = double("Order", total: 100, currency: "EUR") expect { subject.process_payment(order) }.not_to raise_error endend
RSpec.describe CreditCardPayment do subject { CreditCardPayment.new(card_number: "...") } it_behaves_like "a payment"end
RSpec.describe PayPalPayment do subject { PayPalPayment.new(email: "...") } it_behaves_like "a payment"endEvery duck implementation runs through the same set of examples. If someone creates ApplePayPayment and forgets process_payment, the associated test fails and the contract is protected.
Tests serve two roles here: they verify behavior and document the interface. For a duck type, that dual function is essential.
Classical Inheritance
Duck typing handles the case of unrelated objects that share a role. But there’s another scenario: genuinely related objects that share a large amount of behavior and differ only in specific details. That’s what inheritance is for.
When to apply inheritance
Inheritance is a mechanism for automatic message delegation. When a subclass receives a message it doesn’t understand, Ruby forwards it up to the superclass. This lets you define common behavior once and specialize only what changes.
But inheritance only works if the objects being modeled have a generalization-specialization relationship. A subclass must be everything the superclass is, plus something more. Any code that expects the superclass must be able to work with the subclass without knowing the difference (this is the Liskov Substitution Principle).
If that relationship doesn’t exist, inheritance creates more problems than it solves.
The antipattern: types embedded in a concrete class
Suppose the system starts with a single Transaction class that represents a purchase:
class Transaction attr_reader :amount, :payment_method, :order_id
def initialize(amount:, payment_method:, order_id:) @amount = amount @payment_method = payment_method @order_id = order_id end
def execute validate charge record end
def validate raise "Invalid amount" if @amount <= 0 raise "Missing payment" unless @payment_method end
def charge @payment_method.process_payment(self) end
def record TransactionLog.create( type: "purchase", amount: @amount, order_id: @order_id ) endendThis works fine until refunds come along. A refund looks a lot like a purchase (it has an amount, a payment method, it needs to be validated and recorded) but it reverses the money flow and needs a reference to the original transaction. The temptation is to add that case to the existing class:
class Transaction attr_reader :amount, :payment_method, :order_id, :type, :original_transaction
def initialize(amount:, payment_method:, order_id:, type:, original_transaction: nil) @amount = amount @payment_method = payment_method @order_id = order_id @type = type @original_transaction = original_transaction end
def execute validate if @type == :purchase charge elsif @type == :refund reverse_charge end record end
def validate raise "Invalid amount" if @amount <= 0 raise "Missing payment" unless @payment_method if @type == :refund raise "Missing original" unless @original_transaction raise "Refund exceeds original" if @amount > @original_transaction.amount end end
# charge, reverse_charge, record with more if @type == ...endIf this looks familiar, it’s because it’s the first cousin of the case we saw in duck typing. A variable (@type) divides instances into categories, and methods change their behavior based on its value. Every new transaction type will multiply these if branches across every method.
The difference from the duck typing case is that here the types are related. All transactions follow the same flow. The antipattern doesn’t point to a hidden duck; it points to a hidden subclass.
From concrete class to hierarchy: promote, don’t demote
There are two ways to extract a hierarchy, and only one works well.
The temptation is to demote: keep Transaction as the superclass and push the purchase-specific pieces down into a new Purchase subclass. But this approach is treacherous. If you leave any concrete behavior behind in the superclass, new types will inherit it unwillingly. The superclass stops being an abstraction, and bugs surface far from where they were introduced.
The right way is to promote: start by moving all the concrete behavior downward, then bring up, piece by piece, only what is genuinely common.
Step 1: empty out Transaction and create Purchase with the original code.
class Transaction # empty for nowend
class Purchase < Transaction attr_reader :amount, :payment_method, :order_id
def initialize(amount:, payment_method:, order_id:) @amount = amount @payment_method = payment_method @order_id = order_id end
def execute validate charge record end
# validate, charge, recordendRefund is also built by inheriting from Transaction:
class Refund < Transaction attr_reader :amount, :payment_method, :order_id, :original_transaction
def initialize(amount:, payment_method:, order_id:, original_transaction:) @amount = amount @payment_method = payment_method @order_id = order_id @original_transaction = original_transaction end
def execute validate reverse_charge record end
# validate, reverse_charge, recordendThere’s duplication now, but it’s intentional. The next step is to identify the shared code and promote it to Transaction.
Step 2: move the shared attributes and common initialization up.
class Transaction attr_reader :amount, :payment_method, :order_id
def initialize(amount:, payment_method:, order_id:) @amount = amount @payment_method = payment_method @order_id = order_id endend
class Purchase < Transaction # initialize is no longer needed
# validate, charge, recordend
class Refund < Transaction attr_reader :original_transaction
def initialize(amount:, payment_method:, order_id:, original_transaction:) @original_transaction = original_transaction super(amount:, payment_method:, order_id:) end
# validate, reverse_charge, recordendWhy all this ceremony for a change that seems obvious in hindsight? Because promoting from concrete classes guarantees that nothing sneaks upward unintentionally. If you leave something behind in a subclass, as soon as a third type needs it, you’ll promote it. The “didn’t promote enough” mistake is cheap and gets caught early. The “left concrete code in the superclass” mistake is expensive and spreads debt throughout the hierarchy.
Template Method pattern: the algorithm lives in the superclass
Purchase and Refund follow the same flow: validate, execute the monetary operation, record. What changes is what gets validated, which monetary operation runs, and how it gets recorded.
This is the classic setup for the Template Method pattern: the superclass defines the algorithm and sends messages that subclasses implement.
class Transaction attr_reader :amount, :payment_method, :order_id
def initialize(amount:, payment_method:, order_id:) @amount = amount @payment_method = payment_method @order_id = order_id end
def execute validate perform record end
def validate raise "Invalid amount" if @amount <= 0 raise "Missing payment" unless @payment_method end
def perform raise NotImplementedError, "#{self.class} must implement perform" end
def record TransactionLog.create( type: log_type, amount: @amount, order_id: @order_id ) end
def log_type raise NotImplementedError, "#{self.class} must implement log_type" endend
class Purchase < Transaction def perform @payment_method.process_payment(self) end
def log_type "purchase" endend
class Refund < Transaction attr_reader :original_transaction
def initialize(amount:, payment_method:, order_id:, original_transaction:) @original_transaction = original_transaction super(amount: amount, payment_method: payment_method, order_id: order_id) end
def validate super raise "Missing original" unless @original_transaction raise "Refund exceeds original" if @amount > @original_transaction.amount end
def perform @payment_method.reverse_payment(self, @original_transaction) end
def log_type "refund" endendThere are two important details that often get overlooked.
The first: Transaction (the superclass) implements both perform and log_type, even though they just raise NotImplementedError. Every class that uses Template Method must implement all the messages it sends, even if only to raise an explicit error. If someone creates a Transfer tomorrow and forgets log_type, the stack trace will say exactly what’s missing and where:
Transfer must implement log_typeIf the superclass doesn’t implement log_type, the developer will see an opaque NoMethodError far from where the real problem is. The difference between the two experiences is small when you write it, but significant when it happens in production.
The second: Refund#validate calls super to inherit the general validations and then adds its own. This is legitimate when the subclass wants to extend validation rather than replace it. It’s one of the few places where super is genuinely justified in this pattern.
The problem with super: hidden coupling
Look again at Refund’s initialize:
def initialize(amount:, payment_method:, order_id:, original_transaction:) @original_transaction = original_transaction super(amount: amount, payment_method: payment_method, order_id: order_id)endSeemingly correct. But this line hides a trap: for a subclass to work, it must remember to call super. If someone adds Transfer tomorrow and forgets that super, the common attributes won’t be initialized, and the failure will appear somewhere remote in the system when someone tries to read @amount.
The problem isn’t just forgetting. It’s that the subclass needs to know details of the superclass’s algorithm: what order to initialize in, which arguments to pass, when super is needed and when it isn’t. Each subclass reproduces the same pattern, and each one is an opportunity to get it wrong.
This hidden coupling is eliminated by inverting the control: let the superclass ask, rather than having the subclass remember to notify.
Hook messages: inverting the control
The solution is to flip the pattern. Instead of the subclass calling the superclass, the superclass invites the subclass to contribute, via an empty method that subclasses can override:
class Transaction attr_reader :amount, :payment_method, :order_id
def initialize(amount:, payment_method:, order_id:, **opts) @amount = amount @payment_method = payment_method @order_id = order_id post_initialize(opts) end
# Empty hook that subclasses can override def post_initialize(opts) endend
class Refund < Transaction attr_reader :original_transaction
def post_initialize(opts) @original_transaction = opts[:original_transaction] end
# initialize and super are no longer neededendNow Refund doesn’t control initialization; it only contributes to it. It no longer needs to know what Transaction#initialize does, in what order, or remember to call super. The superclass owns the control and the subclass just fills in a slot.
The same pattern applies to validation. Instead of forcing the subclass to call super, the superclass offers a hook:
class Transaction def validate raise "Invalid amount" if @amount <= 0 raise "Missing payment" unless @payment_method extra_validations end
def extra_validations # hook endend
class Refund < Transaction def extra_validations raise "Missing original" unless @original_transaction raise "Refund exceeds original" if @amount > @original_transaction.amount endendRefund no longer needs to know what the superclass validates. It just adds its own part. If the order or logic of the general validations changes tomorrow, Refund won’t notice and won’t break.
The underlying rule is this: knowledge of the algorithm lives in the superclass. Subclasses only know their own specializations. Any time a subclass has to call super, that’s a sign that knowledge has leaked downward.
When inheritance is the wrong choice
Even though hooks and template methods make inheritance much safer, it’s still an expensive tool: cheap to write, costly to undo. Once three or four classes depend on a superclass, changing the hierarchy means touching all of them at once. Hierarchies are rigid: each class has a single parent, and any change to the superclass propagates to all subclasses without asking permission.
Before creating a hierarchy, it’s worth asking:
- Is there a genuine “is-a” relationship? A
Refundis aTransaction. But anEmailNotificationis not aUser, even if both have anemail. Reusing code by sharing an attribute is not a good enough reason to inherit. - Are the subclasses substitutable? If at any point in the code you have to check whether a
Transactionis aRefundto avoid calling certain methods, the hierarchy is structured incorrectly. - Do I have at least two or three concrete cases? Designing an abstraction from a single case is guesswork. With two there are signals. With three, the abstraction is usually mature. If you only have one case, or even two, wait.
If any of these answers is no, what you’re looking for probably isn’t inheritance.
When to use each technique
The two forms of polymorphism don’t compete; they cover different situations.
Duck typing works when several objects share a role in a conversation but share nothing structural. The payment processors in the first example are different on the inside (each one talks to a different external service) and only resemble each other in the one thing PaymentProcessor cares about: they know how to process themselves. It wouldn’t make sense to force them to inherit from a common class.
Inheritance works when objects are genuinely related and follow the same algorithm with bounded variations. Purchase, Refund, and Transfer aren’t interchangeable just in a single message: they share the entire flow, and each one specializes specific steps of the same process.
A useful question to help decide: what changes and what stays the same? If what stays the same is a single message and everything else is completely different, it’s duck typing. If what stays the same is an entire algorithm with slots to vary, it’s inheritance.
Conclusion
SOLID principles describe the what; duck typing and inheritance are the how. These two techniques are the concrete mechanics through which Ruby makes abstract principles tangible.
The Liskov Substitution Principle says a subclass must be able to replace its superclass without the client code noticing. In a hierarchy built with Template Method and hooks, that substitution is possible: PaymentProcessor can receive any Transaction (Purchase, Refund, Transfer) and the consuming code doesn’t change. The superclass defines the contract, and subclasses honor it by construction. The same holds for duck typing: any object that responds to process_payment is substitutable for any other object that does too, with no class hierarchy between them.
The Open/Closed Principle says code should be open for extension and closed for modification. Both techniques enable this directly. Adding a new payment method doesn’t touch PaymentProcessor: it just requires creating a new class with the right interface. Adding a new transaction type doesn’t touch Transaction: it just requires a subclass that fills in the hooks. Extensibility is built into the mechanics of polymorphism.
What both techniques have in common (and what makes polymorphism such a powerful tool) is that both shift the decision of what to do from the sender to the receiver. The sender sends a message and trusts. The receiver knows how to respond. Every time a piece of code stops checking types and starts trusting messages, it gains flexibility: new types can appear without touching the sender, and internal changes to the receiver don’t propagate outward.
The hard part isn’t writing the code. It’s seeing where it applies. The most reliable signal is still the same: a case that switches on type, an is_a? that controls the flow, a @type variable that triggers ifs throughout the class. Each of these patterns is a sign pointing toward a duck or a hierarchy.
Test your knowledge
-
You see a
casestatement that switches on the class of its argument:when Mechanic,when Driver, etc. What does this most likely indicate?
-
Replacing
is_a?(Mechanic)withrespond_to?(:prepare_bicycle)to decide which message to send is:
-
When refactoring a concrete class into an abstract superclass plus subclasses, the recommended strategy is to:
-
In a Template Method pattern, if a subclass forgets to implement one of the methods the superclass relies on, what’s the most helpful failure mode?
-
What’s the main benefit of using Hook Messages instead of requiring subclasses to call
super?