Bluetape4k Projects Part 2: Core, Coroutines, Logging, Tests

This is Part 2 of the bluetape4k-projects series. Part 1
covered why this repository acts as the shared foundation for the bluetape4k ecosystem. This post looks at the modules
developers tend to reach for first: core, coroutines, logging, assertions, junit5, and testcontainers.
At first glance, these modules look a little boring. They are not an API server users can call, a dashboard people can
open, or a feature that feels done as soon as the dependency is added. But after a few services, they keep coming back:
validate input, combine coroutine results, keep requestId values in logs, and prove the same path in tests.
When each service solves that work on its own, the codebase gets noisy. Validation helpers drift, log context disappears across some coroutine boundary, and every test class starts containers in a slightly different way. At first this looks harmless. Eventually each service grows its own tiny framework. It may not call itself a framework, but it still sends the maintenance bill.

core: Small Rules Get Annoying When They Drift
Section titled “core: Small Rules Get Annoying When They Drift”bluetape4k-core is not a grand module. That is why it shows up so often. It collects the pieces you would otherwise
write by hand: require* validation helpers, codecs, UTF-8 safe string handling, ranges, collections, wildcard
matching, hashing, and java.time extensions.
The most common entry point is validation. Contract-backed helpers such as requireNotBlank, requireGe, and
requireContains normalize caller input failures as IllegalArgumentException. When this rule is scattered, each
service has to rediscover whether blank values are allowed, what error type to throw, and how much detail the message
should carry.
import io.bluetape4k.support.requireContainsimport io.bluetape4k.support.requireNotBlank
fun createUser(email: String?) { val validEmail = email .requireNotBlank("email") .requireContains("@", "email")
// validEmail is now a non-null String.}This kind of code looks easy to write once. The problem is that it is never once. API input, configuration values,
fixture names, image tags, and cache keys all need similar checks. core removes those small differences. Fewer small
differences make the real larger differences easier to find.
coroutines: async Is Easy; Cleanup Is Not
Section titled “coroutines: async Is Easy; Cleanup Is Not”Writing async { ... } is not the hard part. The hard part comes next: which Deferred result wins, whether failed
work cancels the rest, whether Flow processing keeps order, and how far coroutine context should travel.
DeferredValue covers asynchronous work that starts immediately but must be read through both await() and blocking
value. Helpers such as awaitAnyAndCancelOthers() cover winner-takes-result paths where the remaining work must be
cleaned up. Rebuilding this in every service creates implementations that look similar until an incident proves they
are not.
import io.bluetape4k.coroutines.deferredValueOfimport io.bluetape4k.coroutines.mapimport kotlinx.coroutines.delay
val source = deferredValueOf { delay(100) 21}
val doubled = source.map { it * 2 }These helpers are not about making coroutine code look fancy. They are about avoiding a different interpretation of
failure and cancellation in every service. If every service decides where to await, when to cancel failed work, and
how much Flow ordering to preserve, async code becomes harder to reason about than it needs to be. The kotlin/coroutines and
spring-boot/webflux-coroutines workshops show the path from learning examples to real HTTP request wiring.
logging: Logs Are What People Read During Incidents
Section titled “logging: Logs Are What People Read During Incidents”Most days, logs sit in the background. During an incident, they become the text everyone reads. If requestId or
orderId is missing, the reader spends time reconstructing context instead of operating the service.
bluetape4k-logging makes SLF4J less rough in Kotlin code. It includes KLogging, KotlinLogging, lambda-based lazy
logging, MDC helpers, coroutine MDC propagation, and channel-based asynchronous logging. The rule is simple: do not build
strings for disabled log levels, and keep traceId, spanId, and orderId across coroutine boundaries.
import io.bluetape4k.logging.KLoggingimport io.bluetape4k.logging.coroutines.withCoroutineLoggingContextimport io.bluetape4k.logging.debug
class OrderService { companion object: KLogging()
suspend fun process(orderId: String) = withCoroutineLoggingContext("orderId" to orderId) { log.debug { "Processing order: $orderId" } }}The helper looks small, but logging style becomes an operational habit. Habits that drift usually become visible only during incidents, which is the most expensive time to notice them. Logs are not prose for quiet days. They are text that must still make sense under pressure.
tests: Fixtures Get Duplicated Quickly
Section titled “tests: Fixtures Get Duplicated Quickly”Test code is less visible than production code, but duplication often grows faster there. It creates temp folders, changes system properties and restores them, captures stdout, waits for coroutine conditions, and starts Redis or PostgreSQL containers. Do that thirty times and the test code needs as much care as the production code it verifies.
bluetape4k-assertions handles the sentence shape that shows up in almost every test. It borrows the Kluent-style idea
of making assertions read like plain English in Kotlin: actual shouldBeEqualTo expected,
items shouldContainAll expected, and name.shouldNotBeNull(). The point is not clever syntax for its own sake. The
point is keeping tests from mixing assertEquals, assertTrue, null checks, and collection checks in a different dialect
in every class.
import io.bluetape4k.assertions.shouldBeEqualToimport io.bluetape4k.assertions.shouldContainAllimport io.bluetape4k.assertions.shouldNotBeNull
found.shouldNotBeNull()found.name shouldBeEqualTo "TestItem"names shouldContainAll listOf("alpha", "beta")bluetape4k-junit5 groups Stopwatch, TempFolder, Output Capture, Random/Faker, system property restoration, coroutine
waiting, and stress testing helpers. bluetape4k-testcontainers groups Redis, Kafka, PostgreSQL, MongoDB, Neo4j,
WireMock, LocalStack-family servers, and more behind wrappers and singleton launchers.
The key value is not merely “we can start a container.” The value is preventing every test class from starting it in a slightly different way. Singleton launchers and system property export let Spring Boot tests, HTTP mock tests, and data integration tests share the same connection rules. Once fixtures get quiet, tests can start explaining the real failure.
import io.bluetape4k.junit5.coroutines.runSuspendTestimport io.bluetape4k.testcontainers.storage.RedisServer
class CacheTest { private val redis = RedisServer.Launcher.redis
@Test fun `cache entry expires`() = runSuspendTest { val redisUrl = redis.url // exercise coroutine path against shared fixture }}Concurrency tests follow the same idea. Helpers such as MultithreadingTester, StructuredTaskScopeTester, and
SuspendedJobTester centralize rounds, worker counts, and cleanup behavior.
| Helper | Execution model to verify | Smell it catches |
|---|---|---|
MultithreadingTester | Fixed platform-thread pool | Whether shared mutable state, caches, memoizers, locks, and atomic operations survive ordinary thread contention |
StructuredTaskScopeTester | Java 21/25 virtual threads and StructuredTaskScope | Whether failure propagation, timeout, scope close, and ScopedValue propagation still behave on virtual-thread paths |
SuspendedJobTester | Coroutine Jobs and suspend blocks | Whether suspend functions survive concurrent execution on a dispatcher, including cancellation, collected failures, and shared state |
The point is not to use all three everywhere. Synchronous APIs often need only MultithreadingTester. Virtual-thread
specific paths are better covered by StructuredTaskScopeTester. Suspend APIs should be tested with
SuspendedJobTester. Forcing coroutine code into a platform-thread stress test may still pass, but it proves the wrong
thing. The wrong test tool is loud when it fails and not very useful when it passes.
Test code should be less creative so production code can be more honest.
When to Reach for Each Module
Section titled “When to Reach for Each Module”| Module | Smell | Useful example |
|---|---|---|
bluetape4k-core | if (x.isBlank()) throw ... appears in several places | core README |
bluetape4k-coroutines | Deferred, Flow, or cancellation handling is rebuilt in each service | kotlin/coroutines workshop |
bluetape4k-logging | Request ids sometimes disappear across coroutine boundaries | logging README |
bluetape4k-assertions | assertEquals, assertTrue, null checks, and collection checks mix different styles across tests | assertions README |
bluetape4k-junit5 | Temp files, output capture, or coroutine waits are copied between tests | junit5 README |
bluetape4k-testcontainers | Redis, Kafka, or DB fixtures start differently in each class | testcontainers README |
| WebFlux coroutine examples | You want to see coroutine controllers and handlers in real request paths | webflux-coroutines workshop |
The adoption order is straightforward. Start with core for inputs and small utilities. Add coroutines when async
composition appears. Add logging when request context must survive. Add assertions when test sentences start using
several assertion dialects. Bring in junit5 and testcontainers when that path needs repeatable fixture and dependency
proof. The easiest way to use this repository is to pick only the piece that removes the repeated
code in front of you.
Source Links
Section titled “Source Links”- Core README: bluetape4k/core/README.md
- Coroutines README: bluetape4k/coroutines/README.md
- Logging README: bluetape4k/logging/README.md
- Assertions README: testing/assertions/README.md
- JUnit 5 README: testing/junit5/README.md
- Testcontainers README: testing/testcontainers/README.md
- Workshop: kotlin/coroutines
- Workshop: spring-boot/webflux-coroutines
Closing
Section titled “Closing”The modules in Part 2 will not look like ready-to-use product features. They still show up whenever a service validates input, crosses a coroutine path, preserves log context, and proves the same path in tests.
When the small foundation is stable, the application layer can focus on application behavior. When it is not, controllers, services, and tests duplicate their own tiny frameworks. They may not call themselves frameworks, but they are still hard to build and expensive to maintain. These modules are not loud when they exist. They get loud when they are missing.
Next, we move to I/O, serialization, HTTP, and encryption. Once bytes and wire formats appear, small mistakes can travel surprisingly far.
Comments
Leave a note or reaction with your GitHub account.