Replies: 1 comment 3 replies
-
Hey Chris, thanks for taking the time to provide this feedback. This is such a great question and one that comes up often so I'm going to try and explain how we landed here and where we may be going.
This is great to hear!
It has less to do with easier to generate and more that there are technical limitations in the way services are modeled (more or on that in a second). There are two things here that you are getting at really (1) the use of data classes vs builders and (2) better nullability information in generated types. Data classes vs BuildersThere are a few things that influence this decision.
Model Evolution(1) is the most important so let's start with that. Let's take a hypothetical Smithy model (see appendix for more information):
The required trait is what would tell us that we could theoretically generate a type as data class CreateFooRequest(
name: String,
description: String? = null
) The issue is that it is actually a non-breaking change to remove the
Then suddenly we would output: data class CreateFooRequest(
name: String? = null,
description: String? = null
) This is breaking change in the generated SDK (whether we use data classes or builders). Of course we could major version bump the SDK but this evolution happens enough that it would be a poor customer experience as well. The other issue with model evolution is the addition of members. If an (optional) member is added to a structure:
For this to not be a breaking change we have to guarantee the order that members are generated in because consumers may be using positional arguments (there is no way to force named arguments). e.g. if we mistakenly generate priority in the middle of existing arguments it would be a breaking change: // after model update that added `priority`
data class CreateFooRequest(
name: String,
priority: Int? = null,
description: String? = null
) // version 1
val request = CreateFooRequest("name", "description") // error, priority became the second constructor argument which is of type Int
val request = CreateFooRequest("name", "description") Not only would this break the constructor but the This issue is actually solvable but it requires that members always be added to the end of a structure. This is supposed to be the way models evolve but nothing actually guarantees this. It was deemed safer to use a DSL builder to prevent accidental backwards compatibility issues than rely on something that isn't guaranteed. Complex InputsFrom less of a technical constraint we chose DSL syntax over data classes because it supports more complicated inputs. For example any operational input that has a nested structure type benefits from a DSL. Let's take a DynamoDB example: val req = CreateTableRequest {
tableName = "table-name"
...
// this is a nested class/structure type, the DSL allows you to create it using the ProvisionedThroughput.DslBuilder
provisionedThroughput {
readCapacityUnits = 10
writeCapacityUnits = 10
}
} Additionally there are some operations that have a very large number of inputs (e.g. S3 Finally, DSL builders allow us to generate overloads for the service client interface that allow you to not create a request in most cases: val createTableResp = ddbClient.createTable {
tableName = "table-name"
...
provisionedThroughput {
readCapacityUnits = 10
writeCapacityUnits = 10
}
} Nullability of Generated TypesThe model evolution section above touched on this but the main issue today is that there is nothing in the model that really allows us to generate required members as non-nullable. All three new AWS SDKs Kotlin, Rust, and Swift all model optionality/nullability directly in their type system and all share this concern. We recognize that this is not ideal and there is some work going on to try and improve the status quo but nothing concrete to share at the moment. Hopefully that gives you some context around both the technical constraints as well as some of the subjective decisions made. Please feel free to ask any additional questions and of course we would love to hear any feedback you have about using the SDK (good or bad). Appendix
|
Beta Was this translation helpful? Give feedback.
-
Hi, thanks for all of your work so far on the Kotlin SDK! We're very excited that it exists and counting the days until it moves out of alpha/beta into GA :)
First thing to note is that the killer feature for us is the suspend functions, by a mile. We're being somewhat aggressive about trying it out / adopting it just because of how big of a difference that makes in our kotlin code base.
One thing I have been noticing though, that I wanted to inquire about, is how the builders work.
For example, for the DDB client, to create a table, I have to do something like this:
many (most? all?) of those fields are required, but because of the way this DSL works, I can delete e.g.
provisionedThroughput
and this code will still happily compile, and I won't know I have a problem until runtime.One of the main value propositions of Kotlin for us is that it is so diligent about pushing errors forward from runtime to compile time. If I was designing a DSL for these builders from scratch, my inclination would be to use data classes rather than this builder block DSL.
With a data class, the syntax would look almost the same:
but if I removed a field, e.g.
provisionedThroughput
, then one of two things would happen. Either your data class provides a default value for that field and I'm fine, or it doesn't, and I get a compile-time error instead of a runtime error.Without any familiarity with how your codegen works, I don't know whether there is some technical reason why it's easier to generate this builder block DSL than it would be to generate data classes, or if you have some other motivation for this design choice. But I wanted to ask about it because it feels like it strays from kotlin idioms a bit and I'm curious to know if it would be possible to flip to data classes.
Beta Was this translation helpful? Give feedback.
All reactions