Composite
💬 Intent¶
See Refactoring Guru: Composite
☹️ Problem¶
I often stumble upon the following problem. Starting off with a class that accepts a callback of some kind.
class UserService(
private val repository: UserRepository,
private val listener: Listener
) {
fun create(user: User) {
require(user.isValid())
val created = repository.create(user)
listener.onCreated(created)
}
}
Could be a listener, notifier or anything along those lines:
interface Listener {
fun onCreated(user: User)
}
More often than not, I have the need to add support for multiple of callbacks. The straightforward and somewhat compelling implementation looks like this:
class UserService(
private val repository: UserRepository,
private val listeners: List<Listener>
) {
fun create(user: User) {
require(user.isValid())
val created = repository.create(user)
listeners.forEach { it.onCreated(created) }
}
}
It does have a few aspects in its favor:
- ✅ Easy to understand
- ✅ Easy to implement
But it comes with a bunch of problems as well:
- ❌ Violates the Single-responsibility principle
UserService
should only change
- ❌ Violates the Open-closed principle
UserService
should not be modified to extend its behavior
In addition, the proposed implementation base on Collection#forEach
is too simplistic.
A proper implementation should also take the following into consideration,
just to name a few:
- ❌ Error handling
If one listener fails, the remaining ones won’t be executed. - ❌ Concurrency
Running listeners in parallel. - ❌ Asynchrony
Executing listeners asynchronously, not waiting for them to finish. - ❌ Dynamic listener de/registration
Allow adding and removing listeners at runtime. This is not totally trivial in a multi-threaded environments.
Adding support for any of them is not rocket science, but it would increase
the complexity of UserService
quite a bit.
😊 Solution¶
Structure¶
Pseudocode¶
The ideal solution addresses all concerns above.
class UserService(
private val repository: UserRepository,
private val listener: Listener
) {
fun create(user: User) {
require(user.isValid())
val created = repository.create(user)
listener.onCreated(created)
}
}
The UserService
didn’t need to change compared to the original design.
No principle would be violated:
We create a new class instead:
class CompositeListener(
internal val listeners: Collection<Listener>,
) : Listener {
override fun onCreated(user: User) {
listeners.forEach { it.onCreated(user) }
}
}
The other concerns could (as needed) all addressed with separate classes using the Decorator pattern:
- ✅ Error handling
fun Listener.onError(handler: ErrorHandler) = object : Listener { override fun onCreated(user: User) { try { this@onError.onCreated(user) } catch (e: Exception) { if (!handler.handle(this@onError, e)) return } } }
interface ErrorHandler { fun handle(listener: Listener, exception: Exception): Boolean } object Log : ErrorHandler { override fun handle(listener: Listener, exception: Exception): Boolean { TODO("Not yet implemented") } } object Ignore : ErrorHandler { override fun handle(listener: Listener, exception: Exception): Boolean = true }
- ✅ Concurrency
fun CompositeListener.concurrent() = object : Listener { override fun onCreated(user: User) { runBlocking { val async = listeners.map { it.async(this) } CompositeListener(async).onCreated(user) } } }
- ✅ Asynchrony
fun Listener.async(scope: CoroutineScope): Listener = object : Listener { override fun onCreated(user: User) { scope.launch { this@async.onCreated(user) } } }
- ✅ Dynamic listener de/registration
Just pass aCopyOnWriteArrayList
toCompositeListener
and modify it as needed.
class Usage {
fun userService(repository: UserRepository): UserService {
return UserService(
repository,
CompositeListener(listOf(
LoggingListener().onError(Log),
MetricsListener().onError(Ignore),
)).concurrent()
)
}
}