Skip to content
Go back

Two Racing Lines: Annotations and the DSL in NetFlow 0.7.0

by KMP Bits

KMP Bits Cover

Every circuit has a second line painted on it. It runs alongside the main straight, behind a wall, with a speed limiter engaged and a white line at the exit you are not allowed to cross. The pit lane. It is slower than the track it parallels, it is narrower, and it is identical for every car on the grid: same entry, same limiter, same sequence of jacks and guns and a lollipop. Nobody has ever set a lap record in there.

Every car still uses it. Tyres, fuel, a new nose, a driver change. The routine work of a race happens in the one place on the circuit where being routine is the entire point. The rest of the lap, the part where the corners actually decide the race, is driven on the other line.

Two lines, one car, one set of tyres. You move between them without changing anything about the machine.


I have been building NetFlow, my Kotlin Multiplatform networking library, since it was an Android-only thing called Communication. I wrote about that migration and why I took it on in Why I Took the Leap from Android-Only to Kotlin Multiplatform, so I will not repeat it here. Version 0.6 I added a transform parameter. The compiler forces you to provide whenever your DTO and your domain model are different types. Forget it and the build fails. No runtime surprise, no half-mapped object reaching the UI.

Then I started a new data layer in a side project, opened an empty file, and typed @GET.

Into my own library. Which did not have annotations.

That is a small, stupid moment, and it told me something I had been avoiding. NetFlow has three stars on GitHub and, as far as I know, no users outside my own projects, so I cannot tell you what evaluators said about it. I can tell you what my own hands did when I was not paying attention.


The reflex

Years of Retrofit built a motor pattern into every Android developer I know, including me. You want an endpoint, you write an interface, you put @GET("users/{id}") on a method. It is not a decision. It is closer to reaching for the indicator stalk.

Here is what fetching a list looked like in NetFlow 0.6:

// commonMain
val todos = client.call {
    path = "todos"
    method = HttpMethod.Get
}.responseListFlow<TodoDto, Todo>(transform = { it.toModel() })

Read that with fresh eyes. It says exactly what it does, it is one expression, and the mapping is compiler-checked. It is also nothing like what muscle memory reaches for.

A developer evaluating a networking library gives it about ninety seconds of README. In that window, “does this look like the thing I already know” beats “is this better” almost every time. I don’t think that is laziness. Familiarity is a real cost saving when you are picking one of five libraries that all do roughly the same job.

So the 0.7.0 question was not “annotations or DSL”. It was: can I put a familiar entry on the front without weakening the thing behind it?

What came out is a ladder with three rungs. The bottom rung is an annotated interface and covers most of what you write in a day. The middle rung annotates the request and hands the response back to you. The top rung is the DSL, unchanged, for the calls that need everything. You climb only as far as the call in front of you requires.


Rung one: the return type picks the strategy

The annotation layer lives in two new modules, netflow-annotations and netflow-ksp. You annotate an interface, KSP generates the implementation, and a factory extension hands it to you.

// commonMain
@NetFlowApi
interface TodoApi {

    @GET("todos")
    suspend fun getTodos(@Query completed: Boolean?): AsyncState<List<TodoDto>>

    @GET("todos/{id}")
    fun observeTodo(@Path id: Int): Flow<ResultState<TodoDto>>

    @Headers("Accept: application/json", "X-Client: netflow")
    @POST("todos")
    suspend fun create(@Body request: CreateTodoRequest): AsyncState<TodoDto>

    @DELETE("todos/{id}")
    suspend fun delete(@Path id: Int): AsyncState<Unit>

    @GET("todos")
    fun pagedTodos(): Flow<PagingData<TodoDto>>
}

val api = client.createTodoApi()

The part I want to point at is that there is no @Strategy annotation anywhere. The return type does that job.

AsyncState<T> on a suspend function means a one-shot call. Flow<ResultState<T>> on a non-suspend function means an observable call with loading and error states. Flow<PagingData<T>> means a Paging 3 flow. List<T> inside any of those routes to the list variants. The processor reads the shape and picks responseAsync, responseListAsync, responseFlow, responseListFlow, or responsePaginated accordingly.

This is not a clever trick, it is consistency. Flow<ResultState<T>> already self-describes in the hand-written DSL. Making the annotation layer read the same signal means there is one fact to learn instead of two.

The processor is strict about mismatches, and the errors are compile-time:

Function 'getTodos' returns AsyncState and must be suspend.
Function 'observeTodo' path declares '{id}' but there is no @Path parameter named 'id'.
Function 'create' has more than one @Body; at most one @Body is allowed.

@Path and @Query and @Header take their wire name from the parameter name unless you override it, a nullable @Query or @Header is omitted from the request when it is null, and at most one @Body is allowed per function.


What the processor actually writes

This matters more than it sounds, so here is the generated file for two of those methods, copied out of build/generated:

// _TodoApiImpl.kt, generated
internal class _TodoApiImpl(
  private val client: NetFlowClient,
) : TodoApi {
  override suspend fun getTodos(completed: Boolean?): AsyncState<List<TodoDto>> = client.call {
    method = HttpMethod.Get
    path = "todos"
    if (completed != null) parameter("completed" to completed)
  }
  .responseListAsync<TodoDto>()

  override fun observeTodo(id: Int): Flow<ResultState<TodoDto>> = client.call {
    method = HttpMethod.Get
    path = "todos/" + id
  }
  .responseFlow<TodoDto>()
}

public fun NetFlowClient.createTodoApi(): TodoApi = _TodoApiImpl(this)

That is the 0.6 DSL. Byte for byte, it’s the code you would have written by hand. The annotations are not a parallel implementation, they are a text expansion into the existing one. Nothing new runs at runtime, there is no reflection, and there is no second code path to keep in sync when the DSL changes.

It also means you can open the file and read it.


The decision that matters: no transform in annotations

Now the constraint that shaped everything else.

An annotation cannot hold a lambda. Kotlin annotation parameters are limited to compile-time constants, so transform = { it.toModel() } has nowhere to live. Neither does onNetworkSuccess { db.insert(it) }, or local { observe { db.todos() } }, or a paging RemoteMediator.

The mapping problem has known workarounds and I looked at all of them. @MappedBy(TodoMapper::class) pointing at an object with a map function. A converter registry you populate at client construction. An interface the DTO implements. Every one of them takes a relationship the 0.6 compiler verifies and moves it somewhere the compiler cannot see, resolved by KSP at best and by a map lookup at worst. Forgetting a mapper would stop being a build error and start being a runtime cast exception on a device.

I was not going to make the convenience layer less safe than the thing it is a convenience for. So annotated methods return the DTO. Full stop.

The mapping moves one layer out, into the repository, using the .map helpers NetFlow already ships. Their KDoc has said the same thing since 0.1: usually used in the repository to map the dto to the model.

// commonMain
class TodoApiRepositoryImpl(client: NetFlowClient) {

    private val api = client.createTodoApi()

    suspend fun getTodos(): AsyncState<List<Todo>> =
        api.getTodos(completed = null).map { dtos -> dtos.map { it.toModel() } }

    fun observeTodo(id: Int): Flow<ResultState<Todo>> =
        api.observeTodo(id).map { state -> state.map { it.toModel() } }

    fun pagedTodos(): Flow<PagingData<Todo>> =
        api.pagedTodos().map { page -> page.map { it.toModel() } }
}

The mapping is still explicit, still typed, still checked by the compiler. It moved. It did not weaken.

If there is one transferable idea in this whole article, it’s that one. When you build a convenience layer over a stricter API, the layer is allowed to be less capable. It’s not allowed to be less safe. The moment it is, every user of the convenience layer is quietly worse off than the users who ignored it, and you have built a trap with good ergonomics.


The paged endpoint is one line

If there is a single place where this layering pays for itself, it’s paging.

An annotation library that only speaks HTTP gets you as far as the DTO. After that you write a PagingSource: a load that does the key arithmetic, a getRefreshKey that reads state.anchorPosition and reaches for the closest page, a LoadResult branch for success and one for failure, then a Pager and a PagingConfig to assemble it. That is fifty lines you have written before, and fifty more for the next paged endpoint, because none of this is about your data.

In NetFlow you only need a PagingData return type.

// commonMain
@GET("todos")
fun pagedTodos(): Flow<PagingData<TodoDto>>

@Paginated(pageSize = 15)
@GET("todos")
fun pagedTodosSmall(): Flow<PagingData<TodoDto>>

@Paginated is optional, and it carries only the two things: the query parameter name for the page number, and the page size. Leave it off and you get page and 20.

The DTO extends PagingModel, which is where the page number and the last-updated timestamp live:

@Serializable
data class TodoDto(
    val userId: Int,
    val id: Int,
    val title: String,
    val completed: Boolean
) : PagingModel()

Forget it and the build stops with Flow<PagingData<T>> requires T to extend PagingModel. Put @Paginated on a method that returns something else and the build stops there too. Neither is a runtime discovery.

The repository maps exactly like every other annotated call, and the ViewModel does what it always does:

// commonMain
fun pagedTodos(): Flow<PagingData<Todo>> =
    api.pagedTodos().map { page -> page.map { it.toModel() } }

val todos = repository.pagedTodos().cachedIn(viewModelScope)

That flow is declared in commonMain, and this is the part with no Ktorfit equivalent at all. On Android you collect it with collectAsLazyPagingItems(). On iOS, netflow-paging ships PagingCollectionViewController, a Kotlin class exposing loadStateFlow, onPagesUpdatedFlow, getItems() and loadNextPage() to Swift through SKIE, so a SwiftUI list drives the same pager. One annotated method, both platforms, no per-platform paging code.

Now the boundary. An annotated paged method is network-only. The generated call says so:

// generated
.responsePaginated<TodoDto, TodoDto> {
  onlyApiCall = true
  defaultPageSize = 15
}

Remote plus local paging needs a PagingSource factory, an insertAll block, and a timestamp lambda. Three lambdas. So it stays where lambdas live, and getting there is the next rung.


Rung two: NetFlowCall

That leaves an obvious hole. What about an endpoint whose request is pure boilerplate, path and method and three query params, but whose response needs a local cache?

Under a strict reading of rung one you drop back to the full DSL and hand-write the request too, which means the annotation layer is useless for exactly the calls that have the most typing in them.

0.7.0 splits the call in half. An annotated method can return NetFlowCall, which is the request with no response strategy attached:

// commonMain
@GET("todos")
fun todosCall(): NetFlowCall

The generated implementation stops at the request:

// generated
override fun todosCall(): NetFlowCall = client.prepareCall {
  method = HttpMethod.Get
  path = "todos"
}

And the caller composes the response side with the whole DSL available, transform requirement included:

// commonMain
fun getTodos(): Flow<ResultState<List<Todo>>> =
    api.todosCall().responseListFlow<TodoDto, Todo>(transform = { it.toModel() }) {

        onNetworkSuccess { dtos ->
            database.todoQueries.transaction {
                dtos.forEach { database.todoQueries.insertTodo(it.toEntity()) }
            }
        }

        local(
            { observe { database.todoQueries.selectTodos() } },
            transform = { entities -> entities.map { it.toModel() } }
        )
    }

The annotation owns what annotations are good at: path, method, query parameters, headers, body. The repository owns what only a lambda can express.

Because the response strategy now belongs to the caller, @Wrapped on a NetFlowCall method is a compile error, with a message that says why:

Function 'todosCall' returns NetFlowCall — @Wrapped is the caller's
response-strategy choice, not the interface's.

@Paginated fails the same way, since it requires a Flow<PagingData<T>> return type it will never see. I would rather refuse the annotation than silently pick a strategy on someone’s behalf.

prepareCall { } is public, so hand-written DSL users get the same split without touching annotations.


Rung three: the call {} DSL

The top rung is unchanged. Remote plus local paging, localQuery with its count and items and invalidation lambdas, retry policies with a retryOn predicate, per-request headers computed at call time, onlyLocalCall for offline reads. The paged call from two sections ago, with a database under it, looks like this:

// commonMain
@OptIn(ExperimentalPagingApi::class)
override fun getTodos(): Flow<PagingData<Todo>> = client.call {
    path = "todos"
}.responsePaginated<TodoDto, Todo> {
    localSource(
        pagingSource = { TodoPagingSource(database) },
        transform = { it.toModel() }
    )

    deleteOnRefresh = false
    insertAll(transform = { it.toEntity() }) { todos ->
        database.todoQueries.transaction {
            database.todoQueries.deleteTodos()
            todos.forEach { database.todoQueries.insertTodo(it) }
        }
    }

    firstItemDatabase(
        itemDatabase = { database.todoQueries.getFirstTodo().executeAsOneOrNull() },
        timestamp = { it.lastUpdatedTimestamp }
    )
}

There is no annotation on earth that expresses that, and I did not try to invent one. The top of the ladder is supposed to look like this.


Three rungs, or three libraries

The alternative to layering is what most projects already do. Ktorfit for the HTTP calls, Store or something hand-rolled for the cache, Paging wired up separately with its own RemoteMediator. It works. I have shipped it.

What it costs is coherence. Three libraries mean three sets of state types that don’t compose, three failure models, and three test setups. Your Ktorfit call throws, your cache layer returns a sealed class, your paging layer emits LoadState, and the repository is the place where you write the glue that reconciles all of it. Then you write a MockEngine for one, a fake for another, and an in-memory database for the third.

In NetFlow the three rungs return the same ResultState, AsyncState, and PagingData types. They run on the same NetFlowClient. And they are tested with the same MockNetFlowClient, which intercepts requests without any network at all:

@Test
fun `delete removes item from local database`() = runTest {
    val mockClient = MockNetFlowClient { _ -> NetFlowMockResponse.success() }
    val repository = TodoRepositoryImpl(mockClient, database)

    repository.deleteTodo(id = 1)

    mockClient.assertCalled("todos/1", HttpMethod.Delete)
}

That test doesn’t care which rung the repository used. Swap getTodos from an annotated method to a call {} block and the test still passes, because the boundary is the client, not the call style.

Now the honest part. If you just want to use annotations, Ktorfit beats this comfortably. It has years of production use, multipart and form-data, streaming, response converters, a much larger vocabulary, and a community that has already hit the bugs you are about to hit. NetFlow 0.7.0 has a first pass and a roadmap item that still says multipart. If HTTP is genuinely all you need, use Ktorfit.

The exception is the one I spent a whole section on. No annotation layer hands you a Flow<PagingData<T>> from commonMain, because that requires the paging integration to already be part of the same library. That is not me out-annotating Ktorfit, it’s the stack underneath the annotations showing through, which is the only bet I am actually making.


Generating into commonMain with KSP

This is the part I found least documented, so here it is in full.

The processor emits one file per annotated interface into the common metadata source set, and every target then compiles that file as ordinary common code. On the consumer side it is four pieces of wiring:

// build.gradle.kts
plugins {
    alias(libs.plugins.ksp)
}

kotlin {
    sourceSets {
        commonMain {
            kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
        }
        commonMain.dependencies {
            implementation("io.github.kmpbits:netflow-annotations:<latest_version>")
        }
    }
}

dependencies {
    add("kspCommonMainMetadata", "io.github.kmpbits:netflow-ksp:<latest_version>")
}

tasks.withType<KotlinCompilationTask<*>>().configureEach {
    if (name != "kspCommonMainKotlinMetadata") {
        dependsOn("kspCommonMainKotlinMetadata")
    }
}

The srcDir line and the dependsOn block are the two that bite. Without srcDir, the generated file exists on disk and no source set can see it. Without the task dependency, your Android compilation happily starts before KSP has produced anything, and you get an unresolved reference to createTodoApi that disappears on the second build. That intermittent-failure flavour is the worst kind to debug, which is exactly why it is worth ten lines of Gradle.

One detail worth mention: netflow-ksp has no dependency on netflow-paging. The processor emits responsePaginated as a fully qualified name string and lets the consumer’s own classpath resolve it. If you never return Flow<PagingData<T>>, you never pull in Paging 3, and the annotation module stays a pure Kotlin Multiplatform artifact with no Android-flavoured transitive weight.

I picked KSP over a compiler plugin for one reason: the output is a plain .kt file you can open. When a generated call sends the wrong query parameter, you go look at the generated line and see it. A compiler plugin gives you IR, a stack trace, and a bad afternoon. Ktorfit itself ran on KSP for years and only moved to a compiler-plugin-only model after it had the user base to absorb that opacity. For a library at 0.7.0 with three stars, readable output is worth more than whatever the plugin route buys.


The line I hold

Adding a familiar surface to an unfamiliar API is not a compromise, as long as the surface refuses to lie about what is underneath it. Annotations in NetFlow do not wrap the DSL, they generate it, they return the same types, they run on the same client, they break at compile time when you ask them for something they cannot do, and they stop at the exact line where a lambda becomes necessary. That stopping point is the design, not a gap in it.

A layer that quietly did less checking so it could cover more cases would be a worse library with a better README.

This is 0.7.0, and the honest framing is that I have no external users yet. The annotation surface may well shift in 0.8 or 0.9 once somebody who is not me tries to model an API with it. Read the code samples here as 0.7.0, not as a promise.

But the shape I would defend regardless of version: put the familiar entry on the front, keep the harder API reachable from it without changing cars, and be loud about where one ends and the other begins.

Pit lane or full circuit, it is the same car. 🏁


The library is available on GitHub.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Torque Spec: Shared Form State and Validation in KMP