Skip to content

ZLink Framework Kotlin — User Guide

A Kotlin application framework for building server systems where real-time messaging matters out of several cooperating processes. It goes straight into Spring Boot, so there is no separate runtime to move to.

// Both sides must name the mesh identically, or they never see each other
// as peers. The routing id names this node; without it the Framework
// assigns a generated one, which a caller cannot type into a URL.
val mesh =
    options
        .addRouteMesh("game")
        .listen("tcp://127.0.0.1:7601")
        // The advertised endpoint must match the endpoint passed to
        // connect(routingId, endpoint). Both use 127.0.0.1 here.
        .setAdvertiseHost("127.0.0.1")
        .setRoutingId(RoutingId.from("game-server-1"))

Register one handler and the framework takes care of message decoding, routing and encoding.


Systems It Is Built For

It is designed for systems where several server processes divide the roles between them and state changes reach the client in real time.

Domain Core scenario
Real-time games Create a room -> player joins -> game state updates -> client push
Customer support chat Open a conversation -> assign an agent -> relay messages -> push conversation state
Order workflow Accept an order -> process each step -> change state -> notify the client
Delivery and dispatch Request a dispatch -> assign and accept -> track state -> real-time push

The shape they share is that server processes for each role talk in typed messages, and the client receives state changes over a real-time connection.

↗ Open larger


Core Capabilities

Channel messaging — typed request-reply between servers

A channel is a name given to a path between servers. One side sends a request to the channel name and the other side handles it and replies. Serialization (JSON · MessagePack · Protobuf) is the framework's work.

The sending side.

@GetMapping("/players/{playerId}/profile")
suspend fun profile(@PathVariable playerId: String): PlayerProfile {
    // The target is a channel name. Which node answers is decided at call time.
    // The reply type is a reified type argument, not a Class passed at the end.
    val request = GetPlayerProfile(playerId)
    return route.requestToChannel<PlayerProfile>("profile", request).await()
}

The receiving side.

// Answers a request addressed to the "profile" channel. Any node that exposes
// this channel may receive it; the caller does not pick one.
//
// ZLinkSuspendingRequestHandler is the Kotlin surface: the body is a suspend fun
// and returns the reply itself. The Java surface, ZLinkRequestHandler, returns a
// CompletionStage, which a suspend fun cannot override. The group annotation is
// what ties this class to the channel -- see HandlerGroups.kt.
@ZLinkHandlerGroup(HandlerGroups.PROFILE)
class GetPlayerProfileHandler : ZLinkSuspendingRequestHandler<GetPlayerProfile, PlayerProfile> {

    override suspend fun handle(
        request: GetPlayerProfile,
        context: ZLinkMessageContext,
    ): PlayerProfile {
        return PlayerProfile(request.playerId, nickname = "rookie", level = 1)
    }
}

Besides request-reply there are the fanout (pub/sub) and route mesh (address routing) patterns. Channel messaging →


Spot — a unit of state without locks

A Spot binds one region of state and its participants into an execution unit: a game room, a support conversation, a unit of order processing. Everything that happens inside one Spot — participant packets, timers, joins and leaves — is processed serially. State is reached without a lock, and two requests never overlap in the same Spot even when the handling is asynchronous.

// A room owns its own state and is addressed by a global SpotId. Messages sent
// to one room run one at a time, so the fields below need no synchronization.
//
// ZLinkSuspendingSpot is the Kotlin base: it turns every CompletionStage
// callback into a suspending one. Its type argument is the actor type the room
// can admit; this room admits none, so it names the base type and rejects every
// join. Actors are a later chapter.
class GameRoom(override val context: ZLinkSpotContext) : ZLinkSuspendingSpot<ZLinkActor>() {

    private val chat = mutableListOf<String>()
    private var title = "untitled"

    init {
        // Handler classes are named here rather than scanned, the same way the
        // channel registrations name their group. Each takes the target room as
        // its first argument.
        context.handlers().addHandler<PostChatHandler>()
        context.handlers().addHandler<GetRoomStateHandler>()
    }

    // Runs before the room accepts any message. Rejecting here means the create
    // call fails and no room exists. Omit this method to accept every request.
    override suspend fun onCreateSuspending(request: ZLinkMessage): ZLinkSpotCreateResponse {
        val body = request.decode<OpenRoom>()
        title = body.title
        return ZLinkSpotCreateResponse.accept()
    }

    // No actor ever joins this room, so the three membership callbacks below say
    // so and do nothing else.
    override suspend fun onActorJoinSuspending(
        actorId: String,
        request: ZLinkMessage,
    ): ZLinkSpotActorJoinResult {
        return ZLinkSpotActorJoinResult.reject()
    }

    override suspend fun onJoinedActorSuspending(actor: ZLinkActor) {}

    override suspend fun onLeaveActorSuspending(actor: ZLinkActor) {}

    fun append(line: String) {
        chat.add(line)
    }

    fun state(): RoomState {
        return RoomState(title, chat.toList())
    }
}

It divides into an entry spot that assigns (one per node) and a room spot that holds the state (one per unit). Periodic work is registered as a timer. Spot →


STREAM and Actor — the client's real-time connection

A client's real-time two-way connection is a STREAM, and the server-side object standing for one connection is an Actor. When a client connects, a session creates the Actor, and the Actor joins a Spot to take part in handling its state.

// A slotted packet uses its dispatch Actor. An unslotted packet can use
// the connection only while exactly one Actor is bound.
val player =
    dispatch.actor()
        ?: run {
            val bound = context.actors().bound()
            when (bound.size) {
                1 -> bound[0]
                0 -> error("Authenticate an Actor before sending player packets.")
                else -> error("Select an Actor handle when more than one Actor is bound.")
            }
        }

player.kotlin().relay(dispatch, payload).await()

The client side of the connection is a separate product, the stream connector. STREAM → · Binding a session to an Actor →


Location — endpoints stay out of the code

When several servers of the same role are up, the address of the one to connect to is not written in the code. A shared location store keeps the addresses, and each server looks up the node an id is on right now.

// Rooms are addressed by id, not by host, so their current location is
// kept here. Every node reads and writes the same store under the same
// prefix.
val locationOptions =
    ZLinkRedisLocationOptions()
        .setConnectionString("127.0.0.1:6379")
        .setKeyPrefix("zlink-tutorial-kotlin:location:")
options.addLocationStore(ZLinkRedisLocationStore(locationOptions))

Location →


What Spring Boot provides

The DI container, configuration, HTTP endpoints and logging are Spring Boot's own. ZLink Framework registers its handlers and meshes on top of them, so REST endpoints and real-time connections run in the same process.


Table Of Contents

Order Document Content
1 Overview A quick map of the core surface, layers, and topology
2 Interactive Tour Step through how each feature behaves, using a single MORPG
3 Quickstart Install, a minimal project where two processes call each other, first-run checks
4 Core Concepts What a channel, a Spot, an Actor and a session each are
5 Channel Messaging The path that calls by name — registering and calling
6 Spot Creating and calling a shared place by id
7 Actor Creating and calling one entity by id
8 STREAM A client outside the mesh attaching over one connection
9 Session and Actor Binding one connection to one Actor
10 Location Looking up the node something is on by id
11 Monitoring A placeholder in the feature guide — no body yet
12 The Execution Model Two queues, the serialization scope, the turn
13 Backpressure When arrival outruns processing, and the options that affect it
14 Activation and Lifetime Creation time per kind, lifecycle callbacks, injection lifetime
15 Actor Membership Moving between Spots, reservations and limits
16 Timers and Workers Periodic execution, running outside the line, giving the turn back
17 Relocation What survives a move, the adapter, the unit
18 How Channels Work Pattern differences, target selection, pub/sub, connection and discovery
19 Handlers and Message Processing Registration variants, filters, codecs, handler kinds
20 How STREAM Works Startup checks, error ownership, reply tokens, execution mode
21 How Session Binding Works How many bindings, route refresh, disconnect, failures
22 Operations and Lifecycle Runtime metrics, relocate, drain, readiness wiring
23 Options The options, their defaults, and when to change them
24 Picking a Sample Choosing which sample to read first and how to run it
25 Reading Along: Bingo Authentication, matching, rooms, timers, multicast and cleanup in code order
26 Reading Along: TicTacToe Manual connection and registration, an Actor join onto another node
27 Reading Along: SupportChat Several Actors on one session, metadata relay, the idle timer
28 Reading Along: DeliveryDispatch One-way sends, deadline records and reassignment, customer pushes
29 Reading Along: ShoppingMall The owner Instance Spot, replay, next step and expected version
30 Reading Along: GameQuest A per-player owner, best-effort pushes and reconciliation
31 Reading Along: ZoneWorld Capacity placement, boundary joins and relocation, fanout and observation
32 E2E Testing Verifying the whole system with the client library
33 Key Type Index The contract interfaces indexed by their verification code
34 Monitoring Awaiting rewrite — status snapshots and diagnostics

The file number identifies the same chapter regardless of language. This table owns the reading order.

Kotlin runs on the Java runtime as-is. zlink-framework-kotlin isn't a separate implementation — it's a thin layer that adds coroutine idioms. So this guide writes up only what differs from Java and points everything else at the Java guide.

  • 01 — the dependency and registration code differs, so this is Kotlin-specific.
  • 11, 13, 16 — the surface matches Java; only the idiom differs. This guide writes up only the difference and points the rest at the Java chapter.

The goal is not to keep two copies of the same content. When the Java document changes, Kotlin readers see the same document.

How To Read The Diagrams

Every diagram in this guide uses the same visual language — the color is the concept.

↗ Open larger

Several chapters draw the same topology; what changes from chapter to chapter is where it is magnified.