Was reading “Multi-Modules” <https://www.apollogra...
# apollo-kotlin
s
Was reading “Multi-Modules” https://www.apollographql.com/docs/kotlin/advanced/multi-modules part of the docs, as I was wondering if I can turn my apollo schema module into a JVM one (from Android) so that dependent modules can also start not having to be Android modules but can be JVM only. Some questions • Does this sound like a reasonable thing to do? 😄 I want to have some of my data layer modules simply get an instance of ApolloClient through DI, and have access to all the apollo generated models so that they can do what they are already doing, which is just use ApolloClient to do some query, turn it into a result object and pass it to the caller, which may be an Android module, but that shouldn’t matter. • I see that the docs mention that “The schema module and only the schema module can call mapScalar”. Does this mean that since I do have some scalars which map into object types that are coming from Android modules, I simply won’t be able to do that? Particularly, we do have one mapper from a scalar to
org.json.JSONObject
which comes from Android, and one for a class coming from the Adyen Android SDK which I don’t think ever would not be an Android library.
As I am thinking about this, maybe my best bet for those scalars is to keep them as String, so do a
mapScalarToKotlinString
on them, and then use the adapter manually whenever there is a place that does in fact want to use that model. And that module can be Android, that’s fine. Provided my idea makes sense and would work in the first place of course 😄
Oh and I see 4.0 is gonna change this a bit, from this part of the docs:
Copy code
// Replace
dependencies {
  // ...

  // Get the generated schema types (and fragments) from the upstream schema module 
  apolloMetadata(project(":schema"))

  // You also need to declare the schema module as a regular dependency
  implementation(project(":schema"))
}

// With
dependencies {
  // ...
  
  // You still need to declare the schema module as a regular dependency
  implementation(project(":schema"))
}

apollo {
  service("service") {
    // ...

    // Get the generated schema types (and fragments) from the upstream schema module 
    dependsOn(project(":schema"))
  }
}
```
But this shouldn’t change what I am trying to do I think.
b
wondering if I can turn my apollo schema module into a JVM one
Correct, Apollo doesn't depend on Android, so it's perfectly OK for this to be a Java (without Android) module ...but it's also true that if you want the generated code to reference types that come from libraries that do depend on Android, then this will probably not work (although to be honest I'm not 100% certain! Can Java module never depend on Android modules? May be worth a try (as I'm typing this I think this shouldn't work as Java-only Gradle wouldn't know anything about
.aar
files for instance). If that's the case, yes mapping to Strings may be a solution. Or depending on the scalar, not mapping at all, in which case this will result in them being generated as
Any
and will effectively reflect the Json at runtime (so
Map<String, Any>
for objects, etc.)
as for the 4.0 changes, yeah they are about the syntax to link modules together and how the types used across modules are detected - shouldn't impact you too much
1
s
Yeah I tried a bit more after this, and I am fairly certain that you can’t have an Android module depending on a JVM one. But with that said, the only limitation then should be my mappers, where only 2 of them are Android specific, so maybe it wouldn’t be too much work. About letting them be Any vs String, I wonder, could I possibly reuse my adapters in some way so that I can be confident in this change? Like in this adapter, I am using
AnyAdapter
to turn it into Any and then passing it through the serializer that the SDK is providing. If I did not do this on this layer, I wouldn’t have access to the
CustomScalarAdapters
, (which I suppose aren’t used here anyway, only if I had nested types in there which also require custom adapters), so I can just omit passing it through this adapter at all. Would I then on the Android module need to do this thing again and pass it through AnyAdapter before being able to deserialize it? My guess would be yes, otherwise the
Any
given from the scalar itself would still inside there have stuff like
__typename
right? Or instead of passing it through AnyAdapter to get the content out of it, maybe mapToKotlinString would do that for me in the first place?
w
out of curiosity, what class from this
Adyen
library you're mapping to? I'm asking because it's interesting that the backend returns fields that are considered so library-specific that they're mapped directly to those types
Overall I highly recommend decoupling as much as possible from Android and having Android-free modules, our entire networking is Kotlin-only so it's definitely possible. We do not expose Apollo generated modules types directly though, we have custom app-specific types for all of them in
api
module though, so there's some duplication (but at the same time this allows us to do minor modifications to queries without those changes propagating through the entire app, so it's worth it)
s
This part predates me, so I really don’t know why it’s this way. But my understanding basically is that Adyen SDK has a class, which they provide their own serializer/deserializer for, all in the same SDK. The backend probably gets that exact type from them in some way, and then to give it to us they use the SDK serializer to turn it into a String, send it to us, and we use the SDK serializer again to deserialize it back into the type. Then this type is used for some drop-in part of the SDK which takes this class and does stuff
👍 1
b
CustomScalarAdapters
, (which I suppose aren’t used here anyway,
That's right, this isn't used at all for scalars, it can be ignored.
w
That makes sense 🤔 What I'd start with would be to create your own wrappers for both types using only Kotlin primitives, and see how much effort it would be to map outside of Apollo. For json you could also attempt to use some non-Android type (like kotlinx.serialization) but I imagine that's already non-trivial migration
s
Yeah I think as a first step I would rather defer this mapping process to doing it further down the line, provided we do not use these types way too much at least. Then it wouldn’t be the end of the world to have to do them later down the line. But this is just an idea, we’ll see if I can manage to make it all work this way first, and then consider if I can drop at least the JSONObject type completely.
b
on your specific example that you linked, you could simply not map the scalar, so it would be
Any
and then you can reuse your logic starting with
return if (data is Map<*, *>)
, etc.
s
We usually have a data layer Repository/UseCase which does the query directly, gets the GraphQL generated model as a response, and that itself maps it into a non-generated model which the rest of the app uses. In your module picture that you show up there, which one is the one that has your schema in it? And do all the others read from it using
apolloMetadata(…)
? I just wonder which other modules do you have that depend on generated-models? Having a bit hard time understanding how you don’t expose the generated models to any(?) other module aside your API? How do your repositories for example make a query if they don’t see the generated FooQuery() class?
on your specific example that you linked, you could simply not map the scalar, so it would be
Any
and then you can reuse your logic starting with
return if (data is Map<*, *>)
, etc.
Right, but I start this if check only after having passing it through the AnyAdapter, would I not still have to do this in some way? The
Any
that I receive if I don’t do anything, not even put
mapScalarToKotlinString
on the scalar, wouldn’t it not be on the same shape as I have it after passing it through the AnyAdapter?
b
wouldn’t it not be on the same shape as I have it after passing it through the AnyAdapter?
no that would be equivalent 🙂 The codegen actually will use AnyAdpater under the hood in that case
today i learned 1
it converts json to maps of primitive types
s
Right, so it’s in my best interest not to pass use
mapScalarToKotlinString
so that I get it on the exact same shape as I would in here. That’s really good to know, thank you!
2
w
generated-models
only contains Apollo's generated classes, queries and responses.
api
module contains our own types that map ~1:1 to generated models, but with our own types. So for example
generated-models
will have Apollo's generated
SomeQuery
and
SomeQueryData
and our
api
will have
object SomeQuery : ApiQuery<ApiResponseModel>
and
data class ApiResponseModel
, where
ApiQuery
is our abstraction over a query. Then in
apollo
we have an implementation that accepts
ApiQuery
, uses mappings provided by
adapter
module to transform them to Apollo classes, use Apollo classes to run the query, get the response, and again mappings from
adapter
would map to our own types for responses
thank you color 1
b
(in fact, if you use
mapScalarToKotlinString
and the server returns something that is not a String, like a Json object, it will fail)
👍 1
w
Rest of the app only depends on
api
, so our own classes (there's also
Api
class there with functions like
query(ApiQuery<T>): Flow<T>)
. Rest is wired up in app DI
s
Okaaay I think I follow a bit better now. I gotta say, that does feel like quite some work though no? I have no issue using the generated models/class at least as long as they don’t propagate all the way down to the UI, I haven’t felt the need to do the mapping that early. This here is a good example of how our good use cases look like. It does not expose anything from the generated code, but returns a mapped model, but it still does use the generated code directly. These classes is what I want to be able to have in pure JVM code, and it should be possible without this extra layer of mapping is what I am thinking. What are the main reasons you’ve gone that far to make an entire mapping layer there?
b
(As an aside, the question whether to use generated models in upper layers directly, vs converting them to dedicated models comes up regularly! Even came up yesterday :) The tradeoff is true isolation vs convenience/verbosity.)
w
Well I'm not gonna argue it's more work, but it's really manageable and we enjoy the decoupling when we have to do changes in the networking layer. For example v2->v3 migration was that much easier, also back when the option was added to generate Kotlin models we also updated only networking layer which was nice. Not gonna argue it's the only correct approach, IMO it's in line with clean principles as it decouples data-layer implementation details from domain code 🙂
true isolation vs convenience/verbosity
that's a good summary — we just value isolation much more. IMO the benefits kick in when you have more libraries like that and you were consistent with the isolation everywhere. If it's only Apollo that would not be isolated then it's not that bad, but I think all libraries should be treated equally 😛
👍 1
b
The way I see it, “by default” I would want to isolate/decouple - and then “reality kicks in” as a layer on top: pragmatism / pros vs cons / project’s specifics / team size, etc.
s
It’s just that I never considered doing it before, that’s why I am asking mainly 😅 I can see the appeal though, of course. We use apollo for 99% of our network, and we got 1 module for 1 service, 1 for another, each having their own ApolloClient. The only tricky thing we go going is that if a module needs access to both services, you gotta depend on both modules, so you get access to both generated code, so you may try to make a query for one service on the wrong instance of your ApolloClient 😅 Also we’re a team of 2, so we’re really taking all the shortcuts we can take regarding autogenerated code and such 😄 So the approach we’re having right now feels quite easy to work with already.
👍 1
b
that sounds very reasonable!
1