spring-projects / spring-projects/spring-graphql
`IllegalArgumentException: object is not an instance of declaring class` when a reflectively-invoked `suspend` GraphQL data fetcher suspends on `Dispatchers.IO` and returns a value-class-bearing type
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 1.6k
- Forks
- 336
- PR merge metrics
- No merged PRs in 30d
Description
Summary
A Spring GraphQL data fetcher declared as a Kotlin suspend function throws
java.lang.IllegalArgumentException: object is not an instance of declaring class
at invocation time, before (or during the resumption of) the resolver body, when all the
following hold:
- The data fetcher is a Kotlin
suspendfunction and is therefore invoked
reflectively through Spring'sCoroutinesUtils.invokeSuspendingFunction(...)
→ kotlin-reflectKCallables.callSuspendBy(...)→ValueClassAwareCaller. - The function actually suspends and resumes on a different dispatcher —
in our case its body doescoroutineScope { async(Dispatchers.IO) { … } }
andawait()s the results. - The value produced on resume is constructed from / contains a Kotlin
@JvmInline value class(so kotlin-reflect's value-class-aware boxing path
is engaged when marshalling the call).
Making the body non-suspending (running the same work synchronously, no
async(Dispatchers.IO)) eliminates the crash. No other change is
required.
Environment
| Component | Version |
|---|---|
| Kotlin | 2.4.0 (language/api version 2.4) |
| kotlinx-coroutines | 1.11.0 |
| JVM target | 21 |
| Spring Boot | 4.0.6 |
| Netflix DGS | 12.0.1 (graphql-dgs-spring-graphql-starter, i.e. DGS on top of Spring for GraphQL) |
| DGS codegen | 8.5.0 |
| Reactive stack | spring-boot-starter-webflux / reactor |
Observed behavior
Calling the query throws during data-fetch dispatch. Representative stack trace
(framework frames are verbatim; application package/line numbers genericized):
java.lang.IllegalArgumentException: object is not an instance of declaring class
at kotlin.reflect.jvm.internal.calls.ValueClassAwareCaller.call(ValueClassAwareCaller.kt:137)
at kotlin.reflect.jvm.internal.ReflectKCallableKt.callDefaultMethod(ReflectKCallable.kt:173)
at kotlin.reflect.full.KCallables.callSuspendBy(KCallables.kt:82)
at org.springframework.core.CoroutinesUtils.invokeSuspendingFunction(CoroutinesUtils.kt:...)
... reactor / DGS data-fetcher dispatch frames ...
at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt)
at com.example.app.api.MetricsResolver.aggregateMetrics$suspendImpl(MetricsResolver.kt:30)
...
The aggregateMetrics$suspendImpl(...:30) frame (below the
_COROUTINE._BOUNDARY._ marker) is the suspension point — the body does
begin executing and reaches the async(Dispatchers.IO)/await() call. The
thrown exception originates from the reflective call machinery
(ValueClassAwareCaller) handling the suspending invocation, not from
application logic.
Minimal-ish reproduction
The crash reproduces only through the reflective suspend-invocation path that
Spring GraphQL / DGS uses (CoroutinesUtils.invokeSuspendingFunction). A direct
KFunction.callSuspendBy(...) from a hand-written harness did not reproduce
it in our testing (it resolved a plain, non-value-class-aware caller), so the
reproduction below is framed around the Spring GraphQL runtime.
Value class + return type
@JvmInline
value class Amount(val cents: Long) {
fun toDollars(): Double = cents / 100.0
}
// Domain type returned by the service; contains a value-class field.
data class Metrics(
val total: Amount,
val count: Int,
)
// GraphQL DTO (plain data class generated by codegen).
data class MetricsDto(
val total: Double,
val count: Int,
)
fun Metrics.toDto() = MetricsDto(total = total.toDollars(), count = count)
Service — body genuinely suspends on Dispatchers.IO (TRIGGERS the crash)
@Service
class MetricsService(
private val repository: MetricsRepository, // blocking JPA repo
) {
suspend fun calculate(filterId: String?): Metrics? =
coroutineScope {
val a = async(Dispatchers.IO) { repository.aggregateA(filterId) }
val b = async(Dispatchers.IO) { repository.aggregateB(filterId) }
Metrics.from(a.await(), b.await()) // builds the value-class field
}
}
Resolver — reflectively invoked by Spring GraphQL / DGS
@DgsComponent
class MetricsResolver(
private val metricsService: MetricsService,
) {
@DgsQuery
suspend fun aggregateMetrics(
@InputArgument accountId: String?,
@InputArgument openOnly: Boolean?,
@InputArgument filter: FilterInput,
): MetricsDto? =
metricsService.calculate(filterId = accountId)?.toDto()
}
GraphQL schema:
type Query {
aggregateMetrics(accountId: String, openOnly: Boolean, filter: FilterInput!): Metrics
}
Querying aggregateMetrics throws IllegalArgumentException: object is not an instance of declaring class.
The fix / workaround — make the body non-suspending (NO crash)
Removing the coroutineScope { async(Dispatchers.IO) { … } } and running the
two aggregate queries sequentially (so the suspend function never actually
suspends/resumes on another dispatcher) makes the resolver complete
synchronously and the crash disappears:
@Service
class MetricsService(
private val repository: MetricsRepository,
) {
fun calculate(filterId: String?): Metrics? { // no longer suspend
val a = repository.aggregateA(filterId) // sequential, blocking
val b = repository.aggregateB(filterId)
return Metrics.from(a, b)
}
}
(The resolver may remain suspend or be changed to a plain function; either way
it now completes without a real suspension point, and the value is returned
without hitting the broken resume path.)
Why we believe the trigger is the suspend-resume + value-class boxing combination
We bisected against every other suspend GraphQL resolver in our codebase.
Each of the following passes, which individually rules out the obvious
single-feature explanations:
| Distinguishing feature | A passing suspend resolver that has it |
|---|---|
Is suspend |
many |
Nullable return (Dto?) |
several |
Nullable primitive parameter (Int? / Boolean?) |
yes |
Takes the same complex @InputArgument input type |
yes |
| Returns a type built from value classes | yes (when it does not dispatch via async(Dispatchers.IO)) |
The only resolver that crashes is the one whose body genuinely suspends and
resumes on Dispatchers.IO while producing a value-class-bearing return. The
crash is raised by kotlin-reflect's ValueClassAwareCaller (the value-class /
boxing-aware caller), on the reflective-suspend path
(callSuspendBy → callDefaultMethod → ValueClassAwareCaller.call). This is
consistent with the known class of reflective-suspend-invocation issues where
the caller mishandles the resumed continuation / COROUTINE_SUSPENDED vs. the
boxed return value (cf. kotlinx.coroutines issue #3761 and related
kotlin-reflect reports). The receiver/argument array appears to be misaligned
on resume, producing object is not an instance of declaring class from the
underlying Method.invoke.
We did not confirm the exact internal cause inside kotlin-reflect; the above
is the empirically confirmed trigger and a hypothesis for the mechanism.
Questions
- Is
ValueClassAwareCallerexpected to be selected for asuspendfunction
whose declared parameter and return types contain no value class (the DTO
is a plain data class)? If so, does its argument/receiver indexing account for
the continuation parameter and the resumed return correctly? - Does the resumption of a reflectively-invoked
suspendfunction on a different
dispatcher (Dispatchers.IO) interact incorrectly with the value-class boxing
path?
I can also submit this to the Kotlin team, as the throwing frame
is kotlin.reflect.jvm.internal.calls.ValueClassAwareCaller), with a
cross-reference to kotlinx.coroutines (#3761 family) and a note that it
surfaces through Spring Framework CoroutinesUtils.invokeSuspendingFunction
as used by Spring for GraphQL / Netflix DGS.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Spring's CoroutinesUtils.invokeSuspendingFunction entry point and trace the reported kotlin-reflect path through KCallables.callSuspendBy and ValueClassAwareCaller.call using the provided Spring GraphQL reproduction. Confirm the suspend-resume and value-class conditions, then define a regression test covering the dispatcher switch; done means the reflective GraphQL invocation no longer throws for that case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- graphql, kotlin, spring
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100