Skip to content

Bluetape4k Exposed Part 5: Production Examples with Cache and Multi-Tenancy

Small robotic builders connecting service, cache, metrics, and data pipes on a production example workbench
The production examples keep Exposed SQL control while making cache, tenant routing, and outbox patterns usable as application features.

The final post in this series looks at production examples. The part I cared about most in bluetape4k-exposed was keeping Exposed’s SQL DSL and explicit transaction control while making repeated operational features easier to attach from service code. Cache strategies, multi-tenancy, outbox, and idempotency can all be built by hand. The problem is that once they are built by hand, repository, service, interceptor, scheduler, and metric code spread quickly.

I organized these features as extensions that fit naturally into applications already using Exposed. Spring Boot reduces configuration, the cache modules turn read/write strategies into repository API choices, and the multi-tenant examples collect schema or database switching at transaction entry points. Outbox and idempotency follow the same direction. The goal is not to build a large framework around operational behavior, but to layer it thinly on Exposed transactions so it can be tested and benchmarked immediately.

Production boundary diagram from HTTP request through Spring Boot wiring, cache strategy, tenant context, outbox idempotency, repository layer, and observability to measured service
Spring wiring, cache, tenant context, outbox, and observability are assembled around Exposed repository and transaction boundaries.

The first piece is Spring Boot auto-configuration. In a real application, Exposed Database, transaction manager, repository scanning, and entity wiring are easy to hand-wire once. After the service count grows, that setup code starts being copied. I added Spring Boot auto-configuration to reduce that repetition.

ExtensionRole
exposed-spring-boot-jdbcSpring Data JDBC-style repositories based on Exposed DAO entities
exposed-spring-boot-r2dbcCoroutine repositories based on Exposed R2DBC DSL
batch-exposedSpring Batch integration with Exposed
exposed-spring-modulithSpring Modulith JDBC event publication based on Exposed

The JDBC side supports Spring Data-style repositories and PartTree queries.

interface UserRepository : ExposedJdbcRepository<User, Long> {
fun findByName(name: String): List<User>
fun findByAgeGreaterThan(age: Int): List<User>
fun findByEmailContaining(keyword: String): List<User>
}

The R2DBC side puts coroutine repositories and Flow streaming at the front.

interface UserRepository : ExposedR2dbcRepository<User, Long> {
suspend fun findByName(name: String): List<User>
suspend fun countByAge(age: Int): Long
}

The point is to provide familiar Spring Data-like ergonomics without losing Exposed DSL and transaction control. The repository interface stays thin, while query shape and transaction choice still belong to Exposed. Service code can stay simple like a Spring Boot application, and performance-sensitive paths can still drop directly to SQL DSL.

Cache Strategy: Separate Cache Aside from CacheWriteMode

Section titled “Cache Strategy: Separate Cache Aside from CacheWriteMode”

Cache behavior becomes especially visible when it is placed on top of an Exposed repository. Service code calls the same repository API, while the layer underneath can switch among local cache, Redis, near cache, read-through, write-through, and write-behind strategies. exposed-cache gives Caffeine, Lettuce, and Redisson cache repositories common interfaces and configuration.

The benefit is simple. A service does not need to copy cache-aside code everywhere just to improve performance. Read-heavy services can use near cache to reduce DB and Redis round trips. Services with write bursts can use write-behind to shorten request time, as long as the workload can accept eventual persistence.

CacheModeMeaning
LOCALIn-process cache such as Caffeine. Fast, but not shared across JVMs
REMOTERemote cache such as Redis. Shared across instances
NEAR_CACHEL1 local + L2 Redis. Reduces network round trips for hot reads

The easy part to mix up is Cache Aside versus CacheWriteMode. Cache Aside usually means service code handles both DB and cache directly: after saving to DB it invalidates or puts the cache, or on cache miss it reads from DB and fills the cache. The CacheWriteMode in JdbcCacheRepository and R2dbcCacheRepository is different. The repository API owns the DB write as well. Callers may call put() or save() on the cache repository, but the selected mode decides how the backing DB operation is handled.

Read the write modes this way.

CacheWriteModeReadWrite
READ_ONLYReads from DB on miss and caches the resultUpdates cache only. Do not expect DB persistence
WRITE_THROUGHRead-throughWrites to cache and DB synchronously
WRITE_BEHINDRead-throughAccepts the write into a queue, updates cache, and flushes DB asynchronously

The implementation follows that model. Simplified, WRITE_THROUGH handles cache and DB in the same call, while WRITE_BEHIND queues the DB write and updates cache immediately.

when (config.writeMode) {
CacheWriteMode.WRITE_THROUGH -> {
cache.put(key, entity)
writeToDb(id, entity)
}
CacheWriteMode.WRITE_BEHIND -> {
writeBehindQueue.trySend(id to entity).getOrThrow()
cache.put(key, entity)
}
else -> cache.put(key, entity) // READ_ONLY
}

So WRITE_THROUGH does not mean “the cache knows, but the DB does not.” The cache repository is the API shape, but the repository also owns persistence. Mixing this up with Cache Aside easily creates the wrong question: “Do I still need to save to DB separately from the service?”

The benchmark follows the same distinction. bluetape4k-workshop/spring-boot/cache-benchmark compares seven cache profiles with H2 and Redis Testcontainers.

Cache read throughput chart showing Caffeine around 490000 ops per second and Near Cache around 465000 ops per second versus No Cache around 8200
With warmed cache reads, Caffeine and Near Cache were roughly 57-60x higher than No Cache.
ProfileRead ops/svs baseline
No Cache~8,2001x
Caffeine~490,00060x
Redis Cache~43,0005x
Near Cache~465,00057x
Read-Through~42,0005x
Write-Through~41,0005x
Write-Behind~42,0005x

Writes need a separate view. The strategy that wins on reads is not automatically the right write strategy.

Cache write throughput chart showing Write Behind around 24000 ops per second versus No Cache around 8200 and Write Through around 5600
For writes, Write-Behind reached roughly 24,000 ops/s, but it only fits flows that can accept eventual consistency.
ProfileWrite ops/sInterpretation
No Cache~8,200DB only
Caffeine~8,100DB write + local cache
Redis Cache~7,300DB write + Redis SET
Near Cache~7,200DB write + near cache update
Write-Through~5,600DB + Redis synchronous write
Write-Behind~24,000Writes to cache immediately, then flushes DB asynchronously

Write-Behind is fast because it separates DB write time from the request path. The selection rule still has to be clear. It is a better fit for cache warm-up or delayed persistence flows than for order confirmation or other paths that require immediate durable persistence. With a flush queue, shutdown drain, and idempotency, it becomes a workload-specific write optimization instead of a fast but shaky cache shortcut.

Multi-tenancy can be implemented with other ORMs or plain JDBC as well. In practice, tenant identification, context propagation, schema or database selection, cache keys, and tests tend to spread across different layers. I prefer pulling that boundary as close as possible to the Exposed transaction entry point. exposed-workshop/10-multi-tenant shows schema-based tenants, database-per-tenant, Spring MVC, Virtual Threads, WebFlux, and Ktor side by side.

The basic flow is the same. The implementation can vary, but the goal is to keep this flow with as little scattered code as possible.

  1. Identify the tenant from the X-TENANT-ID header.
  2. Propagate the tenant into request context.
  3. Select the schema or datasource when entering a transaction.
  4. Test that a tenant A request cannot read tenant B data.

Context propagation differs by runtime.

RuntimeContextSchema switch
Spring MVCThreadLocalAOP @Before
Virtual ThreadsScopedValueAOP @Before
WebFlux + CoroutinesReactor Context + CoroutineContext.ElementnewSuspendedTransactionWithTenant
KtorCoroutine context/pluginRequest plugin + transaction helper

Once cache is added, there is one more rule:

cache keys must include tenant information.

user:42 can be acceptable in a single-tenant service. In a multi-tenant service, the key needs to include the tenant, for example tenant:korean:user:42. If this rule is kept inside repository or cache helpers, service code does not need to manually assemble tenant-specific cache keys every time. Exposed transaction helpers own schema or database selection, while cache helpers own tenant-key rules. Separating the responsibilities this way keeps multi-tenancy from spreading through the whole application.

Production Integration: Outbox and Idempotency

Section titled “Production Integration: Outbox and Idempotency”

12-production-integration contains examples closer to application behavior than cache strategy. The main ones are HTTP client outbox and idempotency. Payment, notification, and external API integrations often need to handle “change DB state” and “call an external system” together. If those two actions are scattered through service code, retries, duplicate requests, and state transitions become complicated quickly.

The Spring and Ktor examples organize that flow around Exposed transactions. First, a pending outbox record is stored and a unique constraint is placed on idempotencyKey. If a duplicate request arrives, the application returns the existing record instead of creating a new task. Then it calls the gateway and transitions the record to SUCCEEDED, RETRYABLE_FAILED, or PERMANENT_FAILED.

StepWhat to inspect in code
Request validationorderId, amountCents, idempotencyKey normalization and validation
Intent storageInsert PENDING row into spring_payment_outbox
Duplicate preventionidempotency_key unique index, returning existing row on duplicate
External callFake gateway paths for success, retryable failure, and permanent failure
State transitionSUCCEEDED for success, RETRYABLE_FAILED for retryable failure

An outbox table alone is not the useful part. The useful part is that intent storage, duplicate prevention, and state transition can be modeled as one application pattern inside an Exposed transaction. It can be added from the point that needs it, without starting with JPA event listeners or a separate messaging framework.

The realtime outbox examples use the same idea. A notification request stores the domain row and outbox row in the same Exposed transaction. A separate publish step delivers pending events to the SSE or WebSocket hub. Success becomes PUBLISHED; failure becomes FAILED. It looks small in the example, but production code can add retry workers, claim leases, and attempt limits at that boundary. It does not need to begin as a large event platform.

What to Inspect in the Production Examples

Section titled “What to Inspect in the Production Examples”

When reading the code, look less at “how many features are included” and more at “how easily can production behavior be layered on top of Exposed.” exposed-workshop/11-high-performance and 12-production-integration move one step beyond simple CRUD examples.

ExampleQuestion to ask while reading
01-cache-strategiesCan read/write strategy change at the repository API boundary?
02-cache-strategies-coroutinesDoes the same cache strategy survive in suspend flows?
04-benchmarkAre cache hit/miss and write-mode throughput compared by workload?
05/06-cache-strategies-ktorCan Ktor use cache strategies without bloated controller/service code?
12-production-integration/03-04Are HTTP outbox and idempotency tied to an Exposed transaction?
12-production-integration/07-08Are realtime outbox rows stored with domain rows, then handed to a publish step?
12-production-integration/09-10Are observability, readiness, and structured errors shown inside application examples?

The smoke results in 11-high-performance/04-benchmark show why workload matters. On READ_HEAVY workloads, WRITE_THROUGH is about 9.9x faster than NoCache and READ_THROUGH is about 5.5x faster. On WRITE_HEAVY workloads, the gain falls to about 1.1x. That is why I describe cache strategy by read/write ratio before strategy name.

  • For read-heavy workloads, compare Caffeine, Near Cache, and Read-Through using the real request ratio.
  • For write bursts, decide first whether Write-Behind’s consistency model is acceptable.
  • Make sure tenant id enters schema/database selection, cache keys, logs, and metric tags with the same rule.
  • Choose the runtime model first: Spring MVC, Virtual Threads, WebFlux/Coroutines, or Ktor.
  • Before growing outbox/idempotency into a separate framework, check whether an Exposed transaction is enough.
  • Track cache miss, hit, invalidation, and DB fallback metrics in both benchmark results and production metrics.
  • Copy only the combinations that match the service’s read/write ratio and tenant-isolation shape.

The production path for bluetape4k-exposed does not stop at “attach Exposed to Spring Boot.” I split these examples for that reason.

  • Spring Boot auto-configuration gives familiar repository ergonomics while preserving Exposed DSL control.
  • The cache module lets read/write strategies change inside the repository API and improve performance by workload.
  • Multi-tenant examples collect tenant-specific schema or database switching at transaction entry points.
  • Production integration examples add outbox, idempotency, and readiness as small application patterns.
  • Benchmarks force strategy selection to follow workload, not strategy names.

The conclusion of the series is simple. Exposed is a good starting point for handling SQL in a Kotlin-friendly way. bluetape4k-exposed adds thin extensions for performance tuning and production behavior on top of that starting point.

“Thin” does not mean “few features.” It means service code should not repeat cache-aside logic, attach tenant routing in every controller, or rebuild outbox workers from scratch every time. Keep the necessary behavior around repository and transaction boundaries, then confirm the selection with benchmarks and examples.

That fits Exposed well. Exposed does not hide SQL, and it treats transactions explicitly. If cache, multi-tenancy, outbox, and idempotency are attached without losing those strengths, application code becomes simpler and the tuning points become clearer. The core message of Part 5 is that production behavior can start small.

Comments

Leave a note or reaction with your GitHub account.