Be the Code You Want to See: Domain-Specific Languages

This series takes a deeper look at how our engineers build digital financial services. Learn more about how they address large-scale technical challenges at Tala.


By: M. Silva, Lead Android Engineer

As software systems become more complex, it becomes increasingly difficult to write and maintain code that is efficient, reliable, and scalable. And, as a byproduct, code duplication can undermine the efficacy of your code base. 

Let’s look at creating Retrofit instances as an example. In our code base, we had attempted to streamline this using abstract classes and other typical object-oriented techniques; the result was still verbose and duplicated code! So I put my thinking cap on and analyzed what was exactly the same and what was different between all of them. They all had the following things in common. They added a base URL, call adapter factory, converter factory, and an OkHttpClient instance. For the OkHttpClient instance, they all set timeouts, added a logging interceptor in debug mode, and added auth headers. After setting all of that configuration, the instance would be created and ready to use. It looked something like this:

val sessionInterceptor = Interceptor { chain ->
    val request = chain.request().newBuilder()
        .addHeader(HEADER_SESSION_ID, "session-id")
        .addHeader(HEADER_USER_ID, "user-id")
        .build()
    chain.proceed(request)
}
val builder = OkHttpClient.Builder()
    .retryOnConnectionFailure(true)
    .addInterceptor(sessionInterceptor)
    .readTimeout(DEFAULT_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
    .connectTimeout(DEFAULT_CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
    builder.addInterceptor(
        HttpLoggingInterceptor()
            .setLevel(HttpLoggingInterceptor.Level.BODY)
    )
}
val okHttpClient = builder.build()
val api: Api = Retrofit.Builder()
    .baseUrl("https://0.0.0.0")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .client(okHttpClient)
    .build()
    .create(Api::class.java)

I think it’s safe to say that’s a lot of boilerplate and ceremony. In our application, this would be then replicated, to some degree, for each backend service that we rely on, which is quite a few. 

I wanted a solution that would reduce code duplication, easily reuse common settings and easily override the common settings. After some experimentation, I decided that a domain-specific language (DSL) might be a good fit for solving this problem. 

The Benefits of Domain-Specific Language 

If you haven’t heard the term before, you’ve probably seen the technique used in some capacity. Wikipedia describes a DSL as a language created specifically to solve problems in a particular domain or business area and not intended to solve problems outside of it. For reference, if you’re a Java/Kotlin/Android developer using Gradle as a build tool, you’ll have used DSLs when configuring most plug-ins.

DSLs have a number of benefits; they’re declarative, composable, and not concerned with implementation details. Because of this, they tend to be easier to read, and they tend to reduce the overall cognitive load on the writer as well as future readers of the code.

In Kotlin, DSLs are primarily achieved with extension functions — lambdas with receivers and some kind of builder pattern. Luckily for me, both Retrofit and OkHttp provide builders for their classes, so there wasn’t much to do there. All I needed was to decide what to include in my Retrofit/OkHttp DSL. After a few iterations attempting to clean up the above code, what I ended up with looked like this:

buildRetrofit {
  baseUrl("https://0.0.0.0")
  addGsonConverterFactory()
  addRxJava2CallAdapterFactory()
  setHttpClient {
    addRequestInterceptor { addSessionHeaders(sessionInfoProvider) }
    retryOnConnectionFailure(true)
    applyStandardTimeouts()
    if (BuildConfig.DEBUG) addLoggingInterceptor()
  }
} 

At a glance, it doesn’t seem like much, but you would probably agree that it is much more straightforward to parse through in this form. 

Let’s look at the signatures for the DSL methods used above.

inline fun <reified T> buildRetrofit(builder: Retrofit.Builder.() -> Unit): T

inline fun Retrofit.Builder.addGsonConverterFactory(
  builder: GsonBuilder.() -> Unit = {}
)

fun Retrofit.Builder.addRxJava2CallAdapterFactory()

inline fun Retrofit.Builder.setHttpClient(
    client: OkHttpClient = buildOkHttpClient(),
    builder: OkHttpClient.Builder.() -> Unit = {},
)

inline fun OkHttpClient.Builder.addRequestInterceptor(
    crossinline urlBuilder: HttpUrl.Builder.() -> Unit = {},
    crossinline requestBuilder: Request.Builder.() -> Unit = {},
)

fun OkHttpClient.Builder.applyStandardTimeouts(
  configure: TimeoutSettings.() -> Unit = {}
)

inline fun OkHttpClient.Builder.addLoggingInterceptor(
  builder: HttpLoggingInterceptor.() -> Unit = {}
)

In case it isn’t obvious how to use them by the signature alone, let’s quickly go through them one-by-one:

  • buildRetrofit lets you configure a Retrofit.Builder and then builds and creates an instance of type T.
  • addGsonConverterFactory does as its name implies and optionally allows configuration of the Gson instance it uses internally.
  • addRxJava2CallAdapterFactory does as its name implies, nothing more.
  • setHttpClient allows a pre-configured client to be passed in and further customized. If no client is passed in, a shared instance is provided.
  • addRequestInterceptor adds a single interceptor that allows modification of the request URL and/or the request itself. The first parameter allows the URL to be modified for things like adding query parameters. The second parameter allows for the request to be modified for things like adding headers.
  • applyStandardTimeouts sets timeout according to our applications convention. It uses a custom builder object with our conventional defaults set and the caller can change these values in the lambda if necessary, e.g., applyStandardTimeouts { writeTimeout = CustomTimeout(seconds = 15) }.
  • addLoggingInterceptor adds the logging interceptor with the logging level set to our application’s convention which can be changed in the lambda.

As you can see, most of them take a lambda using a builder as the receiver to allow further customization if necessary, but, since they use default parameters, they can be used without specifying the lambdas to get the most common configuration. You could even create new functions that combine several existing functions together if it would make sense to do so. This approach worked exceptionally for my use case because it was very easy to set up all Retrofit instances in virtually the same way with a few minor exceptions. It didn’t require any inheritance or some opaque mechanism to pre-configure settings. It also had the added benefit of limiting direct dependencies on OkHttp and Retrofit types by users of the DSL while still allowing for low-level customization if necessary.

DSLs offer a powerful tool for software engineers and developers to solve complex problems in specific domains. By providing a language tailored to a specific application, DSLs can help to reduce errors, increase productivity, and improve collaboration between domain experts and software developers through increased readability. With this technique, the set-up code reads the way that you want it to. You have total control over what it looks like and how it is used. Hopefully, with this case study, you are tempted to find patterns in your code base and write some DSLs of your own.