<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged ksp</title>
<link>https://coderlegion.com/tag/ksp</link>
<description>Powered by Question2Answer</description>
<item>
<title>Two Racing Lines: Annotations and the DSL in NetFlow 0.7.0</title>
<link>https://coderlegion.com/27052/two-racing-lines-annotations-and-the-dsl-in-netflow-0-7-0</link>
<description>&lt;p&gt;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 &lt;a rel=&quot;nofollow&quot; href=&quot;/posts/netflow-part1&quot;&gt;Why I Took the Leap from Android-Only to Kotlin Multiplatform&lt;/a&gt;, so I will not repeat it here. Version 0.6 I added a &lt;code&gt;transform&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;Then I started a new data layer in a side project, opened an empty file, and typed &lt;code&gt;@GET&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Into my own library. Which did not have annotations.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-reflex&quot;&gt;The reflex&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;@GET(&quot;users/{id}&quot;)&lt;/code&gt; on a method. It is not a decision. It is closer to reaching for the indicator stalk.&lt;/p&gt;
&lt;p&gt;Here is what fetching a list looked like in NetFlow 0.6:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
val todos = client.call {
    path = &quot;todos&quot;
    method = HttpMethod.Get
}.responseListFlow&amp;lt;TodoDto, Todo&amp;gt;(transform = { it.toModel() })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;A developer evaluating a networking library gives it about ninety seconds of README. In that window, &quot;does this look like the thing I already know&quot; beats &quot;is this better&quot; 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.&lt;/p&gt;
&lt;p&gt;So the 0.7.0 question was not &quot;annotations or DSL&quot;. It was: can I put a familiar entry on the front without weakening the thing behind it?&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-one-the-return-type-picks-the-strategy&quot;&gt;Rung one: the return type picks the strategy&lt;/h3&gt;
&lt;p&gt;The annotation layer lives in two new modules, &lt;code&gt;netflow-annotations&lt;/code&gt; and &lt;code&gt;netflow-ksp&lt;/code&gt;. You annotate an interface, KSP generates the implementation, and a factory extension hands it to you.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@NetFlowApi
interface TodoApi {

    @GET(&quot;todos&quot;)
    suspend fun getTodos(@Query completed: Boolean?): AsyncState&amp;lt;List&amp;lt;TodoDto&amp;gt;&amp;gt;

    @GET(&quot;todos/{id}&quot;)
    fun observeTodo(@Path id: Int): Flow&amp;lt;ResultState&amp;lt;TodoDto&amp;gt;&amp;gt;

    @Headers(&quot;Accept: application/json&quot;, &quot;X-Client: netflow&quot;)
    @POST(&quot;todos&quot;)
    suspend fun create(@Body request: CreateTodoRequest): AsyncState&amp;lt;TodoDto&amp;gt;

    @DELETE(&quot;todos/{id}&quot;)
    suspend fun delete(@Path id: Int): AsyncState&amp;lt;Unit&amp;gt;

    @GET(&quot;todos&quot;)
    fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;
}

val api = client.createTodoApi()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The part I want to point at is that there is no &lt;code&gt;@Strategy&lt;/code&gt; annotation anywhere. The return type does that job.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AsyncState&amp;lt;T&amp;gt;&lt;/code&gt; on a suspend function means a one-shot call. &lt;code&gt;Flow&amp;lt;ResultState&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; on a non-suspend function means an observable call with loading and error states. &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; means a Paging 3 flow. &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; inside any of those routes to the list variants. The processor reads the shape and picks &lt;code&gt;responseAsync&lt;/code&gt;, &lt;code&gt;responseListAsync&lt;/code&gt;, &lt;code&gt;responseFlow&lt;/code&gt;, &lt;code&gt;responseListFlow&lt;/code&gt;, or &lt;code&gt;responsePaginated&lt;/code&gt; accordingly.&lt;/p&gt;
&lt;p&gt;This is not a clever trick, it is consistency. &lt;code&gt;Flow&amp;lt;ResultState&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The processor is strict about mismatches, and the errors are compile-time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Path&lt;/code&gt; and &lt;code&gt;@Query&lt;/code&gt; and &lt;code&gt;@Header&lt;/code&gt; take their wire name from the parameter name unless you override it, a nullable &lt;code&gt;@Query&lt;/code&gt; or &lt;code&gt;@Header&lt;/code&gt; is omitted from the request when it is null, and at most one &lt;code&gt;@Body&lt;/code&gt; is allowed per function.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-the-processor-actually-writes&quot;&gt;What the processor actually writes&lt;/h3&gt;
&lt;p&gt;This matters more than it sounds, so here is the generated file for two of those methods, copied out of &lt;code&gt;build/generated&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// _TodoApiImpl.kt, generated
internal class _TodoApiImpl(
  private val client: NetFlowClient,
) : TodoApi {
  override suspend fun getTodos(completed: Boolean?): AsyncState&amp;lt;List&amp;lt;TodoDto&amp;gt;&amp;gt; = client.call {
    method = HttpMethod.Get
    path = &quot;todos&quot;
    if (completed != null) parameter(&quot;completed&quot; to completed)
  }
  .responseListAsync&amp;lt;TodoDto&amp;gt;()

  override fun observeTodo(id: Int): Flow&amp;lt;ResultState&amp;lt;TodoDto&amp;gt;&amp;gt; = client.call {
    method = HttpMethod.Get
    path = &quot;todos/&quot; + id
  }
  .responseFlow&amp;lt;TodoDto&amp;gt;()
}

public fun NetFlowClient.createTodoApi(): TodoApi = _TodoApiImpl(this)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;It also means you can open the file and read it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-decision-that-matters-no-transform-in-annotations&quot;&gt;The decision that matters: no transform in annotations&lt;/h3&gt;
&lt;p&gt;Now the constraint that shaped everything else.&lt;/p&gt;
&lt;p&gt;An annotation cannot hold a lambda. Kotlin annotation parameters are limited to compile-time constants, so &lt;code&gt;transform = { it.toModel() }&lt;/code&gt; has nowhere to live. Neither does &lt;code&gt;onNetworkSuccess { db.insert(it) }&lt;/code&gt;, or &lt;code&gt;local { observe { db.todos() } }&lt;/code&gt;, or a paging &lt;code&gt;RemoteMediator&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The mapping problem has known workarounds and I looked at all of them. &lt;code&gt;@MappedBy(TodoMapper::class)&lt;/code&gt; pointing at an object with a &lt;code&gt;map&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The mapping moves one layer out, into the repository, using the &lt;code&gt;.map&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
class TodoApiRepositoryImpl(client: NetFlowClient) {

    private val api = client.createTodoApi()

    suspend fun getTodos(): AsyncState&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt; =
        api.getTodos(completed = null).map { dtos -&amp;gt; dtos.map { it.toModel() } }

    fun observeTodo(id: Int): Flow&amp;lt;ResultState&amp;lt;Todo&amp;gt;&amp;gt; =
        api.observeTodo(id).map { state -&amp;gt; state.map { it.toModel() } }

    fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; =
        api.pagedTodos().map { page -&amp;gt; page.map { it.toModel() } }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mapping is still explicit, still typed, still checked by the compiler. It moved. It did not weaken.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-paged-endpoint-is-one-line&quot;&gt;The paged endpoint is one line&lt;/h3&gt;
&lt;p&gt;If there is a single place where this layering pays for itself, it's paging.&lt;/p&gt;
&lt;p&gt;An annotation library that only speaks HTTP gets you as far as the DTO. After that you write a &lt;code&gt;PagingSource&lt;/code&gt;: a &lt;code&gt;load&lt;/code&gt; that does the key arithmetic, a &lt;code&gt;getRefreshKey&lt;/code&gt; that reads &lt;code&gt;state.anchorPosition&lt;/code&gt; and reaches for the closest page, a &lt;code&gt;LoadResult&lt;/code&gt; branch for success and one for failure, then a &lt;code&gt;Pager&lt;/code&gt; and a &lt;code&gt;PagingConfig&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;In NetFlow you only need a &lt;code&gt;PagingData&lt;/code&gt; return type.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@GET(&quot;todos&quot;)
fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;

@Paginated(pageSize = 15)
@GET(&quot;todos&quot;)
fun pagedTodosSmall(): Flow&amp;lt;PagingData&amp;lt;TodoDto&amp;gt;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Paginated&lt;/code&gt; 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 &lt;code&gt;page&lt;/code&gt; and 20.&lt;/p&gt;
&lt;p&gt;The DTO extends &lt;code&gt;PagingModel&lt;/code&gt;, which is where the page number and the last-updated timestamp live:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Serializable
data class TodoDto(
    val userId: Int,
    val id: Int,
    val title: String,
    val completed: Boolean
) : PagingModel()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Forget it and the build stops with &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt; requires T to extend PagingModel&lt;/code&gt;. Put &lt;code&gt;@Paginated&lt;/code&gt; on a method that returns something else and the build stops there too. Neither is a runtime discovery.&lt;/p&gt;
&lt;p&gt;The repository maps exactly like every other annotated call, and the ViewModel does what it always does:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
fun pagedTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; =
    api.pagedTodos().map { page -&amp;gt; page.map { it.toModel() } }

val todos = repository.pagedTodos().cachedIn(viewModelScope)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That flow is declared in &lt;code&gt;commonMain&lt;/code&gt;, and this is the part with no Ktorfit equivalent at all. On Android you collect it with &lt;code&gt;collectAsLazyPagingItems()&lt;/code&gt;. On iOS, &lt;code&gt;netflow-paging&lt;/code&gt; ships &lt;code&gt;PagingCollectionViewController&lt;/code&gt;, a Kotlin class exposing &lt;code&gt;loadStateFlow&lt;/code&gt;, &lt;code&gt;onPagesUpdatedFlow&lt;/code&gt;, &lt;code&gt;getItems()&lt;/code&gt; and &lt;code&gt;loadNextPage()&lt;/code&gt; to Swift through SKIE, so a SwiftUI list drives the same pager. One annotated method, both platforms, no per-platform paging code.&lt;/p&gt;
&lt;p&gt;Now the boundary. An annotated paged method is network-only. The generated call says so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// generated
.responsePaginated&amp;lt;TodoDto, TodoDto&amp;gt; {
  onlyApiCall = true
  defaultPageSize = 15
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remote plus local paging needs a &lt;code&gt;PagingSource&lt;/code&gt; factory, an &lt;code&gt;insertAll&lt;/code&gt; block, and a timestamp lambda. Three lambdas. So it stays where lambdas live, and getting there is the next rung.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-two-netflowcall&quot;&gt;Rung two: NetFlowCall&lt;/h3&gt;
&lt;p&gt;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?&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;0.7.0 splits the call in half. An annotated method can return &lt;code&gt;NetFlowCall&lt;/code&gt;, which is the request with no response strategy attached:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@GET(&quot;todos&quot;)
fun todosCall(): NetFlowCall
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The generated implementation stops at the request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// generated
override fun todosCall(): NetFlowCall = client.prepareCall {
  method = HttpMethod.Get
  path = &quot;todos&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the caller composes the response side with the whole DSL available, &lt;code&gt;transform&lt;/code&gt; requirement included:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
fun getTodos(): Flow&amp;lt;ResultState&amp;lt;List&amp;lt;Todo&amp;gt;&amp;gt;&amp;gt; =
    api.todosCall().responseListFlow&amp;lt;TodoDto, Todo&amp;gt;(transform = { it.toModel() }) {

        onNetworkSuccess { dtos -&amp;gt;
            database.todoQueries.transaction {
                dtos.forEach { database.todoQueries.insertTodo(it.toEntity()) }
            }
        }

        local(
            { observe { database.todoQueries.selectTodos() } },
            transform = { entities -&amp;gt; entities.map { it.toModel() } }
        )
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The annotation owns what annotations are good at: path, method, query parameters, headers, body. The repository owns what only a lambda can express.&lt;/p&gt;
&lt;p&gt;Because the response strategy now belongs to the caller, &lt;code&gt;@Wrapped&lt;/code&gt; on a &lt;code&gt;NetFlowCall&lt;/code&gt; method is a compile error, with a message that says why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Function 'todosCall' returns NetFlowCall — @Wrapped is the caller's
response-strategy choice, not the interface's.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Paginated&lt;/code&gt; fails the same way, since it requires a &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; return type it will never see. I would rather refuse the annotation than silently pick a strategy on someone's behalf.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;prepareCall { }&lt;/code&gt; is public, so hand-written DSL users get the same split without touching annotations.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rung-three-the-call-dsl&quot;&gt;Rung three: the call {} DSL&lt;/h3&gt;
&lt;p&gt;The top rung is unchanged. Remote plus local paging, &lt;code&gt;localQuery&lt;/code&gt; with its count and items and invalidation lambdas, retry policies with a &lt;code&gt;retryOn&lt;/code&gt; predicate, per-request headers computed at call time, &lt;code&gt;onlyLocalCall&lt;/code&gt; for offline reads. The paged call from two sections ago, with a database under it, looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// commonMain
@OptIn(ExperimentalPagingApi::class)
override fun getTodos(): Flow&amp;lt;PagingData&amp;lt;Todo&amp;gt;&amp;gt; = client.call {
    path = &quot;todos&quot;
}.responsePaginated&amp;lt;TodoDto, Todo&amp;gt; {
    localSource(
        pagingSource = { TodoPagingSource(database) },
        transform = { it.toModel() }
    )

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

    firstItemDatabase(
        itemDatabase = { database.todoQueries.getFirstTodo().executeAsOneOrNull() },
        timestamp = { it.lastUpdatedTimestamp }
    )
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;three-rungs-or-three-libraries&quot;&gt;Three rungs, or three libraries&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;RemoteMediator&lt;/code&gt;. It works. I have shipped it.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;LoadState&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;In NetFlow the three rungs return the same &lt;code&gt;ResultState&lt;/code&gt;, &lt;code&gt;AsyncState&lt;/code&gt;, and &lt;code&gt;PagingData&lt;/code&gt; types. They run on the same &lt;code&gt;NetFlowClient&lt;/code&gt;. And they are tested with the same &lt;code&gt;MockNetFlowClient&lt;/code&gt;, which intercepts requests without any network at all:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;@Test
fun `delete removes item from local database`() = runTest {
    val mockClient = MockNetFlowClient { _ -&amp;gt; NetFlowMockResponse.success() }
    val repository = TodoRepositoryImpl(mockClient, database)

    repository.deleteTodo(id = 1)

    mockClient.assertCalled(&quot;todos/1&quot;, HttpMethod.Delete)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That test doesn't care which rung the repository used. Swap &lt;code&gt;getTodos&lt;/code&gt; from an annotated method to a &lt;code&gt;call {}&lt;/code&gt; block and the test still passes, because the boundary is the client, not the call style.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The exception is the one I spent a whole section on. No annotation layer hands you a &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; from &lt;code&gt;commonMain&lt;/code&gt;, 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;generating-into-commonmain-with-ksp&quot;&gt;Generating into commonMain with KSP&lt;/h3&gt;
&lt;p&gt;This is the part I found least documented, so here it is in full.&lt;/p&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// build.gradle.kts
plugins {
    alias(libs.plugins.ksp)
}

kotlin {
    sourceSets {
        commonMain {
            kotlin.srcDir(&quot;build/generated/ksp/metadata/commonMain/kotlin&quot;)
        }
        commonMain.dependencies {
            implementation(&quot;io.github.kmpbits:netflow-annotations:&amp;lt;latest_version&amp;gt;&quot;)
        }
    }
}

dependencies {
    add(&quot;kspCommonMainMetadata&quot;, &quot;io.github.kmpbits:netflow-ksp:&amp;lt;latest_version&amp;gt;&quot;)
}

tasks.withType&amp;lt;KotlinCompilationTask&amp;lt;*&amp;gt;&amp;gt;().configureEach {
    if (name != &quot;kspCommonMainKotlinMetadata&quot;) {
        dependsOn(&quot;kspCommonMainKotlinMetadata&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;srcDir&lt;/code&gt; line and the &lt;code&gt;dependsOn&lt;/code&gt; block are the two that bite. Without &lt;code&gt;srcDir&lt;/code&gt;, 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 &lt;code&gt;createTodoApi&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;One detail worth mention: &lt;code&gt;netflow-ksp&lt;/code&gt; has no dependency on &lt;code&gt;netflow-paging&lt;/code&gt;. The processor emits &lt;code&gt;responsePaginated&lt;/code&gt; as a fully qualified name string and lets the consumer's own classpath resolve it. If you never return &lt;code&gt;Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt;, you never pull in Paging 3, and the annotation module stays a pure Kotlin Multiplatform artifact with no Android-flavoured transitive weight.&lt;/p&gt;
&lt;p&gt;I picked KSP over a compiler plugin for one reason: the output is a plain &lt;code&gt;.kt&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-line-i-hold&quot;&gt;The line I hold&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;A layer that quietly did less checking so it could cover more cases would be a worse library with a better README.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Pit lane or full circuit, it is the same car. 🏁&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The library is available on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kmpbits/netflow&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27052/two-racing-lines-annotations-and-the-dsl-in-netflow-0-7-0</guid>
<pubDate>Mon, 14 Sep 2026 08:00:00 +0000</pubDate>
</item>
</channel>
</rss>