Skip to content
MosaicMosaic

Combine multiple backend API calls in Kotlin with Mosaic

An order endpoint needs customer details, products, prices, and shipping quotes. Those values come from several services, and more than one branch needs the same order. Mosaic lets each part of the response ask for its own dependencies while sharing repeated work within the request.

This guide follows the repository’s order example. Its services use in-memory data so you can run it without external accounts. In an application, service methods can call your existing backend APIs. Mosaic handles composition; your clients still own HTTP requests, timeouts, and retries.

If you are new to the library, complete the Quick Start first. The snippets below use the order example’s models, service bindings, and Tile declarations.

The response has two parts: an order summary and logistics. Write a Tile for each, then compose them into the page:

OrderPageTile.kt
val OrderPageTile by
singleTile {
val summaryTile = composeAsync(OrderSummaryTile)
val logisticsTile = composeAsync(LogisticsTile)
OrderPage(summaryTile.await(), logisticsTile.await())
}

Both branches start before either result is awaited. Use composeAsync for independent work and compose when the next operation needs a result before it can proceed. Concurrent work explains the execution and failure boundaries.

Each branch can follow the same pattern. The summary needs the order, customer, and line items:

OrderSummaryTile.kt
val OrderSummaryTile by
singleTile {
val orderTile = composeAsync(OrderTile)
val customerTile = composeAsync(CustomerTile)
val lineItemsTile = composeAsync(LineItemsTile)
OrderSummary(orderTile.await(), customerTile.await(), lineItemsTile.await())
}

The page Tile does not need to know which services the summary calls. It depends on the response part it needs.

OrderSummaryTile asks for OrderTile directly. CustomerTile and LineItemsTile also need that order. Because they use the same Tile instance in one Mosaic, they share its in-flight work and completed result.

Keep reusable Tiles in stable declarations, as this example does. Creating a new Tile inside each caller creates separate cache identities. Reuse is local to the Mosaic instance, not an application-wide or distributed cache. See request-scoped caching and identity.

The line-item branch first needs the order’s product IDs and SKUs. Once it has them, product and pricing fetches can start independently:

LineItemsTile.kt
val LineItemsTile by
singleTile {
val order = compose(OrderTile)
val productIds = order.items.map { it.productId }
val skus = order.items.map { it.sku }
val products = composeAsync(ProductsByIdTile, productIds)
val pricing = composeAsync(PricingBySkuTile, skus)
order.items.map { item ->
val product = products.getValue(item.productId).await()
val price = pricing.getValue(item.sku).await()
LineItemDetail(product, price, item.quantity)
}
}

composeAsync with a MultiTile returns a map of deferred values, one for each key. The loop awaits the product and price for each line item, then constructs the response model.

Wrap a service’s bulk fetch in a MultiTile:

ProductsByIdTile.kt
val ProductsByIdTile by
multiTile { keys ->
source<ProductService>().getProducts(keys.toList())
}

Repeated requests for equal keys share their work within the same Mosaic. Newly pending keys can join a batch before execution starts, but Mosaic does not intentionally wait to collect more keys. Exact batch boundaries depend on scheduling. Choose bulk, per-key, or chunked fetching to fit the service’s API and limits.

Register your existing service instances in an application Canvas. At the request boundary, bind the order ID in a child Canvas and compose the page inside one withMosaic block:

val page = applicationCanvas.withLayer {
instance(OrderKey, orderId)
}.withMosaic { compose(OrderPageTile) }

Use this inside a suspending handler with the example’s declarations and org.buildmosaic.core.injection.withMosaic imported. Your HTTP framework serializes page and handles routing, status codes, and server lifecycle. The same composition works in the Spring Boot, Ktor, and Micronaut examples.

If the handler also needs the order total, start both results inside that same block. Calling withMosaic twice creates separate caches. Leaving the block cancels unfinished Tile work and waits for cleanup; it does not close the Canvas. Follow resource ownership when a Canvas creates closeable services.

Suppose the response also needs the order total. Both the summary and total use the same line items. Plain Kotlin coroutines can share that work too: create one Deferred and pass it to both consumers.

This version uses the same in-memory services and models as the order Tiles. Product and pricing calls start independently, and both consumers await the same lineItems value:

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.buildmosaic.library.model.LineItemDetail
import org.buildmosaic.library.model.OrderSummary
import org.buildmosaic.library.service.CustomerService
import org.buildmosaic.library.service.OrderService
import org.buildmosaic.library.service.PricingService
import org.buildmosaic.library.service.ProductService
suspend fun coroutineSummaryAndTotal(
orderId: String,
orders: OrderService,
customers: CustomerService,
products: ProductService,
pricing: PricingService,
): Pair<OrderSummary, Double> = coroutineScope {
val order = orders.getOrder(orderId)
val customer = async { customers.getCustomer(order.customerId) }
val lineItems = async {
val productValues = async {
products.getProducts(order.items.map { it.productId }.distinct())
}
val priceValues = async {
pricing.getPrices(order.items.map { it.sku }.distinct())
}
val byId = productValues.await()
val bySku = priceValues.await()
order.items.map { item ->
LineItemDetail(byId.getValue(item.productId), bySku.getValue(item.sku), item.quantity)
}
}
val summary = async { OrderSummary(order, customer.await(), lineItems.await()) }
val total = async { lineItems.await().sumOf { it.price.amount * it.quantity } }
summary.await() to total.await()
}

There is one order fetch, one product fetch, and one pricing fetch. Sharing works because the enclosing function knows both consumers need lineItems and gives them the same Deferred. Extracting these branches into functions still works, provided you keep passing the shared work or its result where needed.

With Mosaic, each consumer declares that dependency itself. OrderSummaryTile already composes LineItemsTile; OrderTotalTile does the same without receiving the summary’s Deferred:

OrderTotalTile.kt
val OrderTotalTile by
singleTile {
val lineItemsTile = compose(LineItemsTile)
lineItemsTile.sumOf { it.price.amount * it.quantity }
}

Compose both through one request Mosaic:

import org.buildmosaic.core.injection.Canvas
import org.buildmosaic.core.injection.withMosaic
import org.buildmosaic.library.OrderKey
import org.buildmosaic.library.model.OrderSummary
import org.buildmosaic.library.tile.OrderSummaryTile
import org.buildmosaic.library.tile.OrderTotalTile
suspend fun mosaicSummaryAndTotal(
applicationCanvas: Canvas,
orderId: String,
): Pair<OrderSummary, Double> = applicationCanvas.withLayer {
instance(OrderKey, orderId)
}.withMosaic {
val summary = composeAsync(OrderSummaryTile)
val total = composeAsync(OrderTotalTile)
summary.await() to total.await()
}

Both versions return Jane Doe’s summary and a total of 55.97 for order-1. The Mosaic version uses the same service bindings as the endpoint above. LineItemsTile runs once even though the two consumers ask for it separately. The request boundary only names the results it needs; it does not wire their shared dependencies.

Plain coroutines are a good fit when the dependency wiring is small and stays local. Mosaic becomes useful when independently reusable response parts discover overlapping dependencies deeper in the graph. It supplies request-scoped identity and reuse on top of coroutines, rather than making the service calls faster. The performance reference describes its measured overhead.

A backend-for-frontend (BFF) API is one example: separate page sections may need the same order, customer, or product data. Each section can compose its own dependencies while sharing work with the others in the request.

Failure handling also needs a deliberate choice. A child failure normally cancels sibling work in coroutineScope; Mosaic supervises Tile producers and retains their outcomes. An exception that escapes the withMosaic block still ends the request and cancels unfinished work. See concurrent work and retained failures before adapting either version’s recovery policy.

Follow the framework example setup, start one application, then request:

Terminal window
curl http://localhost:8080/orders/order-1

The response contains summary and logistics. In the sample data, the summary contains Jane Doe’s order for two Coffee Mugs and one Tea Kettle. Logistics contains the address and carrier quotes. This checks the composed response, not production network performance or the timing of individual branches.

Test composition separately from HTTP. The order-page tests replace summary and logistics Tiles with fixtures, verify the resulting page, and check a failed logistics dependency. Use the testing guide to add equivalent success and failure cases for your response.