Duck Typing and Inheritance in Ruby

David Morales David Morales
/
Four ruby crystals on a dark background: three forming a hierarchical triangle, one floating apart.

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:

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
end
end

At 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:

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) }
end
end
class CreditCardPayment
def process_payment(order)
charge_card(order.total, order.currency)
end
private
def charge_card(amount, currency)
# call to the card gateway
end
end
class PayPalPayment
def process_payment(order)
send_invoice(order.total, order.buyer_email)
end
# send_invoice and other private methods for the PayPal integration
end
class BankTransferPayment
def process_payment(order)
initiate_transfer(order.total, order.iban)
end
# initiate_transfer and other private methods for the banking integration
end
class CryptoWalletPayment
def process_payment(order)
broadcast_transaction(order.total, order.wallet_address)
end
# broadcast_transaction and other private methods for the on-chain integration
end

PaymentProcessor 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 differs
if payment.is_a?(CreditCard)
payment.charge_card(amount)
elsif payment.is_a?(PayPal)
payment.send_invoice(amount)
end

is_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)
end

At 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
end
end
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"
end

Every 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
)
end
end

This 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 == ...
end

If 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 now
end
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, record
end

Refund 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, record
end

There’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
end
end
class Purchase < Transaction
# initialize is no longer needed
# validate, charge, record
end
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, record
end

Why 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"
end
end
class Purchase < Transaction
def perform
@payment_method.process_payment(self)
end
def log_type
"purchase"
end
end
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"
end
end

There 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_type

If 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)
end

Seemingly 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)
end
end
class Refund < Transaction
attr_reader :original_transaction
def post_initialize(opts)
@original_transaction = opts[:original_transaction]
end
# initialize and super are no longer needed
end

Now 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
end
end
class Refund < Transaction
def extra_validations
raise "Missing original" unless @original_transaction
raise "Refund exceeds original" if @amount > @original_transaction.amount
end
end

Refund 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:

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

  1. You see a case statement that switches on the class of its argument: when Mechanic, when Driver, etc. What does this most likely indicate?

  1. Replacing is_a?(Mechanic) with respond_to?(:prepare_bicycle) to decide which message to send is:

  1. When refactoring a concrete class into an abstract superclass plus subclasses, the recommended strategy is to:

  1. 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?

  1. What’s the main benefit of using Hook Messages instead of requiring subclasses to call super?