File storage
The codebase ships an object-storage abstraction with two backends — local disk for the no-deps demo, and any S3-compatible service (AWS S3, Silo/MinIO, R2, etc.) for production. Wine-auction images are the worked example: see madrileno.auction for an end-to-end module that uses it.
What you get
Section titled “What you get”src/main/scala/madrileno/utils/storage/ ObjectStore.scala # the trait + StorageKey (validated) + GetResult ADT DiskObjectStore.scala # local filesystem impl S3ObjectStore.scala # AWS SDK v2 impl + S3Config ObjectStoreRuntime.scala # disk(...) / s3(...) factories (s3 is Resource-shaped) SignedUrlTtl.scala # opaque FiniteDuration; per-module knob StorageConfig.scala # pureconfig wrapper for S3ConfigThe contract is put(key, metadata, body) -> bytes written, get(key, ttl, fileName) -> Streamed | Redirected | NotFound, delete(key). put returns the actual byte count, so callers don’t have to count themselves.
Quick start (dev)
Section titled “Quick start (dev)”docker compose up -d brings up Silo (a maintained MinIO fork; upstream’s Docker Hub images were pulled) on 127.0.0.1:59000 (S3 API) and 127.0.0.1:59001 (console — login minioadmin / minioadmin). A silo-init sidecar runs mc mb --ignore-existing local/madrileno once the server is healthy, so the bucket is there on first boot.
.env.sample is wired against that container:
S3_ENDPOINT=http://localhost:59000S3_REGION=us-east-1S3_BUCKET=madrilenoS3_ACCESS_KEY_ID=minioadminS3_SECRET_ACCESS_KEY=minioadminMain.scala always wires the S3 backend:
storageConfig <- Resource.eval(IO.delay(config.at("storage").loadOrThrow[StorageConfig]))objectStoreRuntime <- ObjectStoreRuntime.s3(storageConfig)If you want the disk backend instead (e.g. zero-deps local dev or running tests outside Testcontainers), swap that line for Resource.pure(ObjectStoreRuntime.disk(FsPath("./uploads"), storageConfig.maxFetchBytes)). There’s intentionally no config-driven dispatch — the choice lives in Main, the same way CacheRuntime.scaffeine and RateLimiterRuntime.scaffeine() do.
The abstraction
Section titled “The abstraction”trait ObjectStore { def put(key: StorageKey, contentType: `Content-Type`, body: Stream[IO, Byte]): IO[Long] def get(key: StorageKey, ttl: SignedUrlTtl, fileName: Option[String]): IO[ObjectStore.GetResult] def delete(key: StorageKey): IO[Unit] def presignPut(key: StorageKey, ttl: SignedUrlTtl, contentType: `Content-Type`, contentLength: Long): IO[PresignedPut] def head(key: StorageKey): IO[Option[ObjectStat]] def fetchBytes(key: StorageKey): IO[Option[ByteVector]]}
object ObjectStore { enum GetResult { case Streamed(contentType: `Content-Type`, fileName: Option[String], body: Stream[IO, Byte]) case Redirected(url: Uri) case NotFound }}
final case class ObjectStat(sizeBytes: Long, contentType: `Content-Type`)final case class PresignedPut(url: Uri, signedHeaders: Headers)A few decisions worth flagging:
getreturns an ADT, notResponse[IO]. Each backend picks the access mode that fits.S3ObjectStorereturnsRedirected(a presigned URL — the client downloads from S3 directly, the API never proxies bytes).DiskObjectStorereturnsStreamed(we have the bytes locally; cheaper than redirecting to ourselves). The router maps either result to an http4s response.fileNameis per-fetch, not stored on the object. S3 bakes it into the presign viaresponseContentDisposition; disk passes it through to the router which sets the header. That’s why the storage layer doesn’t persist filenames — the caller knows what to name the download (e.g. the auction’sauction_image.file_namecolumn).putreturns the actual byte count. Disk streams viaFiles[IO].writeAlland readsFiles.sizeafter the write. S3 buffers viabody.compile.to(ByteVector)becauseS3.putObjectrequires a knownContent-Length; the buffer is bounded byhttp.max-request-size(10 MiB default). For larger streamed uploads, swap toS3TransferManager(multipart) — out of scope for the template.presignPutis S3-only. Returns aPresignedPut(url, signedHeaders)bound to the given content-type and size — the client (browser, mobile) uploads directly to the bucket. The signed headers are the headers AWS bound into the SigV4 signature; clients MUST echo them back on the PUT or the bucket will reject withSignatureDoesNotMatch. Returning typedHeadersinstead of just aUriavoids breakage when SDK options (SSE, checksums) introduce new signed headers in the future. Disk raisesUnsupportedOperationExceptionbecause direct uploads only make sense against an HTTP-addressable bucket; in dev with the disk backend, fall back to multipart-through-the-API.headfor commit-after-direct-upload verification. After a client PUTs to a presigned URL, the server callsheadto confirm the object actually landed (and learns its size + content-type) before persisting the row. ReturnsNonefor missing keys.fetchBytesfor analyzers and variant generation. Pulls the object into memory; returnsNonefor missing keys. Bounded bystorage.max-fetch-bytes(10 MiB default) — S3 uses a ranged GET (bytes=0-N) and disk takes from the read stream up to N+1 bytes. If the response (or stream) exceeds the cap,ObjectTooLargeis raised; the heap never materializes more thanmaxFetchBytes + 1bytes regardless of the actual object size. The cap is hard, not a TOCTOU pre-check, so it survives a client overwriting the object viapresignPutbetween the size check and the read.StorageKeyis validated at the opaque-type boundary.StorageKey.applyrejects empty / absolute /..-segment / NUL-containing keys, so the disk backend can’t be coerced into writing outsiderootregardless of the caller.StorageKey.renderis the domain-aware accessor for code that needs the raw string.
Wiring it into a module
Section titled “Wiring it into a module”ApplicationLoader exposes a single objectStore: ObjectStore for the whole app. Modules just declare it as an abstract val and use it.
AuctionModule.scala:
trait AuctionModule extends RouteProvider with AuthRouteProvider /* ... */ { val objectStore: ObjectStore protected lazy val signedUrlTtl: SignedUrlTtl = SignedUrlTtl(5.minutes)
private val auctionImageService = wire[AuctionImageService] // ...}signedUrlTtl is per-module on purpose. Different file types want different presign lifetimes — auction images can be longer-lived than, say, one-time download links — and macwire picks the right SignedUrlTtl by type without ambiguity.
Storing a file
Section titled “Storing a file”Two flows are wired in AuctionImageService. Multipart-through-the-API works against any backend (including the disk backend in dev). Direct upload uses a presigned PUT URL so client bytes never touch the API server — production-friendly with S3.
Multipart upload
Section titled “Multipart upload”The pattern is upload first, persist second, clean up storage on persist failure. Anything else either leaves a row pointing at a missing object (worse: 404 for a row that exists) or leaves an orphan blob with no row.
AuctionImageService.attachImage (simplified):
for { id <- IdGenerator.generateId(AuctionImageId) now <- Clock[IO].realTimeInstant key = StorageKey(s"auctions/$auctionId/images/$id") actualSize <- objectStore.put(key, contentType, content) result <- persistAttached(...).attempt.flatMap { case Right(AttachImageResult.Attached(image)) => IO.pure(AttachImageResult.Attached(image)) case Right(other) => objectStore.delete(key).attempt.as(other) case Left(t) => objectStore.delete(key).attempt *> IO.raiseError(t) }} yield resultThe persist runs inside transactor.inTransaction and locks the auction row via auctionRepository.findForUpdate(auctionId) — this serializes concurrent uploads to the same auction so nextPosition doesn’t race. It also enqueues the analyzer task (see below) atomically with the row insert.
actualSize is what we actually wrote (put returns it). The DB row uses that, not any client-provided Content-Length header — multipart parts frequently lie about size or omit the header entirely.
Direct upload (presign + commit)
Section titled “Direct upload (presign + commit)”S3-only. The client makes two calls to the API plus one PUT to S3:
POST /auctions/{auctionId}/images/presign— server validates ownership, generatesimageId+ storage key, returnsPresignedUploadDto(imageId, url, signedHeaders).- Client
PUTs the bytes to the presigned URL, echoing every header insignedHeaders(the bucket rejects withSignatureDoesNotMatchif any signed header is missing or differs). POST /auctions/{auctionId}/images/commitwith{imageId, fileName}— serverheads the storage key to verify the object landed (and learnsizeBytes+contentTypedirectly from S3), inserts the row, and enqueues the analyzer task. Idempotent: a retry that lands after a successful first commit returnsCommitted(existingRow)instead of failing on the PK.
No upload-then-cleanup pattern here — the bucket is authoritative; if the client never finishes step 2, no row is ever created and the orphan blob is reaped by bucket-level lifecycle rules (set up out-of-band).
Analyzer and variant generation
Section titled “Analyzer and variant generation”Persisting a row enqueues analyzeImageTask (a one-time scheduler task) in the same transaction. The analyzer:
fetchBytesfrom storage (storage.max-fetch-bytescap applies — see the storage utils).Imaging.infoto extract format/dimensions and whether the upload carries EXIF.- If it carries EXIF,
Imaging.convertit — decoding applies the EXIF Orientation, so the re-encode bakes the rotation into the pixels and drops all camera/GPS metadata — and write those canonical bytes back. Re-runsImaging.infosomarkAnalyzedrecords the upright dimensions. EXIF-free uploads are left byte-for-byte. markAnalyzed(id, sizeBytes, width, height, format, now)and enqueues onegenerateVariantTaskperVariantSpec— all in one transaction, so a scheduler outage between mark + enqueue rolls back and the retry replays cleanly.
Each variant task renders its spec (VariantSpec.Thumb → Imaging.cover(256, 256), VariantSpec.Medium → Imaging.resize(1024)), puts the bytes, and inserts the variant row via INSERT … ON CONFLICT (auction_image_id, spec) DO NOTHING so concurrent retries can’t violate the unique constraint. On insert failure the task best-effort-deletes the rendered blob and re-raises so the scheduler retries.
Serving a file
Section titled “Serving a file”AuctionImageService.serveImage returns Option[ObjectStore.GetResult] — None when the row is missing or the image belongs to a different auction; otherwise the storage backend’s result, which the router pattern-matches:
auctionImageService.serveImage(auctionId, imageId).map { case None | Some(ObjectStore.GetResult.NotFound) => error(NotFound, "image-not-found", "Image not found") case Some(ObjectStore.GetResult.Redirected(url)) => Response[IO](Status.SeeOther, headers = Headers(Location(url))) case Some(ObjectStore.GetResult.Streamed(ct, fileName, body)) => val baseHeaders = Headers(ct) val headers = fileName.fold(baseHeaders)(name => baseHeaders.put(`Content-Disposition`("attachment", Map(ci"filename" -> name))) ) Response[IO](Status.Ok, headers = headers, body = body)}For S3, S3ObjectStore.get issues a HeadObject before presigning so a missing key surfaces as GetResult.NotFound (instead of redirecting to a presigned URL that S3 will reject with 404). It costs one extra round-trip; in exchange the API contract holds for both backends.
Position invariants and reordering
Section titled “Position invariants and reordering”The auction_image table has a partial unique index on (auction_id, position) WHERE deleted_at IS NULL. That keeps positions strict at the DB level — concurrent attaches that race on nextPosition get one winner per position; the loser fails on the constraint and the upload-first/persist-second pattern cleans up its blob.
Reordering can’t update positions row-by-row — swapping [A=0, B=1] to [A=1, B=0] would hit the constraint mid-transaction. AuctionImageRepository.bulkSetPositions does a two-phase update inside a single transaction:
- Move every targeted row to a unique negative slot (
-(idx + 1)per loop index) — the partial unique allows it because no other row uses negatives. - Move every targeted row to its final position.
The ImagePosition opaque type still rejects negatives in the domain — the negatives only exist as raw integers inside the repo helper, never as domain values.
Production deployment
Section titled “Production deployment”- Pre-create buckets out-of-band.
ObjectStoreRuntime.s3does not auto-create. Use Terraform/CDK in cloud,mc mbin dev (already wired intosilo-init), or whatever your provider offers. - IAM on AWS. The app needs
s3:PutObject,s3:GetObject,s3:DeleteObjecton the bucket prefix. Presigned URLs inherit the signer’s permissions; the credentials in.envneedGetObjectfor downloads to work after the redirect. - Region constraints. AWS S3 requires
LocationConstraintfor any region other thanus-east-1. Bucket creation isn’t done by the app, so this isn’t a runtime concern — but it’s something to remember in your IaC. - Presigned URL TTL. Set per module via
SignedUrlTtl. Short TTLs are good for security; long TTLs are CDN-cache-friendly. Five minutes is the auction-images default; raise or lower for your file type. max-request-sizeis the upload ceiling.application.confsetsmax-request-size = 10 MiBso multipart uploads aren’t rejected by the globalEntityLimiter. If you need larger files, bump that — or split into chunked / resumable uploads, which is out of scope for this template.
Testing
Section titled “Testing”TestObjectStoreRuntime.inMemory— aRef-backed in-memoryObjectStore. Used byTestApplicationLoaderso route specs don’t need Silo.getreturnsStreamed(no presigned URL needed).presignPutreturns a fakePresignedPut(URL likehttps://example.test/<key>) so service-level tests can exercise the presign/commit/analyzer/variant pipeline without a real bucket.S3ObjectStoreSpec— runs against a Silo Testcontainer pinned toRELEASE.2026-09-16T00-00-00Z. Creates the test bucket explicitly (the app no longer auto-creates) using aResource.fromAutoCloseableS3 client so nothing leaks.AuctionImageServiceSpec— exercises the service against real Postgres + the in-memory store. Covers multipart attach, detach, reorder, serve cross-auction guards, presign + commit (happy + missing-object + wrong-owner + idempotent retry), the analyzer task (happy + already-analyzed no-op + missing row), and variant generation (Thumb256×256,Medium1024-on-long-edge, idempotent re-runs).AuctionImageRouterSpec— full baklava DSL againstTestApplicationLoader. Usespl.iterators.baklava.{Multipart, FilePart}for the upload body so the endpoints land intarget/baklava/openapi/openapi.yml(and the oRPC / simple-HTML outputs).
Things that catch people out
Section titled “Things that catch people out”- The disk backend writes meta first, data second. The sidecar
.metafile holds the renderedContent-Type.getonly returnsStreamedif both files exist. Failure cases:- Crash before meta lands → no files,
getreturnsNotFound. - Crash between meta and data → orphan
.meta, no data file,getreturnsNotFound(thedataExistscheck catches this); a subsequentputfor the same key overwrites the orphan meta. - Crash mid-data-write → orphan
.meta+ truncated data file,getreturnsStreamedwith corrupt bytes. Production deployments should run a sweeper or rely on filesystem-level integrity; this is the demo-grade tradeoff.
- Crash before meta lands → no files,
- S3 uploads buffer.
S3.putObjectrequires a knownContent-Length, so the S3 backend compiles the body to aByteVectorbefore the PUT. That works fine for the demo’s image-sized uploads bounded bymax-request-size = 10 MiB. If you need streamed uploads at larger sizes, replace the body construction withS3TransferManager.uploadPart/ multipart-upload — it’s a meaningful chunk of code and out of scope for this template. StorageKeyis just a string. No leading slash, no scheme, no bucket prefix — just an object key relative to the configured bucket / disk root. Build it from your domain (e.g.s"auctions/$auctionId/images/$id").- The redirect goes to S3, not back to the API. Browsers download directly from S3 using the presigned URL. If your CORS / network setup blocks that, you’ll see a working
303followed by a failed download — the API never sees the second request.