Stage 7 · Master
File Storage
Multipart Uploads as Streams, Not Buffers
A design specification for Meridian complaint attachments: treat multipart as a streaming transport, keep tenant checks ahead of the byte path, and avoid turning file size into heap pressure.
Why Upload Endpoints Fail Under Load
File uploads look simple because browsers hide the protocol details. In reality, an upload endpoint is a resource-allocation problem: a remote client is asking the service to accept an arbitrarily large stream, hold the connection open, classify the content, enforce authorization, and preserve tenant isolation. If the service materializes that body in memory first, capacity stops being determined by server configuration and starts being determined by the largest file any caller chooses to send.
For that reason, a production backend treats streaming as a correctness property, not merely a performance optimization. Buffering the entire multipart body invites three predictable failures: memory spikes under concurrent uploads, disk spills through temporary files when the framework exceeds its in-memory threshold, and delayed rejection because quota or authorization checks happen after significant I/O has already been accepted. The safer model is to keep bytes inside bounded readers and writers, reject early when policy allows, and let backpressure remain visible all the way to the client socket.
| Approach | Why it looks easy | What breaks in production |
|---|---|---|
| Read the file into []byte | Validation code becomes straightforward | Memory usage scales with attacker behavior and concurrent client size |
| Let the framework spill to temporary disk | Avoids heap spikes without redesigning handlers | Creates cleanup work, node-local state, and unpredictable latency under containerized workloads |
| Stream from multipart reader into a storage writer | Requires more deliberate control flow | Chosen because memory cost becomes a function of configured buffers and concurrency limits |
A service cannot control what size of file a caller attempts to send. It can control whether those bytes are buffered, spilled, or streamed through bounded readers.
Multipart Is a Wire Format
multipart/form-data answers only one question: how multiple form parts are serialized onto one HTTP request body. It does not answer where those bytes should live, when they should be validated, or which service owns the upload. That distinction matters because many beginner implementations confuse 'the framework parsed multipart for me' with 'the backend has a safe storage design.' The first is parsing. The second is architecture.
In Meridian, the gateway remains the only public entrypoint, but it should not become the place where attachment bytes are parsed or stored. Its responsibility is routing, coarse auth, and tenant propagation through X-Tenant-Id. The community-service owns complaint attachments because complaints belong to that bounded context. That keeps domain rules—who may attach evidence, which complaint is being referenced, which tenant owns the object—close to the data they protect.
Meridian's Planned Streaming Design
This chapter is intentionally a design specification, not a description of code that already exists. The real meridian repository is still in scaffolding: every service exposes only /healthz, apps/community-service/internal/domain/domain.go is deliberately empty, and no upload endpoint has been implemented yet. The purpose of the chapter is to define the shape that a correct implementation should take once complaint attachments are introduced.
apps/community-service/cmd/community-service/main.gomodifyResponsibility: Wire upload-size limits, storage dependencies, and the complaint attachment HTTP handler into the service bootstrap.
Why now: Upload policy belongs in explicit startup wiring so operational limits are visible and environment-driven rather than hidden in helper packages.
Connects to: Extends the existing cmd/community-service entrypoint that currently only exposes /healthz.
apps/community-service/internal/http/complaints/handler.gocreateResponsibility: Accept the tenant-scoped multipart request, extract the complaint identifier, and pass the file part downstream as an io.Reader instead of a byte slice.
Why now: The transport layer must preserve streaming semantics. If the handler asks for in-memory bytes, the architecture has already lost.
Connects to: Receives tenant identity from the gateway through libs/platform/tenancy.
apps/community-service/internal/attachments/service.gocreateResponsibility: Own authorization, filename normalization, content sniffing, checksuming, and the handoff from the multipart reader to object storage.
Why now: Upload rules are complaint-domain rules, not HTTP-framework details. Keeping them in a service layer makes them testable without the network stack.
Connects to: Will sit beside the future complaint domain implementation under apps/community-service/internal/....
apps/community-service/internal/repository/complaint_attachment_repository.gocreateResponsibility: Persist tenant-scoped attachment metadata such as complaint ID, object key, checksum, content type, and lifecycle state.
Why now: Object storage keeps bytes, but only a relational record can express which complaint the object belongs to and who may later read it.
Connects to: Follows the shared-schema rule documented in docs/architecture/services.md by including tenant_id on every row.
Validating While Bytes Move
A streaming upload still needs validation; the difference is where validation occurs. Cheap checks should run before meaningful I/O begins: Does the complaint belong to the tenant carried in context? Is the actor allowed to attach evidence? Has the complaint already reached its attachment-count limit? More expensive checks occur during streaming itself: total byte ceiling, content sniffing, digest calculation, and optional malware scanning handoff.
- Apply request-size ceilings at the HTTP layer before the handler begins reading unbounded input.
- Treat the client-supplied MIME type as a hint; derive the authoritative type from server-side sniffing when policy depends on it.
- Generate the object key on the server so the caller cannot choose a path that crosses tenant or complaint boundaries.
- Use the request context to cancel storage work when the client disconnects, but detach cleanup work from that cancelled context if a partial object must be removed.
Metadata and Failure Recovery
A file blob is not yet application state. The application state is the attachment record that says which tenant and complaint the blob belongs to, who uploaded it, which checksum was observed, and whether the upload completed. That is why a backend stores bytes and metadata separately. Object storage answers 'do these bytes exist?'; the database answers 'what do these bytes mean inside the product?'
| Write order | Primary risk | Why Meridian should choose or reject it |
|---|---|---|
| Insert metadata row before streaming bytes | Broken references if storage fails midway | Rejected because the product should not expose attachments that do not actually exist |
| Stream bytes first, then persist metadata | Requires cleanup if metadata persistence fails | Chosen because user-visible state appears only after the expensive byte write succeeds |
Every uploaded file path must remain tenant-aware, even in a service-mediated streaming flow. A stable pattern such as tenants/<tenant-id>/complaints/<complaint-id>/attachments/<attachment-id>/<sanitized-name> prevents path collisions, makes incident triage legible, and ensures that later lifecycle jobs can delete or quarantine a whole tenant's orphaned objects without guessing ownership. The next lesson moves from service-mediated streaming to direct S3-compatible uploads, but the same tenant-scoped key discipline still applies.
The Decision
Meridian's Chapter 10 upload design treats multipart as a streaming transport owned by community-service, not as permission to buffer arbitrary file bodies inside the process. Complaint attachment uploads should enforce tenant and complaint policy before expensive I/O, stream through bounded readers while computing limits and digests, and create relational metadata only after the byte write succeeds. That design is not yet implemented in meridian; it is the specification for how the implementation should behave once the scaffolding phase ends.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.