Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Adopting Kotlin, Or: How we learned to stopped worrying and love the null

Adopting Kotlin, Or: How we learned to stopped worrying and love the null

A case study presented by Duncan McGregor & Nat Pryce at SPA 2017


Nat Pryce

June 27, 2017


  1. Adopting Kotlin Or: How we learned to stopped worrying and

    love the null Duncan McGregor & Nat Pryce
  2. What is Scala? • A statically-typed OO/functional hybrid language running

    on the JVM • A hell of a compiler • An impressive runtime • Still evolving Kotlin?
  3. @

  4. What were we building at Springer Nature? •Server-side JVM webapps,

    hosted on Undertow •Nat - 6 devs, Postgres, RabbitMQ •Duncan - 4 devs, Elastic Search and Postgres •Algorithmically uninteresting •TDD, mostly XP •IntelliJ, Gradle, Git •Springer Nature’s own CD pipeline to Cloud Foundry
  5. Why did we adopt Kotlin? Because •we like / needed

    to run on the JVM •it’s so hard to do simple things in Java •we’re too old to cope with dynamic languages •we value excellent tool support •everything else was worse
  6. Why Might You Migrate? •Productivity • FP benefits in algorithm

    design • Immutable throughput for multi-core • Expressiveness • The Python Paradox
  7. Coming up... What we did Overenthusiasm Culture Shock Gradually converting

    a Java application to Kotlin without slowing delivery. We went too far so that you don't have to. Kotlin is a foreign country; they do things differently there.
  8. Conversion demo

  9. How did we migrate? •Build in both Java and •New

    classes in Scala •Migrate existing classes case-by-case ▪ “Convert File to Kotlin” ▪ Move to functional style ▪ Embrace null ▪ Adopt Kotlin idioms This is also a good model for learning Scala Kotlin Kotlin Kotlin
  10. Code layout and organisation The Kotlin style guide is not

    very extensive It seems trivial, but inconsistent conventions became annoying • Code layout • Code organisation • Visibility modifiers Each project developed its own conventions … eventually. They were all slightly different.
  11. It took us time to accept using null data class

    SubmissionDraft( override val id: DraftId, override val submissionStatus: SubmissionStatus, override val createdTimestamp: Instant, override val submittedTimestamp: Instant? = null, override val submissionId: SubmissionId?, override val decisions: UserDecisions )
  12. Extension functions on nullable types "Elvis has left the building!"

    This can be null in an extension method of a nullable type! fun String?.orEmpty() = if (this == null) "" else this fun <T> List<T>?.orEmpty() = this ?: emptyList() Beware: toString is an extension method of a nullable type! val maybeCount: Int? = … maybeCount?.toString() // returns null or the count maybeCount.toString() // returns "null" or the count The type checker won't help: String is a subtype of String? Impossible to spot the difference in test diagnostics!
  13. Exceptions are not type checked! "Failure is not an option!"

    sealed class Result<out T, out ERR: Any > data class Failure<out ERR: Any>( val value: ERR) : Result<Nothing,ERR>() data class Success<out T>(val value: T) : Result<T, Nothing>() inline fun <T, U> Result<T>.map(f: (T) -> U): Result<U> = ... inline fun <T, U> Result<T>.flatMap(f: ( T) -> Result<U>): Result<U> = ... inline fun <T> Result<T>.forEach(f: (T) -> Unit): Unit = ... inline fun <T> Result<T>.orElse(f: (ErrorCode) -> T) = ... inline fun <T> T?.asResultOr(errorCodeFn: () -> ErrorCode): Result< T> = ... ... etc. Use the same method names as Kotlin's collections: "map", "flatMap", "filter", ...? Or something domain-specific? ?
  14. Exceptions are not type checked! Technical error? Further action? Catch

    as close as possible to failing call. Return an error code in a Result.failure Translate to HTTP response in the web layer Let the exception propagate – the web layer will send a 500 response Yes No Yes No
  15. Enforced by fuzz testing @Test fun `reports all parse errors

    with a JSON Pointer`() { val validJson = exampleAmendmentRequest.toJson() val mutants = random.mutants( defaultJsonMutagens().forJackson(), 500, validJson) mutants.forEach { mutantJson -> val result = format.parseJson(mutantJson) // no exception thrown if (result is Failure) assertThat(result.value, isA<InvalidJson>( has(InvalidJson::pointer, present()))) } } https://github.com/npryce/snodge
  16. Result<T> instead of exceptions fun sendToEjp(draft: SubmissionDraft): Result<SubmissionId> = journals.getByPCode(draft.pCode)

    .flatMap { journal -> journal.ejpJournalId .asResultOr { PeerReviewSystemNotSupported(ejp, draft.pCode) } .flatMap { ejpJournalId -> payloadConverter.convertForCreate(draft) .flatMap { requestJson -> val uri = EjpUrlRoutes.create.path(ejpJournalId) sendCreate(uri, payload) .flatMap { responseJson -> responseJson.toSubmissionId() } } } } Type safe, but...
  17. Accept early returns in functional code inline fun <T> Result<T>.onFailure(handler:

    (Failure) -> Nothing): T = when (this) { is Failure -> handler( this) is Success -> value } fun create(draft: SubmissionDraft): Result<EjpSubmissionId> { val journal = journals.getByPCode(draft. pCode) ?: return Failure(JournalNotFound(draft. pCode)) val journalId = journal. ejpJournalId ?: return Failure(PeerReviewSystemMisconfiguration()) val ejpPayload = ejpPayloadConverter.convertForCreate(draft) .onFailure { return it } val uri = EjpUrlRoutes. createPath.path(journalId) val responseJson = sendCreate(uri, payload) .onFailure { return it } return responseJson.toSubmissionId() }
  18. So Far So Good •At this point you just have

    a better Java • More consistent • Default immutability • Better collections • Less noisy • More bang per line of code • Fast
  19. Except •The build is much slower •Tool support is worse

    •Debugging is harder •Error messages are sometimes cryptic •Scala runtime source is opaque •Scala is evolving
  20. The Next Step •Scala tools for making code more expressive

    • Mixin traits • Pattern matching • Implicit conversions / parameters • Operator overloading • AST Macros • Compiler plugins
  21. Extension functions (as usually described) Additional methods defined on types

    we don’t own. Before val name = mandatoryChild(node, "name") fun mandatoryChild(node: JsonNode, name: String): JsonNode = node.get(name) ?: throw MissingPropertyException(name) After val name = node.mandatoryChild(“name”) fun JsonNode.mandatoryChild(name: String): JsonNode = get(name) ?: throw MissingPropertyException(name)
  22. Class cluttered with application functionality class Address( … ) {

    fun isInCountry(county: Country) = … fun isFreeDelivery() = … fun somethingTheUserInterfaceNeeds() = … fun somethingTheDatabaseNeeds() = … } Or application functionality in functions enable(checkbox, isFreeDelivery(preferredAddress(customer))) We extend our own types
  23. Local extension functions for local logic Fundamental abstraction class Address(

    … ) { fun isInCountry( … ) } Logic specific to a part of the business domain fun Address.isFreeDelivery() = UI Module fun Address.somethingTheUserInterfaceNeeds() = … Persistence Module fun Address.somethingTheDatabaseNeeds() = …
  24. Extension functions allow concise names Nested functions can be verbose

    and hard to read checkbox.enabled = addressIsFreeDelivery(customerPreferredAddress(customer)) Extension functions chain nicely checkbox.enabled = customer.preferredAddress.isFreeDelivery (And make it much easier to deal with null references)
  25. Descriptive names instead of direct calls to copy(). Before val

    newState = submission.copy(files=submission.files + newFile) After val newState = submission.plusFile(newFile) ... fun Submission.withNoFiles() = copy(files=emptyList()) fun Submission.withFile(f: File) = copy(files=listOf(f)) fun Submission.plusFile(f: File) = copy(files=files + f) Extension functions of data classes Use a common naming convention across the code base. E.g. "withXxx" replaces, "plusXxx" adds, "minusXxx" removes.
  26. Parameterise, rather than extend Before val rootUri = config.getURI("server") val

    accessToken = config.getString("accessToken") val timeout = config.getLong("timeout") After val rootUri = config.get(uri, "server") val accessToken = config.get(string, "accessToken") val timeout = config.get(long, "timeout") object uri : Parser<URI> { override fun parse(s: String): URI? = try { URI(s) } catch (e: URISyntaxException) { null } override val name = "URI" }
  27. Extension functions can be instance methods! A data type that

    will be extended data class Address(..., val postCode: PostCode) Extension function used in a method of a class and defined in that class class PlaceOrder( private val delivery: DeliveryApiClient ): UserAction<Order> { override fun invoke(order: Order) { if (order.deliveryAddress.allowsFreeDelivery()) ... ... } private fun Address.allowsFreeDelivery() = delivery.methodsFor(postCode).find { it.isFree } != null }
  28. Nesting extension methods, extension method literals, extension method instance methods,

    local extension methods, ...
  29. Rabbit Holes •Increasing expressiveness drags you in

  30. Infixation val config = EnvironmentVariables overriding ConfigurationProperties.fromResource("default.conf") infix fun Configuration.overriding(defaults:

    Configuration) = Override(this, defaults) (n) An unhealthy obsession with infix functions. fun Author.toJson(): JsonNode = obj( "name" of name, "affiliation" of affiliation.toJson(), "email" of emailAddress ) Unnecessary (but published, so can't be changed without breaking code) Useful
  31. Operator punning Prefer conventional operators or clearly named functions val

    submissionId = PathElement(::SubmissionId) val fileId = PathElement(::FileId) val submissionPath = "submission"/submissionId val submissionFilePath = submissionPath/"file"/fileId WTF?!?
  32. Spaces in Identifiers private fun String.`fromCamelCaseTo space separated`() = this.replace(wordBoundaryRegex,

    "$1 $2") private val wordBoundaryRegex = Regex("(\\p{Ll})(\\p{Lu})") @Test fun `an author cannot edit another author's submission`() … Too jokey & makes client code hard to read Ok – test names act as documentation in IDE views & no client code This limited use case is now supported by IntelliJ's rename refactoring.
  33. Adoption in other projects • Kotlin has spread very quickly

    throughout the organisation • Java, Scala and Python developers pick it up very quickly • People have just been writing code with no respect for our authoritah!
  34. Summary Don't go overboard with Kotlin's "cool" features You can

    start right away! No need for a big-bang port Be aware of how Kotlin differs from Java
  35. Summary •Simultaneously impressed and horrified •Addictive •I wouldn’t hesitate to

    take a Scala contract •Starting a Scala project is less clear-cut •You can live without cutting edge IDE support (?) •Suitable for a bleeding edge team •Is something simpler trying to get out?
  36. The beginning... (who are hiring in London & Berlin) https://www.springernature.com/gp/group/careers

    Thanks to