Problem RestatementProblem
Atlassian asked a deliberately simple question: design a minimal web app for greeting cards. A user picks a template (birthday, thank you), customizes the message and maybe adds a photo, then sends it to a recipient by email. The recipient opens a link and sees the card. The interviewer wants a clean MVP (minimum viable product) architecture, not a massive distributed system, plus a sense of how it would grow.
RequirementsRequirements
- Browse templates. Create a card (template + message + optional image).
- Send to one or more recipients by email (now, or scheduled).
- The recipient views the card via a unique link, with no account needed.
- The sender sees whether the card was opened.
1.1 Scale (MVP)
- Thousands of cards per day, spiking around holidays. One region is fine.
MVP ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
B["Browser - React SPA"] --> CDN["CDN - static app + template images"]
B --> API["Backend API - one service"]
API --> DB[("Postgres")]
API --> OS[("Object storage - uploaded photos")]
API --> MAIL["Email provider - SendGrid / SES"]
R["Recipient"] -->|"card link"| API- Frontend: a single-page app served from a CDN.
- Backend: one small service (e.g., Node, Python or Java) with a REST API. No microservices needed yet.
- Database: Postgres for users, templates, cards and recipients.
- Object storage for user photos, uploaded directly from the browser with a pre-signed URL.
- Email provider API to send emails (don't run your own mail servers).
Data Model and APIsData model
templates: template_id, name, category, image_url, layout_json
cards: card_id, sender_id, template_id, message, photo_key, created_at
recipients: card_id, email, share_token (random, unguessable), sent_at, opened_at, scheduled_forGET /templatesPOST /cards{ template_id, message, photo_key? }→{ card_id }POST /uploads→ a pre-signed URL for the photoPOST /cards/{id}/send{ recipients: [...], send_at? }GET /c/{share_token}→ the card page (recordsopened_at)
Send FlowFlows
- Create a recipient row with a random share token (e.g., 128-bit, URL-safe). The link is
https://cards.example/c/{token}, and it can't be guessed. - Send the email through the provider with the link.
- When opened, render the card and set
opened_at(only the first time).
Deep Dive — Sending the cards on Mother's DayDeep dive
The app is small and its traffic is not: one morning a year produces more sends than the previous three months. Where the email is sent from decides whether that morning works.
Send inside the request
The API calls the email provider and returns when it accepts.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
U["Send card"] --> API["API handler"]
API --> SMTP["Email provider - 300 ms, sometimes seconds"]
SMTP --> HOLD["Request thread blocked for the whole call"]
HOLD --> POOL["Holiday spike exhausts the thread pool"]
SMTP -->|"provider rate limit"| ERR["500 to the user - card lost"]
ERR --> RETRY["User hits send again - recipient gets two cards"]The provider's latency and rate limits are now the app's. Worse, a failure loses a card the user believes they sent, and their natural retry produces duplicates.
Put sends on a queue
The API writes the card and enqueues a send job; workers call the provider.
The request is fast, the spike is absorbed by the queue, and a provider hiccup delays delivery instead of failing it. What is undefined is what a worker does when the provider rejects or times out — a naive retry sends the card twice, and the provider's rate limit is still hit by however many workers happen to be running.
Queue, with idempotent sends and a rate budget
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
API["POST /cards/{id}/send"] --> ROW["Row: status queued, send_key = card_id + recipient"]
ROW --> Q[("Queue")]
Q --> W["Workers"]
W --> TB{"Token bucket - the provider's rate limit"}
TB --> P["Provider - Idempotency-Key: send_key"]
P -->|"transient failure"| BACK["Backoff and retry - same key, no duplicate"]
P -->|"permanent failure"| DLQ[("Dead letter - user told it failed")]
SCH["scheduled_for"] --> SWEEP["Scheduler enqueues due sends every minute"]
SWEEP --> Q- An idempotency key per card and recipient, passed to the provider and stored on the row. A retry — whether the worker's or the user's — resolves to the same send rather than a second email.
- A shared token bucket sized to the provider's limit, so adding workers increases throughput up to the limit and never past it. Per-worker limits cannot do this.
- Scheduled sends fall out for free. Store
scheduled_forand have a job enqueue due cards each minute — the same path, just entered later, which also spreads the holiday load across the morning. - Permanent failures are visible. After the retries, the card is marked failed and the sender is told. A card silently stuck in a queue is the worst outcome for this product.
Everything else about the app can stay simple. This is the one place where the holiday spike, an external dependency and a user who will click twice all meet.
Wrap-UpWrap-up
Start simple: an SPA on a CDN, one backend service, Postgres, object storage for photos (pre-signed uploads) and a managed email provider. Cards get recipient rows with unguessable share tokens, sent by email and tracked on open, with rate limits and input sanitization. Grow step by step: a queue for email, a scheduler for scheduled sends, horizontal scaling and caching, and async rendering.