What is Composition in Object Oriented Programming?

What is Composition in Object Oriented Programming?
Photo by Xavier von Erlach / Unsplash
🎓
I am working in Kotlin for the first time. I'm using Kotlin examples in this post to get used to the syntax. See a mistake? I'm still learning! You can treat the examples here as pseudo-code. Happy to hear about better ways of doing things.

Composition is a strategy in object oriented programming where you build complex entities out of smaller, less complex entities.

For example, lets say we are building a service to track geolocated plant identifications. You might have a PlantObservation class:

data class PlantObservation(
    val genus: String,
    val species: String,
    val latitude: Double,
    val longitude: Double,
    val commonName: String? = null,
    val heightInCm: Double? = null,
    val isPerennial: Boolean = true
) { }

With this class we can record an observation and add a latitude and logitude to the observation so that we can map our observations.

But there is a challenge with this. Now PlantObservation needs to know how to handle latitude and longitude. For example, what if we wanted to know if an observation is inside specific region on a map, each PlantObservation would need to know how to make spatial comparisons and we might end up with code that looks like this:

data class PlantObservation(
    val genus: String,
    val species: String,
    val latitude: Double,
    val longitude: Double,
    val commonName: String? = null,
    val heightInCm: Double? = null,
    val isPerennial: Boolean = true
) {

    fun isInsidePolygon(polygon: List<Location>): Boolean {
        // do calculations to decide if `latitude` and 
        // `longitude` are inside the polygon
    }
}

The code above might look a bit contrived, but it is fairly common to come across code that evolves this way. Why does PlantObservation know about polygon's data structure? It really doesn't need to, but someone at one point thought this was the right way to do it.

So let's refactor this so that PlantObservation uses composition to rely on a Location object to handle location information:

data class Location(
    val latitude: Double,
    val longitude: Double,
    val name: String? = null
) {
    fun isInsidePolygon(polygon: List<Location>): Boolean {
        // do calculations to decide if `latitude` and 
        // `longitude` are inside the polygon
    }
}

data class PlantObservation(
    val genus: String,
    val species: String,
    val location: Location
    val commonName: String? = null,
    val heightInCm: Double? = null,
    val isPerennial: Boolean = true
) {
    // Delegate isInsidePolygon to the location object
    fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)
}

Now our PlantObservation is composed of its own data and a Location.

Let's take this a step further and add additional data describing the plant:

data class PlantDescription(
    val heightInCm: Double,
    val leafShape: String,
    val flowerColor: String? = null
    val isPerennial: Boolean = true,
    val genus: String,
    val species: String,
    val commonName: String? = null,
) {
    fun isWithinHeight(min: Double, max: Double): Boolean {
       return min < heightInCm && max > heightInCm
    }
}

data class PlantObservation(
    val location: Location,
    val description: PlantDescription,
) {
    // Delegate isInsidePolygon to the location object
    fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)

    // Delegate isWithinHeight to the description object
    fun isWithinHeight(min: Double, max: Double): Boolean = description.isWithinHeight(min, max)
}

Now our PlantObservation is composed of a location and a description and each component is responsible for its own logic. You might notice that PlantDescription has a mixed responsibility too. For example, mapping genus and species onto existing names could be encapsulated into a ScientificName class that understands the relationship between different genus and species.

Composition is a practice that allows developers to encapsulate behaviours in component parts. This prevents the parent class from growing in complexity and keeps discrete logic individual classes.

Testing and Composition

Composition is handy when you need to mock data in a test. Lets look at isPerennial, which we had on Location in the example above. A plant might be a perennial in one geographic area but only an annual in another.

If we create isPerennialInLocation to PlantObservation we can use isInsidePolygon to determine if the plant is a perennial in that region.

data class PlantObservation(
    val location: Location,
    val description: PlantDescription,
) {
    fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)

    fun isPerennialInLocation: Boolean {
      // check if observation is inside the polyon that maps where the plant is considered perenial 
    }
}

Testing this is now easy because we can pass custom Location objects into PlantObservation to test our isPerennialInLocation function. Each location we create can be configured to set up the correct test environment.

A very common example of this in testing is when you're working with randomly generated values. How do you test myVal > Math.random for example? For this example, let's say you're reporting metrics on user behaviour for the plant observation app but you don't want to report all the metrics, you only want a sample.

import kotlin.random.Random

class UserBehaviourReport {
    fun sendReport(): String? {
      if (!Random.nextBoolean()) {
          println("Report not sent")
          return null
        }
        
        // Simulate report generation
        val report = generateMockReport()
        
        // Simulate sending the report
        println("Sending report: $report")
        
        // make HTTP calls to send report here
        return report
    }

    private fun generateMockReport(): String {
        // build report string here
    }
}

If we were to test sendReport we would have to try to mock Random to control the flow of the tests. But if we use composition, we can pass a random generator into the report:

import kotlin.random.Random

class RandomBooleanGenerator(seed: Long? = null) : Random(seed) {
  fun getRandomBoolean(): Boolean {
    return nextBoolean()
  }
}

class UserBehaviourReport(
  private val randomGenerator: Random
) {
    fun sendReport(): String? {
      if (!randomGenerator.nextBoolean()) {
          println("Report not sent")
          return null
        }
        
        // Simulate report generation
        val report = generateMockReport()
        
        // Simulate sending the report
        println("Sending report: $report")
        
        // make HTTP calls to send report here
        return report
    }

    private fun generateMockReport(): String {
        // build report string here
    }
}

Now in our test suite, we can pass in a seed so that our tests run in a deterministic fashion:

    // Create a RandomBooleanGenerator with a seed for reproducibility
    val randomGenerator = RandomBooleanGenerator(seed = 12345)
    
    // Create a UserBehaviourReport with the RandomBooleanGenerator
    val reporter = UserBehaviourReport(randomGenerator)