Stage 7 · Master
File Storage
Object Storage with S3-Compatible APIs
A design specification for Meridian attachments: keep blobs out of transactional tables, use S3-compatible object stores as the byte layer, and make every object key tenant-aware.
Why Blobs Do Not Belong in Postgres
Relational databases are optimized for indexed records, transactional integrity, and set-oriented queries. They are not optimized for storing a large and constantly growing collection of user-uploaded binary objects. Putting attachments into Postgres turns every backup, replication stream, and recovery exercise into a blob-transfer problem; large writes amplify WAL volume, hot tables become heavier to vacuum, and ordinary application queries start sharing the same storage system with a workload that has entirely different latency and throughput characteristics.
Service-local disk is not a credible alternative in a Kubernetes-oriented microservice system. Containers are disposable, pods reschedule, and horizontal scaling creates multiple filesystem instances. A complaint attachment or payment receipt needs durability independent of the lifecycle of one replica. Object storage exists precisely for this category of data: large opaque blobs addressed by key, served efficiently, and managed with lifecycle policies separate from relational query concerns.
| Storage option | Why teams try it first | Why Meridian should choose or reject it |
|---|---|---|
| Postgres BYTEA or large objects | Only one backing system to operate | Rejected because binary blobs distort database cost, backup size, and operational focus |
| Local disk on the service | Very easy to prototype | Rejected because pods are ephemeral and scaling multiplies inconsistent file locations |
| S3-compatible object storage | Requires a separate client and metadata model | Chosen because it specializes in durable blob storage while the database keeps business meaning |
A complaint attachment record answers who uploaded the file, which tenant owns it, and which complaint it belongs to. The object store answers only whether the bytes exist at a key.
What S3-Compatible Actually Means
'S3-compatible' is an API contract, not a commitment to one cloud vendor. It means the application speaks the de facto S3 object protocol—put object, get object, head object, multipart upload, presigned requests—and can therefore target MinIO in local development, AWS S3 in one deployment, or another compatible provider later without redesigning attachment semantics. This separation matters in a course because it teaches the architectural interface rather than one provider's control plane.
Meridian does not yet contain any S3 client, bucket configuration, or upload flow. The real repository is still in scaffolding, so this lesson defines the planned boundary that future complaint attachments and billing receipt uploads should use. The shape of that boundary matters now because it determines config surface, security model, and the later decision to move large transfers off the service request path.
Tenant-Scoped Object Keys
In a multi-tenant system, the object key is part of the isolation model. A key should never be caller-chosen because paths are easy places for cross-tenant leakage and accidental collisions. Every uploaded file path in Meridian must include tenant_id, and the rest of the path should encode enough business context to make debugging and cleanup intelligible. The same discipline already appears in SQL rows through the tenant_id column; object storage needs the equivalent rule at the key level.
- Place
tenant_idat the front of the key so bucket listings and lifecycle sweeps can remain tenant-aware. - Generate attachment identifiers server-side so metadata rows and object keys stay aligned even if the original filename changes later.
- Treat the filename suffix as descriptive only; authorization must rely on tenant and attachment metadata, not path parsing alone.
Presigned URLs Separate Policy from Transport
The most scalable upload/download architecture is often not 'the service streams all bytes forever.' It is 'the service decides whether the transfer is allowed, then object storage handles the transfer itself.' Presigned URLs create that boundary. The service authenticates the caller, resolves tenant ownership, chooses the object key, sets a narrow expiry window, and returns a capability that allows only one specific object operation for a short time. The service keeps policy. Object storage keeps transport.
apps/community-service/cmd/community-service/main.gomodifyResponsibility: Initialize the object storage client, load bucket and presign settings from config, and inject the dependency into attachment services.
Why now: Storage infrastructure should be visible at composition time, not created ad hoc inside handlers.
Connects to: Builds on the existing service bootstrap that currently wires only health endpoints.
apps/community-service/internal/storage/object_store.gocreateResponsibility: Wrap S3-compatible client operations such as PutObject, HeadObject, DeleteObject, and PresignPutObject behind one service-local adapter.
Why now: The rest of community-service should speak in domain terms like 'create upload intent' rather than raw SDK calls.
Connects to: Consumes configuration values loaded through libs/platform/config-backed service config.
apps/community-service/internal/attachments/upload_intents.gocreateResponsibility: Create pending attachment intents, generate tenant-scoped object keys, and finalize objects after a successful direct upload.
Why now: Presigned URLs change the workflow from one request to a multi-step protocol. That protocol needs explicit domain code.
Connects to: Extends the streaming attachment design introduced in the previous lesson.
apps/community-service/internal/repository/complaint_attachment_repository.gomodifyResponsibility: Persist pending, uploaded, failed, and deleted attachment states together with upload expiry and object key metadata.
Why now: A direct-upload workflow introduces durable intent and cleanup state that a simple 'uploaded' boolean cannot represent.
Connects to: Uses the same tenant-scoped attachment table introduced when complaint attachments were first modeled.
Metadata, Lifecycle, and Security
Object storage does not understand product semantics. It does not know which complaint is sensitive, whether a payment receipt belongs to the resident making the request, or whether a file has been soft-deleted. Those rules belong in application metadata. For that reason, buckets remain private, object access is granted through short-lived presigned URLs, and services authorize against relational metadata before issuing either upload or download capabilities.
Lifecycle management is part of the design, not an afterthought. Browsers abandon uploads, clients upload the bytes but never call finalize, and some objects must eventually be deleted after complaint retention windows expire. A mature object-storage design therefore tracks states such as pending_upload, uploaded, and deleted, sweeps expired pending intents, and uses object metadata or checksums to verify that the stored blob matches what the service expected. Without this discipline, storage becomes a growing pile of orphaned tenant data that nobody can safely interpret.
That is why expiry windows must be short, object scope must be narrow, and the service must log which tenant and attachment record caused the URL to be issued.
The Decision
Meridian's planned file-storage architecture uses S3-compatible object storage for complaint attachments and, by the same reasoning, future billing receipt blobs. The database keeps tenant-scoped metadata; the object store keeps bytes; and presigned URLs let the service enforce policy without remaining in the byte path longer than necessary. None of that integration exists in meridian yet, but the course defines it now so later implementation work follows a production-safe design instead of growing out of a local-disk prototype.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.