Also a real, running thing, not a made-up company: a standalone Go service at
demo/media-service in this site's own repo, and a real client of it —
demo/chat-demo's Wavelink backend, covered in the
chat application write-up. This one is about a
narrower question: what does file upload/storage look like when it's built as its own
service instead of a feature bolted onto the app that happens to need it first.
media-service owns uploaded files: images and generic documents for now, video named as future scope, not built. It knows about owners and media objects. It has never heard of a conversation, a message, or a group — that vocabulary belongs entirely to Wavelink, the chat app that happens to be its first caller.
That separation is the actual design decision this write-up is about, more than any single endpoint. Every real chat product splits this the same way — WhatsApp's media servers, Slack's Files API, Discord's CDN are all products the messaging layer calls, not code living inside it — for a reason that shows up immediately once you list what each side actually needs:
Different bottlenecks mean different scaling knobs, and bundling them means scaling one forces you to pay for the other. That's the case for a separate deployable service in general, independent of what either side happens to be written in.
The implementation here is Go: a presigned-URL issuer and an image-resize worker pool are both concurrency-heavy, I/O-bound jobs — a lot of short-lived requests and a pool of workers decoding/encoding images in parallel — which is exactly what goroutines and channels are built for, with a mature S3 SDK and standard-library-adjacent image support to go with it.
internal/storage.S3Storage is the only implementation —
dev points it at MinIO, prod points the same code at AWS S3, via one config value
(S3Config.Endpoint). No separate local backend to keep in sync with the real
one.pending →
(processing for images) → ready, or failed.
CompleteUpload stats the object in storage and checks its size against what
was declared at creation before ever moving off pending — a client
claiming success isn't enough.CompleteUpload
and Delete both require the caller's declared owner_id to match
who the record was created for. IDs are sortable, sequential-looking strings (millisecond
timestamp plus a counter) — not secrets, so without this check a second caller who
merely guessed a pending id could finalize or destroy someone else's upload.GET /healthz, the one route that doesn't sit
behind the service token — for a load balancer target group that doesn't exist
yet.
The browser never talks to media-service directly, and never holds its service token —
Wavelink's backend is the only caller, proxying the small JSON exchanges (create, complete,
resolve) while staying out of the way entirely for the actual bytes. That's the one leg
that bypasses both app servers: the browser PUTs straight to object storage
using a presigned URL, and later GETs the same way.
Nothing is trusted as real until it's verified against the source of truth — here, the object store itself, not a database row or a client's say-so.
Image uploads land in processing, not ready, once complete
confirms the bytes landed. Turning that into ready is internal/media.Worker:
an in-process goroutine pool, not a call to SQS or a separate queue service — the
pragmatic v1 shape for a service where each instance only needs to keep up with its own
share of upload volume.
The design problem an in-process queue creates is drops: what happens to a job if the queue
is full, or the process restarts mid-job. The answer here is at-least-once, not
exactly-once, done cheaply: Enqueue never blocks the HTTP request that triggered
it — a full queue just drops the enqueue — and a periodic sweep
(Store.ListByStatus(processing)) re-submits anything still processing,
on a timer and once immediately at startup. A dropped enqueue and a mid-job crash both
self-heal on the next sweep, without either being handled as a special case.
ready or failed, never stuck in
processing — a decode failure doesn't touch the original, which is already
safely in storage by the time thumbnailing even starts.
A message can carry an optional media_id instead of, or alongside, text.
ConversationChannel never touches file bytes and never even calls
media-service's storage layer directly — it calls Wavelink.Media.get/1 to
check two things before durably writing the message: the sender actually owns that media
id, and its status is at least processing (the original landed, even if a
thumbnail hasn't finished). Attaching a still-pending id — an upload that
was created but never confirmed — is rejected before the message is ever durably
written, rather than writing a message that points at bytes which might not exist.
A rejected attach doesn't silently drop the message: the channel pushes back
%{status: "rejected", reason: ...} on the same ack the client
already listens on for send confirmation, and the UI removes the optimistic bubble rather
than leaving it stuck.
Resolving a media_id back into something renderable is deliberately not baked
into the message payload — a signed URL is only good for a few minutes, but a message
lives forever. Instead the client calls GET /api/media/:id (proxied the same
way) whenever it actually renders that message, and again right before opening the full
image or downloading a file, so a bubble scrolled past five minutes ago still resolves to a
working link when it's finally viewed.
owner_id checks inside media-service for mutation, and
conversation membership inside Wavelink for reads (see the gap noted below). Two separate
questions, deliberately answered in two different layers rather than one doing both
badly.SignatureDoesNotMatch from the storage layer, not
a bug report. CompleteUpload's own size check is what catches anything that
still gets through with a technically-matching length but different content.GET isn't
restricted to the owner — other conversation members need to see media they didn't
upload. That makes a media id functionally bearer-token-shaped for reads, same as the
presigned URLs it hands out. Fine as long as the layer deciding who learns a given id
(Wavelink, via conversation membership) is airtight — see the named gap below for
where that currently isn't quite true.net/http's own ServeMux (Go 1.22+'s method+pattern
routing) is enough for four routes and a health check.github.com/aws/aws-sdk-go-v2's S3 client, one
implementation for both MinIO (dev, in Docker) and AWS S3 (prod) — see
Architecture for why that's one client, not two.image/jpeg, image/png, image/gif) plus
golang.org/x/image/webp for decode and
golang.org/x/image/draw's CatmullRom scaler for resizing — no libvips
or ImageMagick dependency, no cgo.Store for dev/test
— fine for that, not a claim about production concurrency or durability. A real
database-backed Store is the natural next implementation of the same
interface, not built yet.ExpiresAt, and the intent (an upload a
client started and never finished should eventually be swept and its storage reclaimed)
is designed in and even named in a code comment — but no job acts on it. The
thumbnail worker's sweep only re-enqueues processing records; a
pending one past its ExpiresAt just sits there. Named here
rather than left implicit.MediaController.show/2 will return any media record to any authenticated
Wavelink user, not just members of whatever conversation it was attached to — closing
it needs a media_id → conversation_id index this pass didn't build.
Media ids being sequential-looking strings, not secrets, makes this a real
guessable-by-id exposure, not just a theoretical one.All system design notes · Designing a chat application · Home