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 canvas, setAlpha or animate. You can group or layout those View
s by putting them into a certain Layout which extends ViewGroup
. Obviously, this ViewGroup
itself can draw itself to a canvas, setAlpha, or animate, because a ViewGroup
itself is just an implementation of View
🧐
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.
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
A 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.