Skip to content

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:

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

Componentrun()CompositeComponentelements: Collection<Component>run()SomeComponentrun()Clientuse

Pseudocode

Listenerrun()CompositeListenerlisteners: Collection<Listener>run()LoggingListenerrun()MetricsListenerrun()UserServiceuse

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 a CopyOnWriteArrayList to CompositeListener and modify it as needed.
class Usage {
    fun userService(repository: UserRepository): UserService {
        return UserService(
            repository,
            CompositeListener(listOf(
                LoggingListener().onError(Log),
                MetricsListener().onError(Ignore),
            )).concurrent()
        )
    }
}

💡Applicability

📝 How to implement

⚖︎ Pros and Cons

References


Last update: September 5, 2021