Skip to content
Small robotic workers inspect projects, exposed, aws, image, text, leader, graph, and javers module blocks beside a dependency BOM board in a 3D workbench illustration
The BOM is only the starting point. Real services still need clear choices around DB access, AWS clients, leader election, tokenizers, and image processing.

Importing bluetape4k-dependencies reduces version work, but it leaves a more practical question:

Which modules should this service actually use?

Some services only need Exposed. Some also need AWS. Scheduled work may need Leader. User input can bring in Text, and file upload paths can bring in Image.

Gradle imports the BOM with platform, and bluetape4k modules stay versionless.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-spring-boot-core")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-spring-boot-jdbc")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-spring-boot")
}

The application chooses feature boundaries. The BOM chooses the compatible versions.

application
-> bluetape4k-dependencies 1.3.0
-> projects 1.11.0
-> exposed 1.11.0
-> aws 0.4.0
-> image 0.3.0
-> text 0.2.1
-> leader 0.4.0
Architecture diagram showing a Spring Boot worker, Ktor search API, and image upload API selecting different modules under one bluetape4k-dependencies BOM
The three services share one BOM but select modules according to different responsibilities and resource lifecycles: database work, HTTP input validation, and image streaming.

Suppose a worker service:

  • reads pending jobs from a database
  • writes result files to S3
  • must not run the same job from several instances
  • preprocesses job names or input text with a tokenizer
  • wants write-behind cache state visible through health

The dependency shape can start here:

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-spring-boot-core")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-spring-boot-jdbc")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-cache")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-spring-boot")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-spring-boot")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-redis-lettuce")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-micrometer")
implementation("io.github.bluetape4k.text:tokenizer-korean")
implementation("io.github.bluetape4k.text:text-search")
}

The code shape is service-specific, but the boundary is the important part.

@Scheduled(fixedDelayString = "PT30S")
fun runPendingJobs() {
leader.runIfLeader("invoice-worker") {
transaction {
val jobs = jobRepository.findPending(limit = 100)
jobs.forEach { job ->
val tokens = koreanTokenizer.tokenize(job.title)
val result = processor.render(job, tokens)
s3Client.putObject(job.resultKey, result)
jobRepository.markDone(job.id)
}
}
}
}

Leader is not decoration here. If three workers pick the same row, the deployment turns into an incident. The leader boundary makes the ownership rule visible in code.

If Exposed cache is used, health should expose write-behind state.

{
"status": "OUT_OF_SERVICE",
"components": {
"exposedCache": {
"status": "OUT_OF_SERVICE",
"details": {
"reports": [
{
"name": "invoice-cache",
"mode": "WRITE_BEHIND",
"queueDepth": 128,
"flushJobRunning": false,
"lastFlushError": "Redis connection failed"
}
]
}
}
}
}

A Ktor API has different lifecycle boundaries:

  • HTTP requests enter through Ktor
  • R2DBC or JDBC reads and writes data
  • CloudWatch receives metrics and logs
  • admin APIs or scheduled work may need leader election
  • text input goes through tokenizers or blockword checks
dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k:bluetape4k-ktor-core")
implementation("io.github.bluetape4k:bluetape4k-ktor-observability")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-ktor")
implementation("io.github.bluetape4k.exposed:bluetape4k-exposed-r2dbc")
implementation("io.github.bluetape4k.aws:bluetape4k-aws-ktor")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-ktor")
implementation("io.github.bluetape4k.leader:bluetape4k-leader-redis-lettuce")
implementation("io.github.bluetape4k.text:tokenizer-korean")
implementation("io.github.bluetape4k.text:text-search")
}

The sensitive part in Ktor is lifecycle. AWS clients, metric namespace, log group names, and stream names should not be spread across random call sites.

fun Application.module() {
install(CloudWatchKtorPlugin) {
namespace = "billing-api"
}
install(CloudWatchLogsKtorPlugin) {
logGroupName = "/bluetape4k/billing-api"
logStreamName = environment.config.property("deployment.instanceId").getString()
flushInterval = Duration.ofSeconds(5)
shutdownFlushTimeout = Duration.ofSeconds(5)
}
routing {
post("/documents/search") {
val request = call.receive<SearchRequest>()
// Application-owned input validation boundary
request.query.requireLength(max = 2_000)
val tokens = koreanTokenizer.tokenize(request.query)
call.respond(documentService.search(tokens))
}
}
}

The plugins register the CloudWatch integration and its lifecycle. They do not automatically publish metrics or log events; the application must submit each datum or append each buffered log event explicitly.

Image upload paths add another boundary: the point where a file becomes memory.

dependencies {
implementation(platform("io.github.bluetape4k:bluetape4k-dependencies:1.3.0"))
implementation("io.github.bluetape4k.image:bluetape4k-images")
implementation("io.github.bluetape4k.image:bluetape4k-images-ocr")
implementation("io.github.bluetape4k.image:bluetape4k-images-vips-api")
implementation("io.github.bluetape4k.image:bluetape4k-images-spring-boot")
}

This is convenient for small samples and risky for production files:

// Convenient for small samples. Dangerous for large files.
val bytes = multipartFile.bytes
val text = ocr.read(bytes)

The large-file path should care about streaming and limits before OCR options.

fun extractText(path: Path): OcrResult {
return fileSystem.source(path).buffer().use { source ->
imageReader
.read(source)
.resize(maxWidth = 2_000)
.normalizeForOcr()
.runOcr(language = "kor+eng")
}
}
Service boundaryFirst module to inspectAlso check
Spring Boot foundationbluetape4k-spring-boot-coreSpring Boot major line
Ktor APIbluetape4k-ktor-core, bluetape4k-ktor-observabilityKtor major line, plugin lifecycle
Exposed JDBCbluetape4k-exposed-spring-boot-jdbc, bluetape4k-exposed-jdbctransaction helper, datasource configuration
Exposed R2DBCbluetape4k-exposed-spring-boot-r2dbc, bluetape4k-exposed-r2dbccoroutine boundary, connection lifecycle
AWS integrationbluetape4k-aws-spring-boot, bluetape4k-aws-ktorclient lifecycle, emulator/runtime
Distributed leader electionbluetape4k-leader-spring-boot, bluetape4k-leader-ktorprovider/storage choice, metrics
Korean/Japanese texttokenizer-korean, tokenizer-japanese, text-searchinput length limit, error response
Image/OCRbluetape4k-images, bluetape4k-images-ocrfile size, memory, native runtime

The table is not a request to install everything. Pick the service boundary first, then add the modules required for that boundary. The BOM keeps the chosen modules on the intended version set.

After moving to dependencies 1.3.0, check the runtime lines near the service boundary.

Terminal window
./gradlew compileTestKotlin
./gradlew test
./gradlew dependencyInsight --dependency exposed --configuration runtimeClasspath
./gradlew dependencyInsight --dependency ktor --configuration runtimeClasspath
./gradlew dependencyInsight --dependency aws --configuration runtimeClasspath

Importing the BOM reduces version-number work. It does not finish service design. The useful questions are still:

  • What database boundary does this service own?
  • Who owns AWS client and log/metric lifecycle?
  • May a scheduled job run on several instances at once?
  • Where is user input limited before tokenizers or blockword checks?
  • When does an image file enter memory?

Let the BOM own versions. Let the service boundary decide modules.

If you want the first import path, start with the usage guide.

Comments

Leave a note or reaction with your GitHub account.