Surefold

Kotlin on virtual threads

Blocking code that behaves.

Call JDBC, files and HTTP clients the plain way. Surefold gives that code the structure virtual threads leave out: scopes that clean up, typed errors and timeouts that actually stop work.

Two blocking calls, in parallel, with a deadline
fun summary(id: UserId): Either<SummaryError, Summary> = either {
    timeout(2.seconds) {
        parZip(
            { users.find(id) ?: raise(UserNotFound(id)) }, // blocking JDBC
            { orders.recent(id) },                         // blocking HTTP
        ) { user, recent -> Summary(user, recent) }
    }
}
No suspendOne calling convention. Stack traces and profilers see real threads.
Pick what you needEvery library is its own dependency on Maven Central.
Apache 2.0Experimental: easy to try, APIs still moving.

Surefold core published today as Lark

Concurrency that cleans up after itself.

In the example above, if the user is missing the orders call is interrupted and you get Left(UserNotFound). If two seconds pass, both calls stop. Either way summary only returns once nothing is left running behind it.

  • parZip, parMap, raceN, timeout and retries, on virtual threads
  • Typed errors with Arrow's Raise, across every fork
  • Grows with you: dependency graphs, typed streams, actors, clusters and Kafka
Read the guide
build.gradle.kts
kotlin { jvmToolchain(25) }

dependencies {
    // brings arrow-core and nothing else
    implementation("io.github.matthewjones372:lark:0.10.1")
}

Pelican

Describe an endpoint once. Get everything else free.

Write the endpoint as a value, failures included. Pelican turns that one description into the route, the OpenAPI document, a typed client and MCP tools, on Jetty or Pekko HTTP.

  • The handler gets the id already decoded as a Long
  • A 404 is part of the type, so docs and clients know about it
  • Docs can't drift from the code, because they are the code
Read the guide
One endpoint, one handler
val bookmarkId      = pathParam<Long>("bookmarkId")
val bookmarkMissing = errorJson<NoSuchBookmark>(404, "No bookmark with that id")

val getBookmark = endpoint(bookmarkId) {
    get("bookmarks" / bookmarkId)
    json<Bookmark>() orFail bookmarkMissing
}

val route = getBookmark handledOrFail { id ->
    Bookmarks.find(id)?.let { ok(it) }
        ?: bookmarkMissing(NoSuchBookmark(id, "No bookmark $id"))
}

Proofload

Latency numbers you can actually trust.

Most load tools time a request from when it was sent. If the generator falls behind, the slow part disappears from the graph. Proofload times each request from when it was scheduled, and checks the generator kept up.

  • A failed run tells you if the service missed its goal or the generator couldn't keep up
  • Scenarios are plain values, run as JUnit tests on virtual threads
  • HTTP, SSE, WebSocket, gRPC, Kafka, JDBC and Pelican
Read the guide
3,000 checkouts over a minute, p99 under 200 ms
val checkout = scenario("checkout") {
    exec(browse, api.get("/products"))
    exec(placeOrder, api.post("/orders")
        .body("""{"cart":"1 anvil"}""")
        .expecting(201))
}

@LoadTest
fun `checkout holds up at fifty a second`(proofload: Proofload) {
    val result = proofload.run(
        checkout.at(50.perSecond, over = 1.minutes)
            .expecting(p99(placeOrder) under 200.milliseconds, keptSchedule),
    )
    assertTrue(result.metEveryGoal, result.verdicts.joinToString("\n"))
}

Kimney

Mapping code you never write, and never debug.

Domain to DTO, row to entity, event to message. Say what turns into what and the K2 compiler plugin writes the code you would have written by hand. No reflection, no runtime cost.

  • Nested types, enums, sealed classes, optionals and collections
  • Renames and computed fields are type-checked overrides
  • If it can't map, the build fails and says exactly what to fix
Read the guide
One line per mapping
fun User.toDto(): UserDto = transformInto()

fun Person.toDto(year: Int): PersonDto = into<_, PersonDto>()
    .withFieldRenamed(Person::fullName, PersonDto::name)
    .withFieldComputed(PersonDto::age) { year - it.born }
    .transform()
When it can't, the compiler tells you why
e: Cannot transform User → UserDto:
   UserDto.email: String
     User has no property 'email'. Add it to User,
     give UserDto.email a default value, or add
     .withFieldConst(UserDto::email, …).

Demos

Don't take the README's word for it.

Two real services built on the libraries, with the tests and chaos runs to prove they hold up.

Petshop

Pelican · Surefold core · Proofload · Kimney

A pet shop with every moving part a real service has: an HTTP API with OpenAPI docs, an actor that owns the state, a Postgres outbox relayed in-process or over Kafka with Avro, and a SolidJS front end traced from a click to the database.

  • The whole service wired as one checked graph
  • Tests that run all of it in-process, as Given/When/Then stories
  • A load test at 200 requests a second against the real graph

Browse the code

Wiring the shop: each node says what it needs
actor<Shop, Outbox, ShopWindow>("shop") { outbox, window ->
    shop(outbox, window, outbox.shelf(opening).associateBy { it.id })
} +
closingOf { ref: ActorRef<Shop>, registry: ChipRegistry,
            window: ShopWindow, streams: StreamBackend ->
    Adoption(ref, registry, window, streams)
} +
single { window: ShopWindow, adoption: Adoption, tracer: Tracer ->
    ObservedShop(ShopFront(window, adoption), tracer)
}.boundTo<PetShop>()
Run it
./gradlew :app:run     # API docs at /api-docs
./gradlew test         # each test starts its own Postgres and Kafka
./gradlew loadTest     # 200 requests a second

tweet-street

Surefold cluster · Pelican · Proofload

A small bank in four Kotlin and Scala services, built to see if the libraries hold up when the money has to add up. Every account is an actor on a three-node cluster. Every transfer is a saga.

  • If a node dies mid-transfer, another picks it up from its last event
  • The chaos script cuts nodes, freezes Kafka and stops databases at 200 transfers a second
  • Afterwards GET /ledger still balances
Browse the code
The transfer saga moves one step at a time (excerpt)
fun Ctx<TransferMessage>.drive(state: Transfer) {
    when (val step = state.next(id, screens)) {
        is Step.DebitSource       -> sendLeg(step, step.account, step.command)
        is Step.CreditDestination -> sendLeg(step, step.account, step.command)
        is Step.RefundSource      -> sendLeg(step, step.account, step.command)
        Step.Done -> timers.cancel(LEG)
        // …
    }
}
Break it on purpose
scripts/chaos-docker.sh   # 200 transfers a second while it breaks things
                          # then checks the ledger still balances

Try one piece this afternoon.

Start with the core, or any single library. Each one stands on its own.