Skip to main content

Sample

MediatorK ships with five runnable samples:

SampleModuleFramework
Basic/samples/basicPlain JVM
Android/samples/sample-androidJetpack Compose
Ktor/samples/sample-ktorKtor Server
Spring Boot/samples/sample-springSpring WebFlux
Kotlin/JVM/samples/samplePlain JVM

The Android, Ktor, and Spring samples all use the same before / after / after super structure against the Aladhan prayer-times API so the progression is easy to compare across platforms.


Basic Sample — Todo

The /samples/basic module is the smallest possible end-to-end demonstration of MediatorK. It uses a simple in-memory Todo domain to show a command, a query, and a notification — each in its own file — with no framework overhead.

Structure

basic/src/main/kotlin/sample/basic/
Basic.kt ← Todo model, TodoStore, TodoRegistrar, main()
AddTodoCommand.kt ← AddTodoCommand + AddTodoHandler
GetTodoQuery.kt ← GetTodoQuery + GetTodoHandler
TodoAddedNotification.kt ← TodoAddedNotification + LogTodoAddedHandler + SyncTodoAddedHandler

Command — write with a result

AddTodoCommand creates a new Todo, saves it to the store, publishes a TodoAddedNotification, and returns the saved todo.

data class AddTodoCommand(val id: String, val title: String) : Request<Todo>

class AddTodoHandler(private val store: TodoStore) : RequestHandler<AddTodoCommand, Todo> {
override suspend fun handle(mediator: Mediator, requestContext: RequestContext, request: AddTodoCommand): Todo {
val todo = Todo(id = request.id, title = request.title)
store.save(todo)
mediator.publish(TodoAddedNotification(todo))
return todo
}
}

Query — read with a nullable result

GetTodoQuery looks up a todo by id. Returns null when not found — no exception, no special case.

data class GetTodoQuery(val id: String) : Request<Todo?>

class GetTodoHandler(private val store: TodoStore) : RequestHandler<GetTodoQuery, Todo?> {
override suspend fun handle(mediator: Mediator, requestContext: RequestContext, request: GetTodoQuery): Todo? =
store.findById(request.id)
}

Notification — fan-out with no response

TodoAddedNotification is broadcast to two independent handlers: one logs, one syncs. Neither returns a value, and neither knows about the other.

data class TodoAddedNotification(val todo: Todo) : Notification

class LogTodoAddedHandler : NotificationHandler<TodoAddedNotification> {
override suspend fun handle(notification: TodoAddedNotification) {
println("[Log] Todo added: '${notification.todo.title}' (id=${notification.todo.id})")
}
}

class SyncTodoAddedHandler : NotificationHandler<TodoAddedNotification> {
override suspend fun handle(notification: TodoAddedNotification) {
println("[Sync] Syncing todo '${notification.todo.id}' to remote...")
}
}

Running the sample

./gradlew :samples:basic:run

Expected output:

[Log] Todo added: 'Buy groceries' (id=1)
[Sync] Syncing todo '1' to remote...
Created: Todo(id=1, title=Buy groceries, done=false)
Found: Todo(id=1, title=Buy groceries, done=false)
Missing: null

Android Sample — Prayer Times

The Android sample is a standalone Jetpack Compose app that fetches daily prayer times and Islamic calendar months from the Aladhan API. It is structured in three layers to show the before and after story of adopting MediatorK — and a third after super layer that adds every built-in pipeline behavior.

Open in Android Studio

# Clone the repo, then open samples/sample-android/ as its own project:
File → Open → <repo-root>/samples/sample-android

The folder contains its own settings.gradle.kts and build.gradle.kts, so Android Studio treats it as a standalone project. The library is pulled from Maven Central — no local build of the root project is needed.


Before — standard Android architecture

ViewModel → UseCase → Repository → DataSource

before/
data/
AladhanRemoteDataSource.kt ← raw Ktor HTTP call
AladhanCacheDataSource.kt ← in-memory cache
AladhanRepository.kt ← wires remote + cache
domain/
GetPrayerTimesUseCase.kt ← delegates to Repository
viewmodel/
BeforePrayerTimesViewModel.kt
ui/
BeforePrayerTimesScreen.kt

The ViewModel calls GetPrayerTimesUseCase directly. Adding a second feature means adding another use-case parameter to the constructor.

class BeforePrayerTimesViewModel(
private val getPrayerTimes: GetPrayerTimesUseCase,
) : ViewModel() {
val uiState = refreshTrigger.flatMapLatest {
flow {
emit(BeforeUiState.Loading)
val result = runCatching { getPrayerTimes() }
emit(
result.fold(
onSuccess = { BeforeUiState.Success(it) },
onFailure = { BeforeUiState.Error(it.message ?: "Error") },
)
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), BeforeUiState.Loading)
}

After — MediatorK handlers

ViewModel → Mediator → Handler

after/
data/
AladhanRemoteDataSource.kt ← same raw Ktor HTTP call
AladhanCacheDataSource.kt ← same in-memory cache
domain/
GetPrayerTimesRequest.kt ← Request<TodayPrayerTimes>
PrayerTimesHandler.kt ← RequestHandler (inline cache logic)
PrayerTimesRegistrar.kt ← MediatorRegistrar
viewmodel/
AfterPrayerTimesViewModel.kt
ui/
AfterPrayerTimesScreen.kt

The ViewModel sends a typed Request through the Mediator. Adding a second feature means adding a new handler — the ViewModel constructor stays the same.

class AfterPrayerTimesViewModel(
private val mediator: Mediator,
) : ViewModel() {
val uiState = refreshTrigger.flatMapLatest {
flow {
emit(AfterUiState.Loading)
val result = runCatching { mediator.send(GetPrayerTimesRequest()) }
emit(
result.fold(
onSuccess = { AfterUiState.Success(it) },
onFailure = { AfterUiState.Error(it.message ?: "Error") },
)
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AfterUiState.Loading)

companion object {
val Factory = viewModelFactory {
initializer {
val cache = AladhanCacheDataSource()
AfterPrayerTimesViewModel(
MediatorFactory.create(registrars = listOf(PrayerTimesRegistrar(cache)))
)
}
}
}
}

After Super — pipeline behaviors

The same After setup, extended with all six built-in pipeline behaviors. Logs are captured into a shared buffer and rendered inside the screen as a terminal-style card.

MediatorFactory.create(
registrars = listOf(PrayerTimesRegistrar(cache)),
pipelineBehaviors = listOf(
RetryPipelineBehavior(maxRetries = 2, delayMillis = 200, order = -200),
LoggingPipelineBehavior(logger = { msg -> logs.add(msg); Log.d("MediatorK", msg) }, order = -100),
TimingPipelineBehavior(order = 0) { name, ms ->
val line = "⏱ $name took ${ms}ms"
logs.add(line)
Log.d("MediatorK", line)
},
TimeoutPipelineBehavior(timeoutMillis = 10_000, order = 10),
RequestCounterPipelineBehavior(order = 20),
ErrorTrackingPipelineBehavior(order = Int.MAX_VALUE) { req, err ->
val line = "❌ ${req::class.simpleName}: ${err.message}"
logs.add(line)
Log.e("MediatorK", line)
},
),
)

Pipeline execution order (lower order = outermost):

OrderBehaviorRole
−200RetryPipelineBehaviorRetries the entire pipeline on failure
−100LoggingPipelineBehaviorLogs → Request and ← Response
0TimingPipelineBehaviorMeasures handler execution time
10TimeoutPipelineBehaviorCancels if handler exceeds 10 s
20RequestCounterPipelineBehaviorCounts requests per type
MAXErrorTrackingPipelineBehaviorCaptures unhandled exceptions

Logs from each pipeline pass appear live in the screen and in Logcat under the tag MediatorK.


Ktor Sample — Prayer Times

The /samples/sample-ktor module is a Ktor ( Netty) HTTP server using the same three-layer structure as the Android sample.

Run

./gradlew :samples:sample-ktor:run
# Server starts on http://localhost:8080

Before — standard Ktor architecture

Route → UseCase → Repository → DataSource

before/
data/
AladhanRemoteDataSource.kt ← raw HTTP call
AladhanCacheDataSource.kt ← in-memory cache
AladhanRepository.kt ← wires remote + cache
domain/
GetPrayerTimesUseCase.kt
GetIslamicMonthsUseCase.kt
routes/
BeforeRoutes.kt ← constructs the chain manually
fun Application.configureBeforeRoutes() {
val getPrayerTimes = GetPrayerTimesUseCase(AladhanRepository(...))
routing {
get("/before/prayer-times/{city}") {
call.respond(getPrayerTimes(call.parameters["city"]!!))
}
}
}

After — MediatorK handlers

Route → Mediator → Handler

after/
data/
AladhanCacheDataSource.kt ← in-memory cache
domain/
GetPrayerTimesRequest.kt ← Request<TodayPrayerTimes> + Handler
GetIslamicMonthsRequest.kt ← Request<List<IslamicMonth>> + Handler
AppRegistrar.kt ← MediatorRegistrar
routes/
AfterRoutes.kt
fun Application.configureAfterRoutes() {
val mediator = MediatorFactory.create(registrars = listOf(AppRegistrar(cache)))
routing {
get("/after/prayer-times/{city}") {
call.respond(mediator.send(GetPrayerTimesRequest(city = call.parameters["city"]!!)))
}
}
}

After Super — pipeline behaviors

Same After setup, extended with validation, retry, logging, timing, timeout, counter, and error-tracking behaviors. The AfterSuperPrayerTimesResponse wraps the data with the request count from RequestCounterPipelineBehavior.

curl http://localhost:8080/aftersuper/prayer-times/London
# { "prayerTimes": {...}, "requestCount": 1 }

Spring Boot Sample — Prayer Times

The /samples/sample-spring module is a Spring Boot WebFlux application using the same three-layer structure.

Run

./gradlew :samples:sample-spring:bootRun
# Server starts on http://localhost:8081

Before — standard Spring architecture

Controller → UseCase → Repository → DataSource

before/
data/
AladhanRemoteDataSource.kt ← @Component, raw HTTP call
AladhanCacheDataSource.kt ← @Component, in-memory cache
AladhanRepository.kt ← @Repository, wires remote + cache
domain/
GetPrayerTimesUseCase.kt ← @Service
GetIslamicMonthsUseCase.kt ← @Service
controller/
BeforePrayerTimesController.kt ← @RestController
@RestController
@RequestMapping("/before")
class BeforePrayerTimesController(
private val getPrayerTimes: GetPrayerTimesUseCase,
private val getIslamicMonths: GetIslamicMonthsUseCase,
) {
@GetMapping("/prayer-times/{city}")
suspend fun getPrayerTimes(@PathVariable city: String): TodayPrayerTimes =
getPrayerTimes(city)
}

After — MediatorK handlers

Controller → Mediator → Handler

after/
data/
AladhanCacheDataSource.kt ← @Component
domain/
GetPrayerTimesRequest.kt ← Request<TodayPrayerTimes> + Handler
GetIslamicMonthsRequest.kt ← Request<List<IslamicMonth>> + Handler
AppRegistrar.kt ← @Component, MediatorRegistrar
config/
MediatorConfig.kt ← @Configuration, @Bean mediator
controller/
AfterPrayerTimesController.kt ← @RestController
@RestController
@RequestMapping("/after")
class AfterPrayerTimesController(@Qualifier("mediator") private val mediator: Mediator) {
@GetMapping("/prayer-times/{city}")
suspend fun getPrayerTimes(@PathVariable city: String): TodayPrayerTimes =
mediator.send(GetPrayerTimesRequest(city = city))
}

After Super — pipeline behaviors

The same After setup. MediatorConfig also exposes a mediatorWithBehaviors bean that includes all six pipeline behaviors. AfterSuperPrayerTimesController injects this bean and responds with the request count.

curl http://localhost:8081/aftersuper/prayer-times/London
# { "prayerTimes": {...}, "requestCount": 1 }

Kotlin/JVM Sample

The /samples/sample module shows commands, queries, notifications, and pipeline behaviors in plain Kotlin/JVM with no UI framework:

Invoice Slice — Vertical Slice Showcase

The invoice/ package is a self-contained vertical slice that demonstrates three advanced features together:

FileWhat it shows
InvoiceDomain.ktDomain model, requests (CreateInvoiceCommand, StreamInvoicesQuery), in-memory repo with TransactionProvider
InvoiceHandlers.ktRequestHandler for commands/queries, StreamRequestHandler for streaming, MediatorRegistrar
InvoiceValidators.ktAll three ValidationScope levels: REQUEST, DOMAIN, PERSISTENCE
InvoiceDemo.ktRunnable demos (Test25–28): transaction commit/rollback, validation scopes, streaming

Test25TransactionPipelineBehavior commits on success, Test26 — rolls back on failure:

val mediator = MediatorFactory.create(
registrars = listOf(InvoiceRegistrar(repo)),
pipelineBehaviors = listOf(
ValidationBehavior(listOf(CreateInvoiceRequestValidator())),
TransactionPipelineBehavior(transactionProvider = repo.transactionProvider),
),
)
mediator.send(CreateInvoiceCommand(id = "INV-001", amount = 500.0))
mediator.send(ApproveInvoiceCommand(id = "INV-001"))

Test27ValidationScope in action — REQUEST is caught before the handler; DOMAIN is caught inside:

// REQUEST scope — caught in the pipeline before handler entry
mediator.send(CreateInvoiceCommand(id = "BADINPUT", amount = 100.0)) // throws ValidationException

// DOMAIN scope — caught inside the handler after loading state
mediator.send(CreateInvoiceCommand(id = "INV-020", amount = 300.0)) // first call: OK
mediator.send(CreateInvoiceCommand(id = "INV-020", amount = 300.0)) // duplicate: throws ValidationException

Test28StreamRequest<Invoice> — lazy flow, filtered by status:

mediator.stream(StreamInvoicesQuery(status = InvoiceStatus.APPROVED))
.collect { invoice -> println("${invoice.id} — $${invoice.amount}") }

Integration tests InvoiceIntegrationTest.kt uses buildHandlerTestHarness to test the full slice end-to-end with real handlers and the real pipeline — no mocks:

val h = buildHandlerTestHarness(
pipelineBehaviors = listOf(
ValidationBehavior(listOf(CreateInvoiceRequestValidator())),
TransactionPipelineBehavior(transactionProvider = repo.transactionProvider),
),
) {
+CreateInvoiceHandler(repo, domainValidator, persistenceValidator)
+ApproveInvoiceHandler(repo)
+GetInvoiceHandler(repo)
registerStream(StreamInvoicesHandler(repo))
}

h.given(CreateInvoiceCommand(id = "INV-200", amount = 500.0)) // arrange
h.send(ApproveInvoiceCommand(id = "INV-200")) // act
val invoice = h.query(GetInvoiceQuery(id = "INV-200")) // assert
assertEquals(InvoiceStatus.APPROVED, invoice.status)

Browse the full module on GitHub →