skip to Main Content

From viewModelScope to Custom Classes: Safe Coroutine Architecture in Kotlin

March 15, 20266 minute read

  

In the previous article, Stop Creating CoroutineScopes Without References: A Kotlin Anti-Pattern, we discussed how creating CoroutineScopes without holding a reference to them can lead to runaway coroutines — coroutines that continue running with no owner responsible for cancelling them.

However, that raises an important architectural question:

If we should not create free-floating scopes…
How should an object properly own its coroutines?

We already know Android provides viewModelScope and lifecycleScope, so clearly this problem has a clean solution.

In this article, we’ll:

  • Build a coroutine-aware custom class
  • Understand why lifecycle awareness matters
  • Explore garbage-collection-based cancellation
  • Solve scope ownership properly
  • Prevent runaway coroutineScopes

Not a member? Read the full article for free here →

Step 1 — A Simple Class

Let’s begin with a very basic class:

class SimpleTask {
fun runTask() {
for(i in 0..10) {
println(i)
}
}
}

fun main() {
SimpleTask().runTask()
}

This class:

  • Runs synchronously
  • Has no coroutine scope
  • Has no dispatcher control
  • Has no cancellation mechanism

If we want it to execute on a specific dispatcher, we have two options:

  1. Let the caller provide a scope
  2. Let the object own its own scope

Option 1 — CoroutineScope From Caller (Bad Idea)

class SimpleTask {
fun runTask() {
for(i in 0..10) {
println(i)
}
}
}

fun main() {
CoroutineScope(Dispatchers.Default).launch {
SimpleTask().runTask()
}
}

This looks harmless — but from the previous article, we know why this is dangerous:

  • The created CoroutineScope has no reference
  • It is not cancelled
  • It has no lifecycle
  • It can continue running indefinitely

This is how runaway coroutineScopes are created.

So the responsibility must shift.

Option 2 — Let the Object Own Its CoroutineScope

class SimpleTask {
val scope = CoroutineScope(Dispatchers.Default)
fun runTask() = scope.launch {
for (i in 0..10) {
println(i)
}
}
fun close() {
scope.cancel()
}
}

fun main() {
SimpleTask().runTask()
}

Now:

  • The scope is owned by the object
  • We can manually cancel it using close()

This is better — but still flawed.

Why?

Because cancellation is still manual.

If the caller forgets to call close(), the scope becomes runaway again.

So we need automatic cancellation.

Learning From ViewModel

viewModelScope works because:

  • It is tied to a lifecycle
  • onCleared() is guaranteed to be called
  • Cancellation is automatic

So what is the lifecycle of a normal Kotlin object?

An object:

  • Is created when constructed
  • Is destroyed when garbage collected

So the real question becomes:

Can we detect when an object is garbage collected and cancel its scope?

Let’s try.

Attempt 1 — Detecting GC Inside the Same Object (Fails)

class SimpleTask : AutoCloseable {
val scope = CoroutineScope(Dispatchers.Default)
init {
scope.launch {
while (scope.isActive) {
if (this == null) {
close()
break
}
println("Checking weakRef")
delay(1000) // check every 1 second
}
}
}
fun runTask() = scope.launch {
for (i in 0..10) {
println(i)
}
}
override fun close() {
println("Cancelling SimpleTask Scope")
scope.cancel()
}
}

fun main() {
var simpleTask: SimpleTask? = SimpleTask()
runBlocking { simpleTask?.runTask()?.join() }
simpleTask = null
runBlocking {
System.gc()
delay(10_000)
}
}

Output:

Checking weakRef
0
1
2
3
4
5
6
7
8
9
10
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef
Checking weakRef

Why does this fail?

Because:

  • this inside the coroutine refers to the same object
  • As long as the coroutine is running, the object is strongly referenced
  • Therefore, it can never be garbage collected

This creates a reference cycle.

So we need separation.

Correct Architecture — Separate Scope From Owner

We introduce a separate class:

class CoroutineRunner(owner: Owner) : CoroutineScope, AutoCloseable {
private val supervisor = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + supervisor
private val ownerRef = WeakReference(owner)
init {
launch {
while (isActive) {
if (ownerRef.get() == null) {
close()
break
}
println("Checking weakRef")
delay(1000) // check every 1 second
}
}
}
override fun close() {
supervisor.cancel()
println("SwitchThread closed")
}
}

interface Owner {
val coroutineRunner: CoroutineRunner
}

class SimpleTask: Owner {
override val coroutineRunner: CoroutineRunner = CoroutineRunner(this)

fun runTask() = coroutineRunner.launch {
for (i in 0..10) {
println(i)
}
}
}

fun main() {
var simpleTask: SimpleTask? = SimpleTask()
runBlocking { simpleTask?.runTask()?.join() }
simpleTask = null
runBlocking {
System.gc()
delay(10_000)
}
}

Output:

0
1
2
3
4
5
6
7
8
9
10
SwitchThread closed

What changed?

  • CoroutineRunner holds a WeakReference to its owner
  • The owner does NOT strongly reference itself through the coroutine
  • Once owner is GC’d → ownerRef.get() becomes null
  • Scope cancels automatically

We now have automatic lifecycle-based cancellation.

Two Remaining Problems

Even this solution has issues:

  1. No explicit manual cancellation exposed cleanly.
  2. CoroutineRunner can still be accessed from outside via coroutineRunner, potentially extending its lifecycle.

We must fix both.

Adding Manual Cancellation

interface Owner {
val coroutineRunner: CoroutineRunner
fun close()
}

class OwnerImpl : Owner {
override val coroutineRunner: CoroutineRunner = CoroutineRunner(this)
override fun close() {
coroutineRunner.close()
}
}

class SimpleTask: Owner by OwnerImpl() {

fun runTask() = coroutineRunner.launch {
for (i in 0..10) {
println(i)
}
}
}

Now close() allows manual cancellation.

Final Improvement — Hide CoroutineRunner Completely

We now prevent external access:

class CoroutineRunner(owner: Owner) : CoroutineScope, AutoCloseable {
private val supervisor = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + supervisor
private val ownerRef = WeakReference(owner)
init {
launch {
while (isActive) {
if (ownerRef.get() == null) {
close()
break
}
println("Checking weakRef")
delay(1000) // check every 1 second
}
}
}
override fun close() {
supervisor.cancel()
println("SwitchThread closed")
}
}

interface Owner {
fun launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
fun close()
}

class OwnerImpl : Owner {
private val coroutineRunner: CoroutineRunner = CoroutineRunner(this)
override fun launch(context: CoroutineContext, start: CoroutineStart, block: suspend CoroutineScope.() -> Unit) =
coroutineRunner.launch(context, start, block)
override fun close() {
coroutineRunner.close()
}
}

class SimpleTask : Owner by OwnerImpl() {
fun runTask() = launch {
for (i in 0..10) {
println(i)
}
}
}

fun main() {
var simpleTask: SimpleTask? = SimpleTask()
runBlocking { simpleTask?.runTask()?.join() }
runBlocking {
simpleTask?.close()
simpleTask = null
System.gc()
}
}

Now:

  • CoroutineRunner is private
  • Only launch() is exposed
  • Ownership chain is preserved
  • GC triggers automatic cancellation
  • Manual cancellation still exists
  • No external leaks possible

Final Architecture Summary

We achieved:

  • Object-owned CoroutineScope
  • Automatic cancellation on garbage collection
  • Manual cancellation support
  • No exposed internal scope
  • No runaway coroutineScopes
  • Proper ownership separation
  • Lifecycle-like behavior without Android

Critical Warning

If OwnerImpl is:

  • Exposed publicly
  • Stored externally
  • Given a getter
  • Or modified structurally

You will break the ownership chain.

And once that happens:

  • GC detection fails
  • CoroutineRunner may outlive its owner
  • Automatic cancellation becomes unreliable

This architecture depends entirely on strict ownership boundaries.

Final Takeaway

The correct way to make coroutine-scoped objects is:

  1. Never create anonymous CoroutineScopes.
  2. Never let callers manage object scopes.
  3. Separate scope and owner.
  4. Use WeakReference to detect GC.
  5. Hide the actual CoroutineScope implementation.
  6. Support both automatic and manual cancellation.

If your object launches coroutines, it must own them properly — 
otherwise, you are building hidden runaway coroutineScopes.


From viewModelScope to Custom Classes: Safe Coroutine Architecture in Kotlin was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top