Skip to Content
Course content

15: Abstract Classes vs Traits

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR from a developer who was building a complex reporting engine. He had created a deep hierarchy of abstract classes: Report, then FinancialReport, then TaxReport. It looked clean until he realized he needed a Schedulable behavior. He tried to make Schedulable an abstract class too, but since TaxReport already extended FinancialReport, he hit the wall. He couldn't inherit from both. He spent an entire afternoon trying to "force" the inheritance tree to work, moving methods up and down the chain, only to realize he was fighting the language itself.

That's the classic "inheritance trap." In Scala, we have two primary ways to define incomplete blueprints: abstract classes and traits. While they look almost identical at a glance—both can have abstract methods and concrete implementations—they serve completely different architectural purposes.

The Rigidity of Abstract Classes

Think of an abstract class as the "identity" of an object. When you use an abstract class, you're saying, "This thing is a version of that thing." The biggest constraint here is that a class can only extend one abstract class. This is your primary lineage.

One major advantage of abstract classes over traits is that they can take constructor parameters. If your base blueprint needs to initialize some state that every single child must possess, an abstract class is your best bet.

abstract class BaseService(val serviceName: String) {
  def performAction(): Unit // Abstract method
  
  def logStatus(): Unit = {
    println(s"Service $serviceName is currently operating.")
  }
}

class EmailService extends BaseService("EmailDispatcher") {
  override def performAction(): Unit = println("Sending emails...")
}

Composition via Mix-in Traits

Traits are where Scala really shines. Instead of defining what an object is, a trait defines what an object can do or what characteristic it possesses. The magic here is that a class can mix in as many traits as you want.

I usually tell my mentees to think of traits as "plugins." You aren't changing the core identity of the class; you're just adding capabilities. If you need your EmailService from the example above to also be Schedulable and Auditable, you don't change the inheritance tree—you just mix them in.

trait Schedulable {
  def schedule(cronExpression: String): Unit = {
    println(s"Scheduling task with $cronExpression")
  }
}

trait Auditable {
  def logAudit(): Unit
}

class EmailService extends BaseService("EmailDispatcher") 
  with Schedulable 
  with Auditable {
    
  override def performAction(): Unit = println("Sending emails...")
  override def logAudit(): Unit = println("Logging email activity to database...")
}

Choosing Between the Two

You'll often find yourself wondering which one to pick. If you're feeling stuck, ask yourself: "Am I defining a core identity, or a reusable behavior?"

Use an abstract class when:

  • You need constructor parameters to initialize the base state.
  • You are building a base class that is meant to be the "root" of a specific family of objects.
  • You want to avoid the slight overhead of trait linearization (though this is rarely a concern in modern Scala).

Use a trait when:

  • You want to share a piece of logic across totally unrelated classes (e.g., both a User and a Document might be Searchable).
  • You need to support multiple inheritance (mix-ins).
  • You are defining a contract (similar to an Interface in Java) that other classes should implement.

I've seen too many projects become a nightmare because they relied solely on deep abstract class hierarchies. Keep your abstract classes shallow and use traits to compose the actual functionality. It makes your code far more flexible when the business requirements inevitably change next month.




📋 Practical Task

Implementing a Modular Payment System

You are tasked with building a payment processing module. Different payment methods have different requirements, and some share specific capabilities.

Requirements:

  1. Create an abstract class PaymentMethod that takes a currency: String as a constructor parameter. It should define an abstract method processPayment(amount: Double): Unit.
  2. Create a trait Refundable with a method issueRefund(amount: Double): Unit. This method should print a message saying a refund was issued.
  3. Create a trait Taxable with an abstract method calculateTax(amount: Double): Double.
  4. Implement a class CreditCardPayment that extends PaymentMethod and mixes in both Refundable and Taxable.
    • Implement processPayment to print "Processing credit card payment".
    • Implement calculateTax to return 15% of the amount.
  5. Implement a class CryptoPayment that extends PaymentMethod. It should NOT be refundable or taxable.
    • Implement processPayment to print "Processing cryptocurrency payment".

Goal: Demonstrate that you can maintain a common identity (PaymentMethod) while selectively applying behaviors (Refundable, Taxable) based on the specific needs of the payment type.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.