Software Design Pattern: Composite by Induction

We at QuickBird Studios are often faced with a wide variety of problems. Some of them are very easy to solve, some of them are very, very hard and some are … let’s say… „very unique“ 😅. We tried a lot of different patterns and strategies to solve those problems and one pattern that we fell in love with is the Composite Pattern. We call our own approach to this pattern Composite by Induction (short Induction) and it enables you to use the composite pattern in very complex situations with very low effort.

Composite Pattern

Here is a very quick refresh of the composite pattern. Feel free to skip this part if you are already familiar with it!

„The composite pattern describes a group of objects that are treated the same way as a single instance of the same type of object.“ Wikipedia

As a developer, whether or not you thought about it, you most likely encountered the composite pattern many times in your career. One of the simplest and most straightforward examples would be Android’s View-System. Any View can do stuff like draw yourself to a canvassetAlpha or animate. You can group or layout those Views by putting them into a certain Layout which extends ViewGroup. Obviously, this ViewGroup itself can draw itself to a canvassetAlpha, or animate, because a ViewGroup itself is just an implementation of View 🧐

Example of the Composite Pattern: Android View Hierarchy

Induction

Let’s imagine you are building an app to track the weight of your customers. One feature you want to implement is the detection of certain data-patterns to help your users to better understand their health and behaviors. So you start thinking about potential patterns that could add value to your customers when detected. You quickly came up with many ideas: Maybe you want to detect your customers cheat days 🤔. You definitely want to find up & downtrends in the data and maybe even encourage „good“ trends inside your app.

Let’s build a weight tracker that detects up & downtrends

After you defined your Pattern interface, you start modeling the detection of those and you came up with a very simple interface:

It is a good idea to split the problem of detecting patterns into smaller problems. It sounds clever to have a certain PatternDetector for each pattern you want to support. So you start implementing a CheatDayPatternDetector , DownTrendPatternDetector , UpTrendPatternDetector, etc. which obviously all implement the same interface PatternDetector.
But wait 🧐. Obviously, there is a very simple way that you can represent all those implementations as a single instance of PatternDetector instead of a List<PatternDetector>. Induction makes this possible by solving this problem for the simplest case. Maybe, if we combine two PatternDetector instances, we have a new PatternDetector which is just more powerful? Maybe we could combine an instance of DownTrendPatternDetector with an instance of UpTrendPatternDetector to get a more powerful PatternDetector able to detect down- & uptrends. Here is how you could do this using induction.

Create a connector

Connector is a class which knows how to combine two instances of an interface and itself implements this very own interface itself.

Let’s take our weight-app example and think how a combination of two PatternDetector instances should behave. This is very simple ☝! We just expect the combination to return a list containing all patterns found by the first detector and all patterns found by the second connector as a list!

Create a combine function

We do not want to deal with the connector type directly. Instead, we create a combine function as extension function on PatternDetector which only exposes the desired interface.

If we would now combine the DownTrendPatternDetector with an instance of UpTrendPatternDetector we would get a new PatternDetector able to detect up- and downtrends in our weight-data:

Compose your final instance

Now, one can easily get the final combined instance by chaining all specific instances together.

Using different connectors to express logic

The given weight-app example is very simple and connecting two instances of a certain type can get very difficult sometimes. Some problem, that we encountered is that the trivial combine logic is not always desired. What if we want to favor a certain PatternDetector over another? What if we want to prevent two pattern detectors to respond on the same area on the data?
This logic can be expressed by using different connectors and different extension functions.
Let’s have a look back up where we combined an instance of DownTrendPatternDetector with one of UpTrendPatternDetector: We notice that both instances are declaring a small shared area as UPTREND as well as DOWNTREND which is not that cool 🤔

Let’s implement an alternative Connector that alters detected patterns to avoid overlays. We could do this by splitting the overlapping area in half, making the overlapping patterns smaller in size!


We can now compose our final production-ready instance of PatternDetector by using our two distinct combine-methods!

Conclusion

We are using Composite by Induction in a wide variety of projects and circumstances:

  • We combine multiple ShapeDetector instances to find basic shapes like circles or polygons in images
  • We model the data layer of apps by combining multiple DataSource instances
  • We build beautiful animations by combining multiple Animation instances

Obviously, it is almost always easier to combine n instances of a certain type by combining always 2 instead of combining them all together at once. That’s why this pattern can even simplify your thinking process. You solve a problem for two instances and you’re afterward able to apply it to n instances.

If you want to know more about how we architect mobile apps, check out this article about our approach to MVVM, a modern UI architecture pattern.