Skip to content

Bluetape4k AWS Part 3: Spring Boot and Ktor Integration

Small robotic operators assembling Spring Boot 4 adapters, Ktor 3 adapters, shared AWS contracts, and LocalStack devices on a Kotlin workbench
Framework adapters are not magic that hides SDK clients. They move repeated wiring out of application code.

This is Part 3 of the bluetape4k-aws series. Part 1 covered the repository overview and mental model, and Part 2 mapped the core modules and AWS service coverage. This post moves one layer up: how Spring Boot 4 and Ktor 3 connect to those shared helpers.

The point is not to force both frameworks into the same API shape. Spring Boot integrates through Spring-native pieces such as @ConfigurationProperties, auto-configuration, EnvironmentPostProcessor, and annotation listeners. Ktor integrates through ApplicationPlugin, lifecycle events, route helpers, and coroutine runtimes. They solve the same AWS problems, but application code expects different entry points.

Framework adapters over shared AWS contracts for Spring Boot 4 and Ktor 3
Spring Boot and Ktor adapters connect the same shared helpers to each framework runtime model.

Part 2’s core modules reduced repeated work between the AWS SDKs and coroutines. In a real application, creating a client is still not enough.

  • The application needs a place to read configuration from.
  • Something must own the client lifecycle and close it.
  • Queue listeners need concurrency, visibility timeout, and retry rules.
  • S3 and DynamoDB helpers must reach controllers, routes, and repositories.
  • Local emulators and real AWS endpoints should be switchable through configuration.

bluetape4k-aws-spring-boot and bluetape4k-aws-ktor own that wiring. They are closer to removing framework glue than to reimplementing SDK calls. The Spring Boot module starts from the Spring-friendly experience popularized by Spring Cloud AWS, then reshapes it for Spring Boot 4 and Kotlin coroutine code. The Ktor module connects the same AWS helpers through Ktor 3 plugins and lifecycle events.

The Spring Boot side starts with auto-configuration. When bluetape4k.aws.enabled=true and service settings are present, the module conditionally registers the required beans. Shared settings such as bluetape4k.aws.region, bluetape4k.aws.endpoint-override, and credentials web identity configuration are reused across services. Service-specific settings override the shared defaults when present.

The main features fall into three groups.

AreaProvided featuresApplication code no longer repeats
Client/templateS3, SNS, SES, SQS, DynamoDB, KMS clients and coroutine templatesSDK builders, async bridges, default options
Listener/runtime@SqsListener, listener containers, retry/visibility/ack settingsReceive loops, delete-on-success, failure visibility handling
ConfigurationS3 config, Secrets Manager, Parameter Store, Exposed database registryRemote property loading, secret binding, database pool wiring
Spring Boot 4 AWS integration sequence from properties and auto-configuration to application beans, AWS helpers, and AWS runtime
In Spring Boot, startup auto-configuration creates framework beans first, and application code calls injected operations.

The Spring Boot S3 example shows the direction clearly. The controller injects S3Operations and uses upload, download, list, and presigned URL operations as suspend functions or Flow.

@RestController
@RequestMapping("/s3/documents")
class S3DocumentController(
private val s3: S3Operations,
) {
@PutMapping(consumes = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
suspend fun upload(
@RequestParam bucket: String,
@RequestParam key: String,
@RequestBody bytes: ByteArray,
): S3DocumentUploadResponse {
val response = s3.upload(bucket = bucket, key = key, bytes = bytes)
return S3DocumentUploadResponse(bucket, key, response.eTag())
}
@GetMapping("/objects")
fun listObjects(@RequestParam bucket: String): Flow<S3DocumentObjectResponse> =
s3.listFlow(bucket = bucket)
.map { S3DocumentObjectResponse(key = it.key(), size = it.size()) }
}

The important part is that the controller does not need to know about the S3 client builder. Endpoint override, path-style access, presigned URL duration, client-side encryption, and TransferManager usage belong to configuration and auto-configuration. The controller keeps the domain API.

SQS has a larger gap. When application code talks to SQS directly, it keeps dealing with receive loops, visibility timeout, delete timing, retry, and shutdown drain. The Spring Boot adapter moves that work to the framework boundary with @SqsListener and coroutine listener containers.

@Component
class OrderMessageListener(
private val store: ReceivedOrderStore,
) {
@SqsListener(queue = "\${example.aws.sqs.listener-queue:orders}", maxMessages = 1, waitTimeSeconds = 1)
fun handle(message: String) {
store.record(message)
}
@SqsListener(id = "typed-order-listener", queue = "\${example.aws.sqs.typed-listener-queue:typed-orders}")
suspend fun handleTyped(order: OrderPayload, acknowledgement: SqsAcknowledgement) {
store.record(order)
acknowledgement.acknowledge()
}
}

A listener method can receive String, AWS SDK Message, SqsReceivedMessage, or a typed payload. If it accepts SqsAcknowledgement, it switches to manual ack. A successful handler deletes the message; an exception leaves it visible for later redelivery. error-visibility-timeout-seconds and retry settings make the redelivery timing explicit.

DynamoDB builds coroutine repositories over DynamoDbEnhancedAsyncClient. The example repository defines only the table name and key mapping. Repeated operations such as save, findById, scan, and query stay in AbstractCoroutinesDynamoDbRepository.

@Repository
class OrderRepository(
enhancedClient: DynamoDbEnhancedAsyncClient,
tableNameResolver: DynamoDbTableNameResolver,
) : AbstractCoroutinesDynamoDbRepository<Order, String>(
enhancedClient = enhancedClient,
tableNameResolver = tableNameResolver,
entityClass = Order::class.java,
) {
override val tableName: String = "orders"
override fun keyFromId(id: String): Key =
Key.builder().partitionValue(id).build()
}

The module still does not take over table creation. Table provisioning belongs to migrations, deployment automation, or test setup. The library reduces repository repetition and helps with naming rules such as table prefixes.

Remote configuration is another important Spring Boot difference. S3 objects, Secrets Manager secrets, and Parameter Store parameters can be read as startup Environment sources, with lazy refresh when the application needs it. @SecretsValue and @ParameterStoreValue are small annotations layered over Spring @Value. Values such as database passwords can then feed into the bluetape4k-aws-exposed registry.

The Ktor module does not imitate Spring Boot auto-configuration. It uses the Ktor-native pieces: install(...), route helpers, and application lifecycle events. AwsKtorCore establishes shared defaults such as region, endpoint, credential provider, and signing clock. S3KtorClient, SqsConsumer, and DynamoDbKtorPlugin inherit those defaults when needed.

Ktor 3 AWS integration sequence from plugin installation and lifecycle events to route helpers, AWS helpers, and AWS runtime
In Ktor, plugin installation and lifecycle events open and close runtime pieces while route code calls explicit helpers.

For S3, S3KtorClient sits on top of AwsSigV4Plugin. It calls the S3 REST API through Ktor HttpClient and provides path-style endpoints, presigned URLs, streaming download, config objects, server-side encryption headers, and client-side envelope encryption helpers.

fun Route.s3DocumentRoutes(
s3: S3KtorClient,
bucket: String,
) {
put("/s3/objects/{key...}") {
val key = call.parameters.getAll("key").orEmpty().joinToString("/")
val bytes = call.receive<ByteArray>()
val response = s3.putObject(
bucket = bucket,
key = key,
bytes = bytes,
contentType = call.request.headers["Content-Type"],
)
call.respondText("""{"bucket":"$bucket","key":"$key","eTag":"${response.eTag}"}""")
}
get("/s3/presigned-get/{key...}") {
val key = call.parameters.getAll("key").orEmpty().joinToString("/")
val presigned = s3.presignGetObject(bucket, key, Duration.ofMinutes(15))
call.respondText("""{"method":"${presigned.method}","url":"${presigned.url}"}""")
}
}

Ktor SQS uses an ApplicationPlugin instead of Spring annotation listeners. SqsConsumer starts the polling runtime on application start and drains receive loops plus in-flight handlers on stop. Inside the handler, application code can call ack() or nack(timeoutSeconds) directly. Interceptors and observers can watch receive, invoke, ack, and nack events.

fun Application.sqsExampleModule(
sqsClient: SqsAsyncClient,
queueUrl: String,
) {
install(SqsConsumer) {
sqsAsyncClient = sqsClient
this.queueUrl = queueUrl
coroutines = 2
maxMessages = 10
deleteOnSuccess = false
onMessage<String> { body ->
if (body.startsWith("retry-once:")) {
nack(timeoutSeconds = 0)
return@onMessage
}
handleOrder(body)
ack()
}
}
}

DynamoDB binds the AWS Kotlin SDK client to the Ktor lifecycle. DynamoDbKtorPlugin closes a plugin-created client on shutdown and leaves an application-owned client alone. In local development, autoCreateTables=true with table { } can create missing tables, but it does not replace production schema verification or migration.

fun Application.dynamoDbExampleModule(
endpointUrl: Url,
region: String,
credentialsProvider: CredentialsProvider,
) {
install(DynamoDbKtorPlugin) {
this.endpointUrl = endpointUrl
this.region = region
this.credentialsProvider = credentialsProvider
autoCreateTables = true
table(
tableName = "orders",
keySchema = listOf(partitionKeyOf("id")),
attributeDefinitions = listOf(stringAttrDefinitionOf("id")),
)
}
}

Exposed follows the same rule. AwsExposedPlugin binds the bluetape4k-aws-exposed registry to the Ktor lifecycle, and routes run Exposed JDBC work through call.awsExposedTransaction { ... }. Unlike the Spring Boot adapter, it does not duplicate Secrets Manager or Parameter Store loaders. Instead, it opens an AwsDatabaseSettingsResolver hook so the application can choose when and how to read remote configuration.

fun Application.exposedExampleModule(database: ExampleDatabaseConfig) {
install(AwsExposedPlugin) {
defaultDatabase {
url = database.url
driverClassName = database.driverClassName
username = database.username
password = database.password
}
}
routing {
get("/exposed/orders") {
val orders = call.awsExposedTransaction {
OrderRepository.findAll()
}
call.respond(orders)
}
}
}

Part 3 is not a deep guide to every example. It is a map. Part 5 will cover the real application flow from examples/ and bluetape4k-workshop/aws in more detail. Here, the useful question is which framework and service combinations are already verified as working examples.

Runnable examples by framework and AWS service for Spring Boot and Ktor
The examples show framework-specific wiring. Part 5 follows the application flow in detail.
ExampleFrameworkWhat it shows
aws-spring-boot-s3-examplesSpring BootS3Operations, presigned URL, object list, optional client-side encryption
aws-spring-boot-sqs-examplesSpring BootSqsOperations, SNS fanout, @SqsListener, typed payload, retry listener
aws-spring-boot-dynamodb-examplesSpring BootAbstractCoroutinesDynamoDbRepository, Enhanced Async table, controller API
aws-spring-boot-exposed-examplesSpring BootAWS-backed Exposed registry, DataSource/Database wiring
aws-ktor-s3-examplesKtorS3KtorClient, streaming/config object, presigned URL
aws-ktor-sqs-examplesKtorSqsConsumer, manual ack/nack, interceptor, observer
aws-ktor-dynamodb-examplesKtorDynamoDbKtorPlugin, auto-create table, repository helper
aws-ktor-exposed-examplesKtorAwsExposedPlugin, suspend transaction helper

The point is not that the repository has many examples. The point is that the same AWS service should look different in Spring Boot and Ktor code. Beans and annotations are natural in Spring Boot. Plugin installation and route-local helpers are natural in Ktor. bluetape4k-aws does not erase that difference; it reduces repeated wiring in each framework’s own vocabulary.

Framework adapters reduce repetitive code, but they do not remove AWS operational concerns. Some decisions should stay visible.

  • Secrets Manager and Parameter Store values affect application startup order. Decide what fallback is allowed when a remote source is slow or unavailable.
  • SQS delete-on-success and manual ack have different meanings. Visibility settings should make redelivery timing explicit when a handler throws.
  • SNS HTTP endpoint messages need more than parsing. Signature, certificate chain, and expected TopicArn validation are part of the application security boundary.
  • DynamoDB table auto-create is a local/test convenience. Production schema management belongs to migration or deployment automation.
  • Ktor must distinguish application-owned clients from plugin-owned clients. The plugin does not close a client that the application injected.
  • LocalStack, Floci, and Testcontainers speed up the development loop, but they do not replace AWS IAM, network policy, quota, or encryption policy checks.

These edges do not mean the library is incomplete. They mark the boundary between framework adapter work and application operating policy. If that boundary gets blurry, convenience turns into production guesswork.

Spring Boot and Ktor integration is not about forcing the same AWS helper into one shared API. In Spring Boot, auto-configuration, property binding, EnvironmentPostProcessor, and annotation listeners remove repetition. In Ktor, ApplicationPlugin, lifecycle-bound runtime, SigV4 clients, and route helpers remove repetition.

The Part 3 rule is simple. If the service is Spring Boot, attach AWS helpers through Spring’s wiring model. If the service is Ktor, attach them through Ktor’s plugin and lifecycle model. Core modules organize the SDK and coroutine boundary underneath. Examples prove that the combination actually runs on local emulators.

Part 4 will compare Spring Cloud AWS and bluetape4k-aws directly. The goal is not to count who has more features. The goal is to explain why Spring-centered integration and Kotlin/Ktor-aware integration make different tradeoffs.

Comments

Leave a note or reaction with your GitHub account.