adapter:outbound:objectstorage — design-decision reference
Object-storage outbound (driven) adapter. Package root:
dev.caskeleton.adapter.outbound.objectstorage. Implements the application-core port
dev.caskeleton.application.storage.ObjectStoragePort behind an opt-in @ConditionalOnProperty
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient).
The allowed/forbidden dependency policy is owned by src/build.gradle's
allowedProjectDependencies['adapter:outbound:objectstorage'] (SSOT). Module rules live in
CLAUDE.md; this document records the design rationale lifted out of the code
comments.
Module overview
An opt-in blob-storage adapter placed behind an application-core port. Two backends select the
same ObjectStoragePort by configuration:
- filesystem (default,
matchIfMissing) —FilesystemObjectStorageAdapterwrites blobs underca-skeleton.objectstorage.base-path. No external service, so the local profile just works. Thelocationin theStoredObjectreceipt is thefile://URI. - s3 —
S3ObjectStorageAdapteruses the AWS SDK v2S3Client. The client's endpoint override + path-style access (wired inObjectStorageConfig) make the same code work against real AWS S3 (leaveendpointunset) and MinIO (endpoint=http://localhost:9000). Thelocationis ans3://bucket/keyURI.
Selector: ca-skeleton.objectstorage.backend=filesystem|s3 (filesystem is the default). Exactly one
ObjectStoragePort bean is contributed, so a fork injects the port without knowing the active
backend.
The port contract (framework-neutral)
ObjectStoragePort is a minimal, framework-neutral surface:
StoredObject put(String key, byte[] content, String contentType)— store/overwrite.Optional<byte[]> get(String key)— read,empty()when absent.void delete(String key)— idempotent delete.boolean exists(String key).
Keys are caller-supplied, backend-relative, opaque strings. Implementations reject a blank key or a
key that escapes the backend namespace (path traversal) with IllegalArgumentException — the
filesystem adapter normalises the resolved path and checks it still starts with the base directory.
The port intentionally exposes no streaming or presigned-URL surface; a fork adds those when a
concrete feature needs them. Raw external SDK types never cross the port (B7) — the adapter returns
only StoredObject / byte[] / primitives.
AWS SDK versioning (why the BOM is imported at module scope)
software.amazon.awssdk:* versions are not managed by the Spring Boot BOM and this repo has no
version catalog. The AWS SDK v2 BOM is therefore imported as a dependencyManagement platform in
this module's build.gradle using the root ext.awsSdkVersion SSOT (set in src/build.gradle),
exactly like the grpc module imports grpc-bom. This keeps the strict-locking blast radius to this
module — the shared root dependencyManagement block stays awssdk-free.
IO-failure handling
Filesystem IO failures are wrapped in the shared-contract DependencyFailureException
(dependencyName="objectstorage") so a fork's web error handler classifies them uniformly with the
other outbound dependencies. Illegal/blank keys are IllegalArgumentException (a caller bug, not a
dependency failure). The S3 adapter maps NoSuchKey / HTTP 404 to Optional.empty() / false.
Tests
FilesystemObjectStorageAdapterTest—@TempDirput/get/delete/exists round-trip, overwrite, idempotent delete, path-traversal + blank-key rejection.S3ObjectStorageAdapterTest— key/metadata/URI mapping against a mockedS3Client(no network).S3ObjectStorageAdapterIT— real S3-protocol round-trip against Testcontainers MinIO; skipped automatically when Docker is unavailable (@Testcontainers(disabledWithoutDocker = true)).
cd src
./gradlew :adapter:outbound:objectstorage:check