Files
clean-architecture-backend-…/src/adapter/outbound/objectstorage

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) — FilesystemObjectStorageAdapter writes blobs under ca-skeleton.objectstorage.base-path. No external service, so the local profile just works. The location in the StoredObject receipt is the file:// URI.
  • s3S3ObjectStorageAdapter uses the AWS SDK v2 S3Client. The client's endpoint override + path-style access (wired in ObjectStorageConfig) make the same code work against real AWS S3 (leave endpoint unset) and MinIO (endpoint=http://localhost:9000). The location is an s3://bucket/key URI.

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@TempDir put/get/delete/exists round-trip, overwrite, idempotent delete, path-traversal + blank-key rejection.
  • S3ObjectStorageAdapterTest — key/metadata/URI mapping against a mocked S3Client (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