Are there any common practices for making code tha...
# arrow
s
Are there any common practices for making code that uses Arrow easier to read? Maybe some linter rules? This would be for improving the dev experience for devs who aren't accustomed to Arrow, or similar frameworks.
t
I’ve had this discussion with my team recently and we are converging on a particular style. I can share some of the discussion and code examples if that is helpful (no linter though so its all convention). I also would appreciate feedback!
Copy code
Tried to refactor this in a way that

Respects a consistent level of abstraction in each function
Keeps the "top-level" abstraction declarative and for the most part hiding the sad path (though I did leave in a single mapErrors fn in each call
Hides the "glue" or "adapter" functions that make all the different types fit together.
I'm currently wrestling with the idea that

Arrow and FP is good and provides super generic good tools like Either, map, flatMap, etc. that we can use to implement pretty much anything in a Good Way.
Those same tools are "low level" and distracting when reading the code
So what I'm messing around with is trying to keep the main bits of behavior in each file/class at a high level of abstraction (of course, what "high-level abstraction" means depends on the context. In this example its high level to talk about HTTP responses and JSON. In a Payment service, that would be considered low level). We do this by adding a bunch of private extension functions that hide the "glue" which is normally needed to fit all these different types together while giving them expressive and clear names (approaching a DSL, yay!), and keeping the functions small enough that they are easy to update and reason about in isolation (hopefully, its what I was going for).

Another way of thinking about it is that we are taking these great super generic lego pieces and fitting them together into larger (though still modular) "solutions" that we paint with concrete function names before finally composing those concrete pieces together at the "top"
Before
Copy code
class AffirmClient(
    private val httpClient: HttpClient,
    // @Value("\${affirm.au.private_key}")
    private val affirmAuPrivateKey: String,
    // @Value("\${affirm.au.public_key}")
    private val affirmAuPublicKey: String,
    // @Value("\${affirm.au.hostname}")
    private val affirmAuApiUrl: String,
) {
    private val logger = KotlinLogging.logger { }

    suspend fun authorizeTransaction(
        partnerPaymentMethodId: String,
        metadata: Metadata,
    ): Either<AuthedError, AuthorizeTransactionResponse> =
        Either.catch {
            httpClient.postJson(
                url = "${affirmAuApiUrl}api/v1/transactions",
                request = AuthorizeTransactionRequest(
                    transactionId = partnerPaymentMethodId,
                    orderId = metadata.orderId,
                ),
                auth = getAuthHeader(),
            )
        }.mapLeft {
            AuthedError.AuthFailureError(it.message)
        }.flatMap {
            <http://logger.info|logger.info> { "AffirmClient.authorizeTransaction response: ${it.body}" }

            if (it.code == 200) {
                AuthorizeTransactionResponse.fromHTTPBody(it.body).right()
            } else {
                AuthedError.AuthFailureError(message = it.body.optString("message").toString()).left()
            }
        }

    suspend fun captureTransaction(
        authorizationToken: String
    ): Either<CapturedError, CaptureTransactionResponse> =
        Either.catch {
            httpClient.postJson(
                url = "${affirmAuApiUrl}api/v1/transactions/$authorizationToken/capture",
                auth = getAuthHeader(),
            )
        }.mapLeft {
            CapturedError.CaptureActionError(it.message)
        }.flatMap {
            <http://logger.info|logger.info> { "AffirmClient.captureTransaction response: ${it.body}" }

            if (it.code == 200) {
                CaptureTransactionResponse.fromHTTPBody(it.body).right()
            } else {
                CapturedError.CaptureActionError(
                    message = it.body.optString("message").toString()
                ).left()
            }
        }

    suspend fun refundTransaction(
        confirmationId: String,
        amount: Int,
    ): Either<RefundError, RefundTransactionResponse> =
        Either.catch {
            httpClient.postJson(
                url = "${affirmAuApiUrl}api/v1/transactions/$confirmationId/refund",
                request = RefundTransactionRequest(amount = amount),
                auth = getAuthHeader(),
            )
        }.mapLeft {
            RefundError.RefundFailureError(it.message)
        }.flatMap {
            <http://logger.info|logger.info> { "AffirmClient.refundTransaction response: ${it.body}" }

            if (it.code == 200) {
                RefundTransactionResponse.fromHTTPBody(it.body).right()
            } else {
                RefundError.RefundFailureError(
                    message = it.body.optString("message").toString()
                ).left()
            }
        }

    suspend fun voidTransaction(
        confirmationId: String
    ): Either<VoidError, Unit> =
        Either.catch {
            httpClient.postJson(
                url = "${affirmAuApiUrl}api/v1/transactions/$confirmationId/void",
                auth = getAuthHeader(),
            )
        }.mapLeft {
            VoidError.VoidFailureError(it.message)
        }.flatMap {
            <http://logger.info|logger.info> { "AffirmClient.voidTransaction response: ${it.body}" }

            if (it.code == 200) {
                Unit.right()
            } else {
                VoidError.VoidFailureError(
                    message = it.body.optString("message").toString()
                ).left()
            }
        }

    private fun getAuthHeader(): HttpHeader =
        HttpHeader(key = affirmAuPublicKey, value = affirmAuPrivateKey)
}
After
Copy code
class AffirmClient(
    private val httpClient: HttpClient,
    private val affirmAuPrivateKey: String,
    private val affirmAuPublicKey: String,
    private val affirmAuApiUrl: String,
) {
    @Loggable
    suspend fun authorizeTransaction(
        partnerPaymentMethodId: String,
        metadata: Metadata,
    ): Either<AuthedError, AuthorizeTransactionResponse> =
        <http://httpClient.post|httpClient.post>(
            url = "${affirmAuApiUrl}api/v1/transactions",
            request = AuthorizeTransactionRequest(
                transactionId = partnerPaymentMethodId,
                orderId = metadata.orderId,
            ),
            auth = getAuthHeader(),
        )
            .mapAuthErrors()
            .parseAuthorizeResponse()

    @Loggable
    suspend fun captureTransaction(
        authorizationToken: String
    ): Either<CapturedError, CaptureTransactionResponse> =
        <http://httpClient.post|httpClient.post>(
            url = "${affirmAuApiUrl}api/v1/transactions/$authorizationToken/capture",
            auth = getAuthHeader(),
        )
            .mapCaptureErrors()
            .parseCaptureResponse()

    @Loggable
    suspend fun refundTransaction(
        confirmationId: String,
        amount: Int,
    ): Either<RefundError, RefundTransactionResponse> =
        <http://httpClient.post|httpClient.post>(
            url = "${affirmAuApiUrl}api/v1/transactions/$confirmationId/refund",
            request = RefundTransactionRequest(amount = amount),
            auth = getAuthHeader(),
        )
            .mapRefundErrors()
            .parseRefundResponse()

    @Loggable
    suspend fun voidTransaction(
        confirmationId: String
    ): Either<VoidError, Unit> =
        <http://httpClient.post|httpClient.post>(
            url = "${affirmAuApiUrl}api/v1/transactions/$confirmationId/void",
            auth = getAuthHeader(),
        )
            .mapVoidErrors()
            .parseVoidResponse()

    private fun getAuthHeader(): HttpHeader =
        HttpHeader(key = affirmAuPublicKey, value = affirmAuPrivateKey)
}

@Loggable
private fun <http://HttpClient.post|HttpClient.post>(
    url: String,
    request: HttpRequest? = null,
    auth: HttpHeader? = null,
): Either<Throwable, HttpResponse> =
    Either.catch {
        this.postJson(
            url = url,
            request = request,
            auth = auth,
        )
    }

private fun <B> Either<Throwable, B>.mapAuthErrors() =
    this.mapLeft { AuthedError.AuthFailureError(it.message) }

private fun Either<AuthedError, HttpResponse>.parseAuthorizeResponse() = this.flatMap {
    when (it.code == 200) {
        true ->
            AuthorizeTransactionResponse
                .fromHTTPBody(it.body)
                .right()
        false -> AuthedError.AuthFailureError(
            message = it.body.optString("message").toString()
        ).left()
    }
}

private fun <B> Either<Throwable, B>.mapCaptureErrors() =
    this.mapLeft { CapturedError.CaptureActionError(it.message) }

private fun Either<CapturedError, HttpResponse>.parseCaptureResponse() = this.flatMap {
    when (it.code == 200) {
        true ->
            CaptureTransactionResponse
                .fromHTTPBody(it.body)
                .right()
        false -> CapturedError.CaptureActionError(
            message = it.body.optString("message").toString()
        ).left()
    }
}

private fun <B> Either<Throwable, B>.mapRefundErrors() =
    this.mapLeft { RefundError.RefundFailureError(it.message) }

private fun Either<RefundError, HttpResponse>.parseRefundResponse() = this.flatMap {
    when (it.code == 200) {
        true ->
            RefundTransactionResponse
                .fromHTTPBody(it.body)
                .right()
        false -> RefundError.RefundFailureError(
            message = it.body.optString("message").toString()
        ).left()
    }
}

private fun <B> Either<Throwable, B>.mapVoidErrors() =
    this.mapLeft { VoidError.VoidFailureError(it.message) }

private fun Either<VoidError, HttpResponse>.parseVoidResponse() = this.flatMap {
    when (it.code == 200) {
        true -> Unit.right()
        false -> VoidError.VoidFailureError(
            message = it.body.optString("message").toString()
        ).left()
    }
}
s
I can’t discern if that approach scales well or not. It’s definitely a good option.