Design an Invoice Generation System

Design an invoice generator step by step: financial snapshots, immutable documents, async PDFs, object storage, transactional outbox, idempotency, and the reasoning behind every major decision.

Page content

Alice pays for order ORD-123. Checkout calls POST /v1/invoices with Idempotency-Key: abc123. The network times out. Checkout retries the same request. A second later two PDF workers both see InvoiceCreated.

That race is the product. Alice must receive exactly one invoice, with the prices she was charged, not the prices in today’s catalog. The PDF can arrive a few seconds later. An email can arrive after the PDF. Neither the PDF renderer nor the mail provider may decide whether the invoice exists.

The main design question is:

How do we issue an invoice exactly once, freeze the money that was charged, and generate PDFs and notifications asynchronously without losing events or sending them twice?

We will start with one PostgreSQL database and a small API. Object storage, a broker, and workers appear only after a concrete limitation appears.

1. Clarify the problem

“Design invoicing” can mean tax engines, multi-currency ledgers, credit notes, recurring billing, and a full ERP. That is too broad for one interview.

I would ask:

  • Does an order have at most one invoice?
  • Is the invoice a legal snapshot of the paid order, or a live view of current prices?
  • Must the create API wait for a PDF?
  • Who downloads invoices: the customer, finance, or both?
  • Do we send email, or is in-app download enough?
  • What volume of invoices and PDFs should we support?
  • Are we on AWS, or should storage and the broker be replaceable?

If the interviewer gives no extra constraints, I would state:

Product              Issue one invoice per paid order
Not in scope         Tax rules engine, credit notes, dunning, ERP
Identity             UUID invoice id; human number INV-2026-00000123
Money                Immutable snapshot at create time
PDF                  Asynchronous; stored in object storage
Download             Short-lived URL after authorization
Notify               After the PDF exists; mocked email
Auth                 Customer sees own invoices; admin sees all
Deployment           One region first
Stack                Go, PostgreSQL, Redis optional, broker + storage abstractions

Local filesystem, a fake mailer, and an in-process or Docker broker are enough to explain the architecture. S3, Kafka, and SES plug in behind interfaces later. That is an interview strength, not a shortcut.

2. Functional requirements

The system must:

  1. Create an invoice from an order.
  2. Store invoice metadata and immutable line items.
  3. Generate a PDF asynchronously.
  4. Store the PDF in object storage.
  5. Let an authorized caller download the invoice.
  6. Notify the customer asynchronously after the PDF exists.
  7. Tolerate duplicate create requests and duplicate events.
  8. Retry failed PDF and notification work, then dead-letter.
  9. Regenerate a PDF without changing financial fields.

The first version does not include:

  • tax jurisdiction logic beyond storing the amounts given by Order Service;
  • credit notes, partial refunds, or invoice revisions of money;
  • recurring subscriptions;
  • a real email provider or a real S3 account; or
  • a public, unauthenticated bucket of PDFs.

3. Non-functional requirements

The important quality targets are:

RequirementTarget
Create latencyp99 below 300 ms excluding PDF and email
PDF freshnessSeconds to a minute is acceptable
DurabilityNever lose an acknowledged invoice
Exactly one invoice per orderEnforced in the database
Idempotent HTTPSame Idempotency-Key returns the original invoice
ImmutabilityFinalized money fields never change
DownloadAuthorize in the API; do not expose the bucket
Degraded PDFCreate still succeeds if the renderer or broker is down

Consistency is not one global setting.

Invoice row and line items     source of truth; read-your-write after POST
PDF bytes                      derived rendering in object storage
PDF status                     derived lifecycle on the invoice row
Email                          derived delivery; at-least-once, idempotent
Catalog prices                 irrelevant after the snapshot is stored

Edge cases to keep in mind

  • Checkout retries create with the same idempotency key.
  • Two keys try to invoice the same order.
  • Two PDF workers claim ORD-123.
  • PDF generation fails after the invoice is finalized.
  • InvoiceCreated is delivered twice.
  • InvoicePDFGenerated is delivered twice and would email Alice twice.
  • Download is requested while pdf_status is still PENDING.
  • A customer requests another customer’s invoice.
  • Object storage is down during upload.
  • Product prices change five minutes after payment.

4. Estimate the scale

Use round numbers. The goal is to find what is not the bottleneck.

Assume a sizable commerce site:

Invoices created              500,000/day
Average create QPS            500,000 / 86,400 ≈ 6
Peak create                   ~100–500/s
PDF size                      ~200 KB
Downloads                     2 million/day

Metadata is small. PDFs are not:

500,000 × 200 KB × 365 ≈ 36 TB/year of PDF bytes

That number is why PDFs do not live in PostgreSQL. Invoice rows and line items are a few kilobytes. PostgreSQL can hold years of them. Object storage holds the files. The create path is a modest transactional write. The PDF path is a throughput and retry problem.

Where scale still matters:

  • Invoice-number allocation contends on a year (or company) counter.
  • A payment retry storm hits the same order_id.
  • Month-end accountants download last month’s PDFs.
  • A poisoned PDF template must not block the outbox forever.

5. What are we storing?

We are storing a legal document and two derived jobs.

Invoice            the issued document: who, what, how much
Invoice item       a frozen line: quantity, unit price, tax, line total
Invoice number     human id INV-2026-00000123
PDF                a rendering of that document
Notification       a delivery attempt of that rendering
Outbox             a durable “this invoice exists” event

The catalog is not part of the invoice. If SKU-44’s price changes tomorrow, INV-2026-00000123 does not.

Keep three lifecycles independent:

Invoice        PENDING | FINALIZED | CANCELLED
PDF            PENDING | PROCESSING | GENERATED | FAILED
Notification   PENDING | SENT | FAILED

A failed PDF does not un-finalize the invoice. Alice was charged. Finance still has a document. We retry the rendering.

For the first version, POST /v1/invoices writes a FINALIZED snapshot in one transaction. PENDING on the invoice itself is useful if the product later needs drafts. We do not need a draft to explain the architecture. CANCELLED is an explicit legal cancel, not a renderer crash.

Source of truth     invoices, invoice_items, invoice numbers
Derived data        PDF object, notification rows
Cache               optional hot GET invoice:{id}
Event system        outbox → broker
Ephemeral state     pre-signed URLs, worker leases

6. APIs

Keep the API independent of Kafka, S3, and the PDF library.

Create invoice

POST /v1/invoices
Authorization: Bearer <token>
Idempotency-Key: abc123

{
  "order_id": "ORD-123"
}
HTTP 201 Created

{
  "id": "inv_8f3a",
  "invoice_number": "INV-2026-00000123",
  "order_id": "ORD-123",
  "customer_id": "u_alice",
  "status": "FINALIZED",
  "currency": "USD",
  "subtotal": "80.00",
  "discount": "5.00",
  "tax": "6.00",
  "total": "81.00",
  "pdf_status": "PENDING",
  "version": 1,
  "created_at": "2026-08-17T10:01:00Z",
  "finalized_at": "2026-08-17T10:01:00Z"
}

The HTTP response does not include PDF bytes. Create is the financial commit. Rendering is a follow-up job.

A replay of the same key returns the original invoice with 200 (or 201 if you stored that status on the idempotency row). A second invoice for ORD-123 returns 409 INVOICE_ALREADY_EXISTS.

Get invoice

GET /v1/invoices/inv_8f3a

Line items are part of the snapshot. They are not loaded from the product catalog.

List by customer

GET /v1/customers/u_alice/invoices?limit=50&cursor=...

Cursor pagination, not OFFSET. Alice may only list Alice.

Download

GET /v1/invoices/inv_8f3a/download
HTTP 200 OK

{
  "download_url": "https://files.example.com/invoices/2026/08/inv_8f3a.pdf?sig=...",
  "expires_at": "2026-08-17T10:16:00Z"
}

If pdf_status != GENERATED:

HTTP 409 Conflict

{
  "error": {
    "code": "PDF_NOT_READY",
    "message": "Invoice PDF is not ready"
  }
}

Regenerate PDF

POST /v1/invoices/inv_8f3a/regenerate
Authorization: Bearer <admin>

This does not touch subtotal, tax, or line items. It asks the PDF worker to render the same snapshot again.

Errors

400  VALIDATION_ERROR, TOTALS_MISMATCH
401  UNAUTHENTICATED
403  UNAUTHORIZED_INVOICE_ACCESS
404  INVOICE_NOT_FOUND, ORDER_NOT_FOUND
409  INVOICE_ALREADY_EXISTS, IDEMPOTENCY_CONFLICT,
     PDF_NOT_READY, INVALID_INVOICE_STATE
503  STORAGE_ERROR, DEPENDENCY_UNAVAILABLE

Do not return SQLSTATE or S3 XML to clients.

7. Basic data model

Invoice and items

invoices
--------
id                UUID PK
order_id          unique
customer_id
invoice_number    unique
status            FINALIZED | CANCELLED | PENDING
currency
subtotal, discount, tax, total     numeric(19,4)
pdf_status        PENDING | PROCESSING | GENERATED | FAILED
pdf_object_key    nullable
created_at
finalized_at
version

invoice_items
-------------
id
invoice_id        FK → invoices.id
product_id        snapshot id, not a live join
description       snapshot text
quantity
unit_price
discount
tax
total

Store money as decimals, not floats. Persist the description and unit price that were true at issue time. product_id is an audit trail back to the catalog, not a price lookup.

Indexes:

invoices(order_id)           uniqueness and payment retries
invoices(customer_id, id)    customer list + cursor
invoices(invoice_number)
invoices(status)
invoices(pdf_status)         operator dashboards, not the worker claim

Numbers, idempotency, outbox, notifications

invoice_number_counters
-----------------------
year         PK
next_number

idempotency_records
-------------------
key              PK
request_hash
invoice_id
response_code
created_at

outbox_events
-------------
id
event_type
aggregate_type
aggregate_id
payload
status           PENDING | PUBLISHED | FAILED
retry_count
created_at
published_at

notifications
-------------
id
invoice_id
event_id         unique with consumer, or unique event_id
status           PENDING | SENT | FAILED
created_at

UNIQUE(order_id) is the business invariant. idempotency_records is the HTTP retry invariant. They answer different races.

8. Start with one service and PostgreSQL

Do not begin with Invoice Service, PDF Service, and Notification Service as three deployables. One Go process can host the HTTP API, the outbox publisher, and the workers. Packages keep the boundaries obvious so they can split later.

Client
HTTP Handler
  │  authn, idempotency key, JSON
Invoice Service
  │  snapshot, totals, transactions
Repository
PostgreSQL

An OrderService interface loads the paid order. In production it is another team’s API. In the interview it is a stub that returns line items. The invoice must not call it again on GET.

Create, in one transaction:

BEGIN
  claim idempotency key
  reject if order already invoiced
  lock year counter, allocate INV-2026-00000123
  insert invoice FINALIZED, pdf_status PENDING
  insert invoice_items
  insert outbox InvoiceCreated
COMMIT
return invoice

No Kafka call, no PDF render, no SMTP inside that transaction.

9. Snapshot the money, then freeze it

If we stored only order_id and computed totals on read:

Day 1  Alice pays $81, SKU-44 is $40
Day 2  catalog price becomes $50
Day 3  GET invoice shows $91

That is a finance bug, not a cache bug. The invoice is evidence of what was charged. Copy every amount onto invoice_items at create time. Validate:

item.total = quantity * unit_price - discount + tax
invoice.subtotal + invoice.tax - invoice.discount = invoice.total

If Order Service’s totals disagree, fail the create. Do not “fix” money in the invoice service.

After FINALIZED, there is no PATCH for amounts. A credit note, if you add one later, is a new document that references this one.

This is why PDF regeneration is allowed and amount edits are not. The file is a view. The rows are the books.

10. Three state machines, not one status column

A single status = PDF_FAILED on the invoice would imply the document is invalid. It is not.

Invoice FINALIZED
   pdf_status FAILED
   notification FAILED

is a coherent state: the legal invoice exists, rendering needs an operator, mail has not gone out.

Transitions:

Invoice
  (create) → FINALIZED
  FINALIZED → CANCELLED     explicit finance operation

PDF
  PENDING → PROCESSING      worker claim
  PROCESSING → GENERATED    upload ok
  PROCESSING → FAILED       render or upload failed
  FAILED → PROCESSING       retry or regenerate
  GENERATED → PROCESSING    regenerate

Notification
  PENDING → SENT
  PENDING → FAILED
  FAILED → PENDING          retry

Conditional SQL, not “read, decide, write” in two statements:

UPDATE invoices
SET pdf_status = 'PROCESSING', version = version + 1
WHERE id = $id
  AND pdf_status IN ('PENDING', 'FAILED')

Zero rows means another worker already claimed it, or it is already GENERATED. That is how we survive two consumers and at-least-once delivery.

11. Why create is synchronous and PDF is not

Alice’s checkout needs to know the invoice id and number before the page closes. That write is a small transaction: a few rows, a counter, an outbox event.

PDF generation is different:

load snapshot
render template
produce 200 KB
PUT to object storage

That can take hundreds of milliseconds to several seconds, depends on a template engine, and fails independently of PostgreSQL. Putting it in POST /v1/invoices would:

  • hold a database connection while rendering;
  • fail the financial commit because wkhtmltopdf crashed;
  • make every payment retry wait on storage.

So the API finalizes money and returns. The worker renders when it can. The download API tells the truth if the file is not ready: PDF_NOT_READY.

12. Invoice numbers without an in-memory counter

INV-2026-00000123 is not the primary key. The UUID is. The pretty number is allocated like a ticket number.

Two API instances must not both issue ...0123.

BEGIN
  SELECT next_number
  FROM invoice_number_counters
  WHERE year = 2026
  FOR UPDATE
  -- allocate, increment, insert invoice
COMMIT

A unique constraint on invoice_number is the backstop. Gaps are allowed if a transaction rolls back. Duplicates are not.

An in-memory ++counter is wrong the moment you run two pods. A Redis INCR is a possible allocator, but then Redis is on the create path and can disagree with PostgreSQL. Keep allocation in the same database as the invoice row.

13. Two kinds of “do not create twice”

Checkout retries are not the same as two orders.

Idempotency key (HTTP):

INSERT idempotency_records (key, request_hash, ...)
ON CONFLICT (key):
  if hash matches: return stored invoice
  else: 409 IDEMPOTENCY_CONFLICT

Store the hash of the body. The same key must not mean two different order_ids.

One invoice per order (business):

UNIQUE (order_id)

If two different keys arrive for ORD-123, the second insert fails and the API returns 409 INVOICE_ALREADY_EXISTS (or the existing invoice, if that is the product rule). The unique constraint, not a SELECT then INSERT, is what two pods cannot evade.

Create therefore does both checks inside one transaction. Application memory is not involved.

14. Transactional outbox

Do not kafka.Publish inside the PostgreSQL transaction, and do not publish after commit with no durable record.

Postgres commit, publish fails     invoice exists, no PDF job
Publish succeeds, Postgres rolls back     worker invoices a ghost

The fix is the same pattern as the ticket and employee designs:

BEGIN
  INSERT invoice, items
  INSERT outbox_events InvoiceCreated
COMMIT

Outbox publisher (later):
  SELECT PENDING events
  publish to broker
  mark PUBLISHED

If the broker is down, rows wait. The invoice is still FINALIZED. PDF lag is visible in pdf_status and in unpublished-outbox age.

Events need enough data for a worker that cannot see a half-written row:

InvoiceCreated
  event_id, invoice_id, order_id, customer_id, created_at

InvoicePDFGenerated
  event_id, invoice_id, object_key

InvoicePDFGenerationFailed
  event_id, invoice_id, error_code, retry_count

InvoiceNotificationRequested
InvoiceNotificationSent

Partition the broker by invoice_id so one invoice’s events stay ordered. Global order across invoices is unnecessary.

15. PDF worker

InvoiceCreated
Broker
PDF Worker
    ├─ claim PROCESSING
    ├─ load invoice + items from PostgreSQL
    ├─ if already GENERATED: ack and stop
    ├─ render bytes through PDFGenerator
    ├─ Put object key invoices/2026/08/inv_8f3a.pdf
    ├─ set pdf_status GENERATED, pdf_object_key
    └─ insert outbox InvoicePDFGenerated

The generator is an interface:

PDFGenerator.Generate(invoice) -> []byte
ObjectStorage.Put / Get / GenerateDownloadURL

A deterministic fake PDF is enough in development. A real library plugs in later. The worker must not import S3 types.

Claim before render so two workers do not both PUT and both email. If render fails after claim, set FAILED and let retry move it back through PROCESSING.

Do not store PDF bytes in PostgreSQL. Backups, page size, and SELECT * get worse, and you still need a download path. Object storage is the blob store; the database stores the key.

16. Download with a short-lived URL

The API is the authorization point. The bucket is not public.

GET /invoices/{id}/download
  authenticate
  authorize customer_id == actor, or admin
  if pdf_status != GENERATED: PDF_NOT_READY
  url = ObjectStorage.GenerateDownloadURL(key, 15 minutes)
  return url, expires_at

The application does not stream 200 KB through the API pod on every accountant download. It mints a capability that expires. Locally, LocalObjectStorage can return a path your dev server already serves. In production, that method becomes an S3 pre-signed GET.

Never put a long-lived public URL in the invoice JSON. Email can include a link to your download API, which re-checks auth, or a short-lived URL generated at send time.

17. Notify after the file exists

Emailing on InvoiceCreated sends Alice a letter with no attachment, or a link that 409s. Wait for InvoicePDFGenerated.

InvoicePDFGenerated
Notification Worker
       ├─ insert notifications row keyed by event_id
       ├─ mint download URL or app link
       └─ NotificationService.SendInvoice(...)

A fake implementation logs the message. The create API never calls it.

If SendInvoice fails, mark FAILED and retry. Do not roll back the invoice. Do not roll back the PDF.

Duplicate InvoicePDFGenerated must not send two emails. Unique event_id (or (consumer, event_id) in processed_events) makes the second insert a no-op.

18. At-least-once, retries, and a DLQ

The broker delivers at least once. Exactly-once across Postgres, Kafka, S3, and SES is not the interview answer.

Duplicate InvoiceCreated
  claim PROCESSING fails or sees GENERATED → ack

Duplicate InvoicePDFGenerated
  unique event_id on notifications → skip send

Retries with backoff:

1s, 5s, 30s, 5m

After the limit, send the payload to a dead-letter abstraction and set pdf_status or notification status to FAILED. Operators replay from the DLQ. A bad template must not block InvoiceCreated for everyone else: process invoices independently, partitioned by id.

The outbox publisher has its own retry_count. It must not call the PDF renderer. It only publishes.

Never hold the create transaction open during backoff.

19. Regeneration

Templates change. A generated file can be corrupt. Finance still must not rewrite totals.

POST /invoices/{id}/regenerate
  authorize admin
  require status FINALIZED
  set pdf_status PENDING (from GENERATED or FAILED)
  insert outbox InvoiceCreated or InvoicePDFRegenerateRequested

Use a conditional update so two regenerate clicks do not start two overlapping renders without a claim. The worker is the same path as create. Line items are untouched.

20. Authorization

A customer token may read and download only customer_id = actor. An internal admin role may read all and regenerate. Authorization is a check in the handler/service after load, not a WHERE clause the client supplies.

Do not implement OAuth in the interview. An Actor{ID, Role} from a header or stub middleware is enough if you always use it.

21. Consistency model

Strong, same transaction
  invoice, items, invoice number, idempotency row, outbox

Read-your-write for the actor
  GET invoice after POST sees FINALIZED and pdf_status PENDING

Eventual
  PDF object, pdf_status GENERATED, email

Unacceptable
  two invoices for one order, mutated totals after finalize,
  email that proves an invoice the database rolled back

Redis, if you add it, may cache GET /invoices/{id}. Invalidate after regenerate and cancel. Uniqueness still lives in PostgreSQL.

22. Failure scenarios

FailureBehavior
PostgreSQL downCreate and authoritative GET fail
Order Service downCreate fails; no half invoice
Broker downOutbox waits; invoice is FINALIZED
PDF renderer downpdf_status FAILED after retries; invoice remains FINALIZED
Object storage downSame as renderer; no GENERATED key
Email provider downPDF remains; notification retries / DLQ
Duplicate eventsConditional PDF claim; unique notification event_id
Download before PDF409 PDF_NOT_READY

Degraded behavior is part of the product: the payment is done, the invoice number is issued, the pretty file may lag.

23. Observability and scale

Log request_id, invoice_id, order_id, customer_id, event_id, idempotency_key, retry_count.

Useful counters:

invoice_creation_success_total
invoice_creation_failure_total
pdf_generation_success_total
pdf_generation_failure_total
notification_success_total
notification_failure_total
outbox_publish_failure_total

Useful histograms: create latency, PDF latency, time from finalize to GENERATED.

Scale horizontally by adding API pods (they contend only on the year counter and unique order_id) and adding PDF workers (they contend on the claim update). Shard later by customer_id or month if a single database is no longer enough. Split PDF workers into their own service when render CPU drowns the API. Do not split before the outbox contract is stable.

24. Where logic lives

cmd/server                 HTTP, worker, publisher wiring
internal/invoice           handler, service, repository, model
internal/outbox            same-transaction writer; publisher loop
internal/pdf               worker, PDFGenerator
internal/storage           ObjectStorage
internal/notification      worker, NotificationService
internal/orders            OrderService port
internal/auth              actor and access checks
migrations/

Handlers map errors to HTTP. The service opens the transaction. Repositories do not send email. Tests fake OrderService and PDFGenerator, and use a real database for UNIQUE(order_id) and the PDF claim update.

Local adapters:

ObjectStorage        filesystem under ./data/invoices
MessageBroker        channel, SQS, or Docker Kafka
NotificationService  log + table
PDFGenerator         minimal valid PDF bytes

Production adapters implement the same interfaces. Business rules do not change.

25. Final architecture

 Client
Invoice API
Invoice Service
   └── PostgreSQL
         invoices
         invoice_items
         invoice_number_counters
         idempotency_records
         outbox_events

PostgreSQL outbox
Publisher → Broker
              ├── PDF Worker → ObjectStorage
              │                  then outbox InvoicePDFGenerated
              └── Notification Worker → Email abstraction

Create:

POST /invoices + Idempotency-Key
  → snapshot order
  → one transaction: number, rows, outbox
  → 201 FINALIZED, pdf PENDING

PDF:

InvoiceCreated → claim PROCESSING → render → Put → GENERATED

Download:

authorize → presigned URL

Notify:

InvoicePDFGenerated → send once

PostgreSQL owns the document. Object storage owns the bytes. The broker moves work. Email proves nothing the database did not already commit.

26. Interview-ready summary

Key decisions to remember

  1. Scope the interview to issuing a snapshot invoice, async PDF, download, and notify — not a tax ERP.
  2. Create is synchronous; PDF and email are not.
  3. Money is copied onto line items and never recomputed from the catalog.
  4. Finalized invoices are immutable; regenerate only the PDF.
  5. Invoice, PDF, and notification are separate state machines. PDF failure does not cancel the invoice.
  6. UNIQUE(order_id) plus idempotency keys stop duplicate creates.
  7. Allocate INV-2026-… with a database counter, not memory.
  8. Write InvoiceCreated in the same transaction as the invoice.
  9. PDF workers claim with PENDING → PROCESSING.
  10. Store bytes in object storage; authorize in the API; return a short-lived URL.
  11. Consumers are idempotent because delivery is at-least-once.
  12. Retries back off, then dead-letter; operators replay.

Likely interviewer follow-up questions

  • Why not generate the PDF inside POST /v1/invoices?
  • Why not SELECT current product prices when rendering the PDF?
  • Why three status columns instead of one?
  • How do two checkout retries not create two invoices?
  • How do two API pods not issue the same invoice number?
  • Why an outbox instead of publishing after COMMIT?
  • How does a second InvoiceCreated not upload two PDFs and send two emails?
  • Why not store the PDF in PostgreSQL?
  • Why pre-signed URLs instead of streaming through the API?
  • What does regenerate change, and what must it not change?
  • What happens if S3 is down for an hour after a successful create?
  • When would you split PDF workers into a separate service?

Senior-level points that differentiate the answer

  • Treat the invoice as a legal snapshot, not a view over orders.
  • Separate “the document exists” from “the file exists” from “the email was sent.”
  • Put uniqueness in constraints and claims in UPDATE … WHERE status = ….
  • Keep Kafka, S3, and SMTP out of the create transaction.
  • Assume at-least-once everywhere and show the duplicate path.
  • Use interfaces so the whiteboard maps to local fakes and later to AWS.
  • Monitor finalize-to-PDF lag, not only HTTP QPS.

A 1–2 minute verbal answer

I would scope this to one invoice per paid order, with immutable money, an async PDF, authorized download, and async notification. Create copies line items from Order Service, validates totals, allocates INV-2026-… from a year counter with SELECT FOR UPDATE, and writes the invoice, items, idempotency row, and InvoiceCreated outbox event in one PostgreSQL transaction. UNIQUE(order_id) and the idempotency key make retries safe across multiple pods. The API returns FINALIZED with pdf_status PENDING.

A worker claims the PDF with a conditional update, renders through a PDFGenerator, PUTs invoices/{year}/{month}/{id}.pdf to object storage, and marks GENERATED. Download authorizes the caller and returns a short-lived URL; the bucket is not public. Notification runs on InvoicePDFGenerated, not on create, and de-duplicates with event_id. If the renderer, broker, or mailer fails, the invoice stays FINALIZED. We retry with backoff and then dead-letter.

For the broader interview framework around this problem, see the System Design Interview Complete Guide.