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.
Start with the response
Section titled “Start with the response”The response has two parts: an order summary and logistics. Write a Tile for each, then compose them into the page:
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:
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.
Share dependencies across branches
Section titled “Share dependencies across branches”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.
Fetch products and prices by key
Section titled “Fetch products and prices by key”The line-item branch first needs the order’s product IDs and SKUs. Once it has them, product and pricing fetches can start independently:
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:
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.
Create one Mosaic per request
Section titled “Create one Mosaic per request”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.
Compare with plain coroutines
Section titled “Compare with plain coroutines”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.asyncimport kotlinx.coroutines.coroutineScopeimport org.buildmosaic.library.model.LineItemDetailimport org.buildmosaic.library.model.OrderSummaryimport org.buildmosaic.library.service.CustomerServiceimport org.buildmosaic.library.service.OrderServiceimport org.buildmosaic.library.service.PricingServiceimport 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:
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.Canvasimport org.buildmosaic.core.injection.withMosaicimport org.buildmosaic.library.OrderKeyimport org.buildmosaic.library.model.OrderSummaryimport org.buildmosaic.library.tile.OrderSummaryTileimport 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.
Run and check the result
Section titled “Run and check the result”Follow the framework example setup, start one application, then request:
curl http://localhost:8080/orders/order-1The 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.