Skip to content

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

Small robotic builders assembling Core, Coroutines, Logging, and Tests blocks on a Kotlin JVM foundation
Foundation modules are quiet. Remove them, and the same decisions get repeated across the codebase.

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.

Service code flows through core validation, logging, coroutine helpers, and test fixtures
The Part 2 flow is about sharing the same small foundation between request code and proof code.

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.requireContains
import 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.

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.deferredValueOf
import io.bluetape4k.coroutines.map
import 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.KLogging
import io.bluetape4k.logging.coroutines.withCoroutineLoggingContext
import 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.

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.shouldBeEqualTo
import io.bluetape4k.assertions.shouldContainAll
import 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.runSuspendTest
import 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.

HelperExecution model to verifySmell it catches
MultithreadingTesterFixed platform-thread poolWhether shared mutable state, caches, memoizers, locks, and atomic operations survive ordinary thread contention
StructuredTaskScopeTesterJava 21/25 virtual threads and StructuredTaskScopeWhether failure propagation, timeout, scope close, and ScopedValue propagation still behave on virtual-thread paths
SuspendedJobTesterCoroutine Jobs and suspend blocksWhether 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.

ModuleSmellUseful example
bluetape4k-coreif (x.isBlank()) throw ... appears in several placescore README
bluetape4k-coroutinesDeferred, Flow, or cancellation handling is rebuilt in each servicekotlin/coroutines workshop
bluetape4k-loggingRequest ids sometimes disappear across coroutine boundarieslogging README
bluetape4k-assertionsassertEquals, assertTrue, null checks, and collection checks mix different styles across testsassertions README
bluetape4k-junit5Temp files, output capture, or coroutine waits are copied between testsjunit5 README
bluetape4k-testcontainersRedis, Kafka, or DB fixtures start differently in each classtestcontainers README
WebFlux coroutine examplesYou want to see coroutine controllers and handlers in real request pathswebflux-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.

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.