Lesson 3 of 83 intermediate

OOP in Kotlin: Interfaces, Abstract Classes & Composition

Design patterns and OOP principles that define senior Android architecture

Open interactive version (quiz + challenge)

Real-world analogy

An interface is a job description — it lists what you must do, not how. An abstract class is a half-built house — some rooms are complete, others are just empty frames you must finish. Composition is hiring specialists instead of one person who does everything.

What is it?

OOP in Kotlin centers around composition over inheritance, explicit openness (classes are final by default), and powerful interface capabilities including default methods and delegation. Senior Android interviews test whether you know when to use abstract classes vs interfaces, how to design for testability through dependency injection, and how to apply SOLID principles in Android architecture.

Real-world relevance

In a fintech claims processing app, you'd define a ClaimProcessor interface with validate() and process() methods. A BaseClaimProcessor abstract class provides shared audit logging and error handling. Specific processors (AutoClaimProcessor, ManualClaimProcessor) compose specialized services (FraudDetector, DocumentValidator) rather than inheriting them. The 'by' delegation creates a LoggingClaimRepository that wraps the real repository without subclassing it.

Key points

Code example

// Interface with default methods
interface ClaimProcessor {
    fun validate(claim: Claim): ValidationResult
    fun process(claim: Claim): ProcessingResult

    // Default method — shared across all implementations
    fun canProcess(claim: Claim): Boolean = validate(claim).isValid
}

// Abstract class with shared implementation
abstract class BaseClaimProcessor(
    private val auditLogger: AuditLogger
) : ClaimProcessor {

    override fun process(claim: Claim): ProcessingResult {
        auditLogger.log("Processing claim: ${claim.id}")
        return doProcess(claim).also { result ->
            auditLogger.log("Claim ${claim.id} result: ${result.status}")
        }
    }

    // Template method pattern — subclasses fill in the logic
    protected abstract fun doProcess(claim: Claim): ProcessingResult
}

// Composition — holds specialists, doesn't inherit them
class AutoClaimProcessor(
    auditLogger: AuditLogger,
    private val fraudDetector: FraudDetector,       // composed
    private val documentValidator: DocumentValidator  // composed
) : BaseClaimProcessor(auditLogger) {

    override fun validate(claim: Claim): ValidationResult {
        val fraudCheck = fraudDetector.check(claim)
        val docCheck = documentValidator.validate(claim.documents)
        return ValidationResult(fraudCheck.passed && docCheck.passed)
    }

    override fun doProcess(claim: Claim): ProcessingResult =
        ProcessingResult(status = Status.AUTO_APPROVED, claimId = claim.id)
}

// Interface delegation with 'by'
class LoggingClaimRepository(
    private val delegate: ClaimRepository,
    private val logger: Logger
) : ClaimRepository by delegate {

    override fun save(claim: Claim): Claim {
        logger.d("Saving claim ${claim.id}")
        return delegate.save(claim)  // intercept only save()
    }
}

Line-by-line walkthrough

  1. 1. interface ClaimProcessor with default method canProcess() — implementors get canProcess() for free; must implement validate() and process()
  2. 2. abstract class BaseClaimProcessor(private val auditLogger: AuditLogger) — constructor injection of a dependency; auditLogger is shared across all subclasses
  3. 3. override fun process() calls auditLogger before and after — template method pattern; subclasses override doProcess() not process()
  4. 4. protected abstract fun doProcess() — forces subclasses to provide the core logic; protected means subclass-visible only
  5. 5. class AutoClaimProcessor(...) : BaseClaimProcessor(auditLogger) — passes auditLogger up to parent via constructor delegation
  6. 6. private val fraudDetector: FraudDetector — COMPOSITION: AutoClaimProcessor HAS a FraudDetector, doesn't extend it
  7. 7. class LoggingClaimRepository(...) : ClaimRepository by delegate — compiler generates all ClaimRepository methods forwarding to delegate
  8. 8. override fun save() — only save() is intercepted; all other ClaimRepository methods pass through to delegate automatically

Spot the bug

interface Syncable {
    fun sync(): Boolean
    fun lastSyncTime(): Long = System.currentTimeMillis()
}

class DataRepository : Syncable {
    fun sync() = true  // missing override keyword
}

abstract class BaseViewModel {
    abstract fun loadData()
    fun refresh() = loadData()
}

class UserViewModel : BaseViewModel() {
    fun loadData() {  // missing override keyword
        fetchUsers()
    }
}
Need a hint?
Kotlin requires explicit override keyword — missing it causes compile errors, not silent shadowing
Show answer
Bug 1: DataRepository.sync() is missing 'override' — Kotlin requires explicit override for interface/abstract implementations. Without it, this is a compile error (or creates a new function that doesn't implement the interface). Fix: override fun sync() = true. Bug 2: Same issue in UserViewModel — loadData() must be 'override fun loadData()'. Without override, it doesn't satisfy the abstract contract and the class cannot be instantiated.

Explain like I'm 5

An interface is like a restaurant menu — it says 'we serve pizza and pasta' but doesn't tell the kitchen how to make them. An abstract class is like a recipe that has some steps filled in and some blank. Composition is like a chef who calls a pizza expert AND a pasta expert, instead of learning everything themselves. That's how modern Android apps are built!

Fun fact

Kotlin's 'classes are final by default' design was intentional — it implements Joshua Bloch's 'Item 19: Design and document for inheritance or else prohibit it' from Effective Java. Google's Android team has cited this as one of the most impactful Kotlin features for reducing fragile inheritance bugs in large codebases.

Hands-on challenge

Design a notification system for a SaaS collaboration app. Define a NotificationChannel interface with send(notification: Notification): Result and isAvailable(): Boolean. Create an abstract BaseNotificationChannel that handles retry logic (3 attempts with exponential backoff). Implement PushChannel and EmailChannel using composition (inject PushService and EmailService respectively). Create a MultiChannelNotifier that uses a List and tries each available channel.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery