Vertical Slice Architecture
How it fits together
In a classic layered architecture every layer talks to the layer below it through abstractions:
UI
└─▶ ViewModel
└─▶ UseCase
└─▶ Repository (interface)
└─▶ RepositoryImpl
├─▶ RemoteDataSource (interface)
│ └─▶ RemoteDataSourceImpl ──▶ Ktor HttpClient ──▶ API
└─▶ LocalDataSource (interface)
└─▶ LocalDataSourceImpl ──▶ SQLDelight Driver ──▶ DB
With MediatorK the ViewModel collapses to a single mediator.send(...) call. The Handler owns the full slice and wires
the concrete clients directly:
UI
└─▶ ViewModel ──send(Request)──▶ Mediator
└─▶ Handler
├─▶ HttpClient ──▶ API
└─▶ SqlDriver ──▶ DB
What the layered version costs: Order example
In the layered architecture, a single "place order" feature needs all of these classes:
OrderRemoteDataSource(interface)OrderRemoteDataSourceImplOrderLocalDataSource(interface)OrderLocalDataSourceImplOrderRepository(interface)OrderRepositoryImplPlaceOrderUseCaseOrderViewModel
A single handler replaces all of it
With MediatorK, the same feature is one request plus one handler. The HttpClient and SqlDriver are injected
directly, with no extra abstraction layers in between:
// The request
data class PlaceOrderCommand(
val userId: String,
val items: List<OrderItem>,
) : Request<Order>
// The handler — HttpClient for remote, SqlDriver for local
class PlaceOrderHandler(
private val httpClient: HttpClient,
private val sqlDriver: SqlDriver,
) : RequestHandler<PlaceOrderCommand, Order> {
override suspend fun handle(
mediator: Mediator,
requestContext: RequestContext,
request: PlaceOrderCommand,
): Order {
// confirm stock via remote API
val confirmed = httpClient.post<List<OrderItem>>("orders/confirm") {
setBody(request.items)
}
// persist locally via SQLDelight
val db = OrderDatabase(sqlDriver)
db.orderQueries.insert(
userId = request.userId,
items = confirmed.joinToString(",") { it.id },
)
return Order(userId = request.userId, items = confirmed)
}
}
The ViewModel stays thin; it only dispatches:
class OrderViewModel(
private val mediator: Mediator,
) : ViewModel() {
val state = MutableStateFlow<OrderUiState>(OrderUiState())
fun placeOrder(userId: String, items: List<OrderItem>) {
viewModelScope.launch {
state.value = state.value.copy(isLoading = true)
try {
val order = mediator.send(PlaceOrderCommand(userId, items))
state.value = OrderUiState(order = order)
} catch (e: Exception) {
state.value = OrderUiState(error = e.message)
}
}
}
}
Each feature gets its own Command/Query + Handler, with no cross-feature coupling, no shared ViewModel god-class.
Real-world examples
Ktor sample-ktor
The sample-ktor shows this directly:
src/main/kotlin/com/fajrbahr/mediatork/sample/ktor/
prayertimes/ ← Feature 1 (vertical slice)
PrayerTimes.kt ← Model
GetPrayerTimesQuery.kt ← Request + Handler + Registrar
islamicmonths/ ← Feature 2 (vertical slice)
IslamicMonth.kt ← Model
GetIslamicMonthsQuery.kt ← Request + Handler + Registrar
AladhanCache.kt ← Shared infrastructure
Application.kt ← HTTP routes + mediator wiring
Each feature is self-contained: its handler knows how to fetch the data, parse it, and cache it. No separate data layers.
Spring sample-spring
The sample-spring adds REST controllers to the same pattern:
src/main/kotlin/com/fajrbahr/mediatork/sample/spring/
prayertimes/ ← Feature 1 (vertical slice)
PrayerTimes.kt ← Model
GetPrayerTimesQuery.kt ← Request + Handler + Registrar
PrayerTimesController.kt ← REST endpoint
islamicmonths/ ← Feature 2 (vertical slice)
IslamicMonth.kt ← Model
GetIslamicMonthsQuery.kt ← Request + Handler + Registrar
IslamicMonthsController.kt ← REST endpoint
AladhanCache.kt ← Shared infrastructure
MediatorConfig.kt ← Spring bean wiring
Benefits
- Scaling: Add new features without touching existing code. Each slice is independent.
- Testing: Test a whole feature end-to-end in isolation. No mock factories, no layer mocking.
- Clarity: The feature's logic is in one place (the handler), not spread across 8 classes.
- Onboarding: New team members see a feature and understand it completely.