Skip to content

Bluetape4k AWS Part 5: Applying the Real Examples

A 3D illustration of small robotic builders assembling Spring Boot, Ktor, S3, SQS, DynamoDB, StorageService, LocalStack, and Floci blocks into an AWS example lab
The point of a real example is not to list every API. It is to see how far application code can stay simple.

This is Part 5 of the bluetape4k-aws series. Part 1 covered the repository overview and mental model, Part 2 mapped the core modules and AWS service coverage, Part 3 covered Spring Boot and Ktor integration, and Part 4 compared bluetape4k-aws with Spring Cloud AWS.

This post moves into working examples. bluetape4k-aws/examples/ contains Spring Boot 4 and Ktor 3 examples for S3, SQS, DynamoDB, and Exposed. bluetape4k-workshop/aws contains workshop examples that show how application code can hide a storage backend behind one service contract.

The goal is not to skim a README catalog. The useful question is which example a team should read first, which work the library owns, and which choices still belong to the application.

Adoption flow from example application through Spring Boot or Ktor path, bluetape4k AWS helpers, local verification, and AWS services
The examples place framework code, helper code, and local-emulator verification in one visible flow.

S3 is the easiest first example to read. Upload, download, list, delete, presigned URL, and local emulator settings are all visible. A small object API can grow into content-type handling, streaming download, pre-signed URLs, and optional client-side encryption, so the repeated work removed by the library is easy to see.

The Spring Boot example starts with a WebFlux controller that injects S3Operations. The controller does not need to know how to build S3Client, set an endpoint override, enable path-style access, or create a presigner.

Spring Boot S3 example flow from HTTP client through S3DocumentController, S3Operations, optional encryption routes, KmsOperations, and S3 endpoint
The Spring Boot S3 example separates normal object APIs and optional client-side encryption routes inside the same controller.
@RestController
@RequestMapping("/s3/documents")
class S3DocumentController(
private val s3: S3Operations,
private val encryptedS3Provider: ObjectProvider<S3ClientSideEncryptionOperations>,
) {
@PutMapping(consumes = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
suspend fun upload(
@RequestParam bucket: String,
@RequestParam key: String,
@RequestBody bytes: ByteArray,
@RequestHeader(HttpHeaders.CONTENT_TYPE, required = false) contentType: String?,
): S3DocumentUploadResponse {
val response = s3.upload(bucket = bucket, key = key, bytes = bytes, contentType = contentType)
return S3DocumentUploadResponse(bucket = bucket, key = key, eTag = response.eTag())
}
}

The application sees S3Operations. The controller keeps domain API work: read request data, call an operation, and return a response DTO. The same controller also has encrypted routes. It passes tenant metadata and encryption context; S3ClientSideEncryptionOperations handles the envelope encryption work.

LocalStack or Floci settings are explicit:

bluetape4k:
aws:
s3:
region: ap-northeast-2
endpoint-override: http://localhost:4566
path-style-access-enabled: true
presign:
duration: PT15M
client-side-encryption:
enabled: true
key-id: alias/example-s3

For real AWS, remove endpoint-override and use the AWS SDK credential chain. The tests create a bucket and verify upload, download, list, presigned GET/PUT, delete, and deterministic test-KMS backed client-side encryption helpers. This is executable proof that the calls work with the settings.

Read the S3 example in four steps:

  1. PUT /s3/documents uploads bytes. The controller receives the request body as ByteArray and passes the Content-Type header to S3Operations.upload().
  2. GET /s3/documents downloads the same key. The controller returns downloadBytes() as a ResponseEntity<ByteArray> instead of handling SDK streams directly.
  3. GET /s3/documents/objects lists a prefix. In a real service, this often becomes a file listing API or a way to find batch targets.
  4. presigned-get and presigned-put issue time-limited URLs while the object stays in S3.
Terminal window
curl -X PUT \
"http://localhost:8080/s3/documents?bucket=demo-bucket&key=docs/hello.txt" \
-H "Content-Type: text/plain" \
--data-binary "hello bluetape4k-aws"
curl \
"http://localhost:8080/s3/documents?bucket=demo-bucket&key=docs/hello.txt"
curl \
"http://localhost:8080/s3/documents/presigned-get?bucket=demo-bucket&key=docs/hello.txt"

The curl commands are not the point. The point is that SDK details do not leak into the business API. The application still chooses bucket and key, but presigner creation, request builder assembly, local endpoint settings, and path-style access move into helpers and auto-configuration.

The encryption route follows the same rule:

val response = encryptedS3().uploadEncrypted(
bucket = bucket,
key = key,
bytes = bytes,
metadata = mapOf("tenant" to tenant),
encryptionContext = mapOf("tenant" to tenant),
contentType = contentType,
)

Not every service should enable client-side encryption on day one. The example shows where the choice lives. Normal upload/download routes stay simple, while encrypted routes use S3ClientSideEncryptionOperations explicitly. The controller separates normal S3 storage from tenant-context encrypted storage, and the library owns the envelope encryption implementation.

Ktor Connects The Same S3 Work Differently

Section titled “Ktor Connects The Same S3 Work Differently”

The Ktor example does not copy the Spring Boot shape. Ktor connects S3KtorClient to routes, and route code calls S3 REST helpers in a suspend flow.

fun Route.s3DocumentRoutes(
s3: S3KtorClient,
bucket: String,
) {
put("/s3/objects/{key...}") {
val key = call.s3KeyParameter()
val bytes = call.receive<ByteArray>()
val response = s3.putObject(
bucket = bucket,
key = key,
bytes = bytes,
contentType = call.request.headers["Content-Type"],
)
call.respondText("""{"key":"$key","eTag":"${response.eTag}"}""")
}
}

The important detail is the tailcard path parameter. /s3/objects/{key...} preserves object keys that contain slashes, such as docs/hello.txt. The same example includes getObjectStream, putConfigObject, getConfigObject, presignGetObject, and presignPutObject, so a Ktor service can stay coroutine-first without Spring beans.

For LocalStack-style endpoints, configure path-style addressing:

val s3 = s3KtorClientOf(
region = "ap-northeast-2",
endpointOverride = Url("http://localhost:4566"),
addressingStyle = S3KtorAddressingStyle.Path,
)

As Part 3 argued, adapters do not need to look identical. Spring Boot fits auto-configuration and bean injection. Ktor fits routes, plugins, and lifecycle hooks. The same AWS problem should be expressed in each framework’s natural vocabulary.

After S3, SQS is the next useful example. SQS goes beyond wrapping sendMessage. Receive loops, visibility timeout, delete timing, retry, typed payload conversion, and manual acknowledgement all show up.

The Spring Boot SQS example exposes that boundary through @SqsListener.

Spring Boot SQS and SNS example flow from HTTP client through WebFlux API, SQS and SNS operations, fanout, LocalStack, SQS listener container, and ReceivedOrderStore
The SQS/SNS example connects REST publishing, SNS fanout, listener handling, manual acknowledgement, retry, and DLQ setup in one executable flow.
@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()
}
}

Application code focuses on what to process. The listener container owns polling, handler invocation, acknowledgement, failed-message retention, and retry/backoff settings. The example also includes SNS to SQS fanout, DLQ redrive policy, and interceptor event recording, so it shows where metrics and tracing hooks can attach.

DynamoDB is a good repository example:

@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()
override fun keyFromItem(item: Order): Key = Key.builder().partitionValue(item.id).build()
}

This does not pretend DynamoDB is an ORM. Table name, partition key, and enhanced client mapping stay explicit. Repeated coroutine repository flows such as save, findById, delete, scan, and query move into the shared base class.

The Exposed example is where AWS meets the database registry. A service that reads AWS configuration or secrets to build database connections needs aws-exposed plus a framework adapter. In this post, treat it as another “configuration → helper → framework wiring → test” example rather than a full Exposed guide.

StorageService Workshop: Where Service Code Stabilizes

Section titled “StorageService Workshop: Where Service Code Stabilizes”

bluetape4k-workshop/aws/storage-abstraction is the most service-shaped example. The application does not know S3 directly. It depends on StorageService, and Spring profiles switch the backend.

StorageService profile switch from application code to local, S3, and S3 presigned backend implementations
Application code keeps the same StorageService contract while profiles switch the backend.
interface StorageService {
suspend fun upload(key: String, content: ByteArray, contentType: String): String
suspend fun download(key: String): ByteArray
suspend fun getUrl(key: String): String
suspend fun delete(key: String)
}

There are three profiles:

ProfileImplementationUse
localLocalStorageServiceDevelop and test quickly without Docker.
s3S3StorageServiceStore data through S3Client in an S3-compatible backend.
s3-presignedS3PresignedStorageServiceStore data in S3 and return pre-signed GET URLs from getUrl().

The module depends on bluetape4k-aws-spring-boot, but it does not hand every decision to auto-configuration. It first defines the application boundary as StorageService; then S3Config creates profile-specific S3Client and S3Presigner beans. The domain contract stays stable while local files, S3, and pre-signed URLs move into infrastructure implementations.

S3Config uses FlociServer.Launcher.floci for tests and local runs:

@Configuration(proxyBeanMethods = false)
@Profile("s3 | s3-presigned")
class S3Config {
companion object {
val floci: FlociServer = FlociServer.Launcher.floci
}
@Bean
fun s3Client(): S3Client =
S3Client.builder()
.endpointOverride(floci.awsEndpoint)
.region(Region.of(floci.regionName))
.credentialsProvider(staticCredentialsProviderOf(floci.awsAccessKey, floci.awsSecretKey))
.build()
}

Because S3Client is blocking, implementations wrap calls with withContext(Dispatchers.IO). That detail is worth keeping in the example. The example shows both how to use S3 and where blocking I/O belongs in a Kotlin coroutine service.

override suspend fun upload(key: String, content: ByteArray, contentType: String): String =
withContext(Dispatchers.IO) {
ensureBucketExists()
s3Client.putObject(
{ req -> req.bucket(bucketName).key(key).contentType(contentType) },
RequestBody.fromBytes(content)
)
"https://s3.amazonaws.com/$bucketName/$key"
}

To move this pattern into a real service, start by depending on StorageService only:

@Service
class DocumentService(
private val storage: StorageService,
) {
suspend fun saveDocument(documentId: String, bytes: ByteArray): String =
storage.upload(
key = "documents/$documentId.bin",
content = bytes,
contentType = "application/octet-stream",
)
}

That code does not know whether local, s3, or s3-presigned is active. Most controller and service tests can run with the local profile. S3 compatibility can be verified in separate integration tests.

Terminal window
./gradlew :aws-storage-abstraction:test
./gradlew :aws-storage-abstraction:bootRun \
--args='--spring.profiles.active=local'
./gradlew :aws-storage-abstraction:bootRun \
--args='--spring.profiles.active=s3-presigned'

Only the StorageService implementation changes. local writes to {basePath}/{key} and returns a file:// URL. s3 stores the object in an S3-compatible backend and returns a normal S3 URL shape. s3-presigned stores the same object in S3 but generates a time-limited GET URL from getUrl().

override suspend fun getUrl(key: String): String =
withContext(Dispatchers.IO) {
val presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(presignDurationMinutes))
.getObjectRequest { req -> req.bucket(bucketName).key(key) }
.build()
s3Presigner.presignGetObject(presignRequest).url().toString()
}

That difference is small in code and large in production. An internal admin screen can read bytes directly with download(). An external user flow can receive only a pre-signed URL from getUrl(). The service code keeps one contract while deployment and security needs choose the implementation.

The test strategy is also practical. Not every test needs real AWS. The local profile verifies file-system behavior quickly, while S3 profiles verify bucket creation and object I/O through Floci or a LocalStack-compatible endpoint. Real AWS can remain a smoke test or environment check instead of slowing every development loop.

bluetape4k-workshop/aws/s3-spring-cloud is also useful to read. It shows Spring Cloud AWS with S3Template and Spring ResourceLoader.

s3Template.store("my-bucket", "hello.txt", "Hello, S3!")
val resource = resourceLoader.getResource("s3://my-bucket/hello.txt") as WritableResource
val content = resource.inputStream.bufferedReader().readText()

This does not repeat Part 4. It gives concrete code for the Spring Cloud AWS style: Spring resource abstractions and templates. If a Spring Boot application should stay fully inside Spring conventions, that flow is a good fit. If Ktor, coroutine helpers, explicit SDK dependencies, or a StorageService application boundary comes first, the bluetape4k-aws examples are the faster path to read.

You do not need to run everything at once. Pick the first example by the problem in front of you.

GoalFirst exampleWhat to check
S3 upload/download/presigned URLexamples/aws-spring-boot-s3-examplesS3Operations, LocalStack/Floci settings, encryption route
S3 REST client in Ktorexamples/aws-ktor-s3-examplesS3KtorClient, slash-preserving key route, streaming download
SQS listener and retry/ackexamples/aws-spring-boot-sqs-examples@SqsListener, typed payload, manual ack, interceptor event
DynamoDB coroutine repositoryexamples/aws-spring-boot-dynamodb-examplesenhanced async client, table name resolver, repository base class
Separate service code from storage backendbluetape4k-workshop/aws/storage-abstractionStorageService, local/s3/s3-presigned profile switch
Compare the Spring Cloud AWS stylebluetape4k-workshop/aws/s3-spring-cloudS3Template, ResourceLoader, Spring-centered usage

The conclusion is simple. When adopting bluetape4k-aws, do not start with “which AWS API can it call?” Start with “how far can application code stay simple?” S3 controllers, Ktor routes, SQS listeners, DynamoDB repositories, and StorageService profile switches all lead to that same question.

Repeated AWS wiring can move into helpers and adapters. Service code can keep the domain contract. The examples in Part 5 are the starting point for proving that boundary.

Comments

Leave a note or reaction with your GitHub account.