Design a Document Management System

Who Asks This Question?

This shows up at companies that store documents as a core part of the product: legal tech (contract repositories), fintech (invoice and statement processing), insurance (claims), healthcare (records), and any B2B SaaS with a "documents" tab. It is also asked in a dozen disguises that are all the same system:

  • Design a document ingestion & processing pipeline
  • Design a contract data extraction system (an NLP/ML pipeline)
  • Design a full-text search system for legal documents
  • Design a contract repository / document management system

The core architecture is identical. Only the deep-dive focus changes. If you can design the general version, you can answer all four.

What the Interviewer Is Really Testing

Evaluation AreaWeightWhat They're Looking For
Requirements & scoping15%Do you separate upload/search/processing and ask about scale?
Architecture & data flow25%Object storage + metadata DB + search + async pipeline, cleanly separated
The ML pipeline (DAG)20%Correct stage ordering, parallel branches, orchestration
Reliability25%Idempotency, retries, fault tolerance, eventual consistency
Proactive trade-offs15%Do you state "I chose X over Y because…" before being asked?

The reliability row is where most candidates lose points and where staff/principal candidates win them. Long-running, expensive GPU tasks make idempotency and fault tolerance non-negotiable.

Step 1: Clarify Requirements

Functional Requirements

Frame these around what the user needs to do, not how you'll build it:

  • Core actions: what must a user be able to do? Upload, preview/download, search, share, and delete documents.
  • Upload: what do users upload, and how large are the files? Text-native files (PDFs, Office docs) versus scans and photos change what "readable" means; very large files change the upload experience.
  • Search: how does a user expect to find a document? By metadata (name, owner, date), by the text inside it, or by meaning (e.g., "find the termsheet").
  • Automatic understanding: do users expect the system to read documents for them (extract key fields, classify, summarize), or just store and retrieve?
  • Sharing & access: are documents private, shared with a team, or role-based? Who is allowed to see what?
  • Freshness: after upload, how soon must a document become searchable? Instantly, or is a short processing delay acceptable?

Non-Functional Requirements

  • Idempotency: reprocessing a document must not duplicate work or corrupt results.
    • Wasted work: don't re-run OCR/NLP that already finished, or you pay for the most expensive GPU compute twice.
    • Corrupt data: don't create duplicate DB rows or index the same document twice.
  • Scalability: absorb bulk uploads; autoscale expensive GPU workers.
  • Fault tolerance: long running tasks fail partially often (OOM, timeouts, pod eviction). Plan for operations and maintenance: the system must alert on failures promptly and recover cleanly.
  • Consistency: eventual consistency between the metadata DB and the search index is acceptable.

Step 2: High-Level Architecture

Start with the happy path: the simplest structure that moves a document from upload to searchable. Get that end-to-end skeleton on the board first, then revisit and refine it against the requirements we collected (large-file uploads, OCR vs. direct extraction, reliability, eventual consistency). Designing the simple version first keeps the conversation grounded and gives you concrete places to layer in complexity.

Components

High-level components: Documents Service (API, storage, metadata DB, permissions/RBAC), ML Processing Service (OCR/NER, classification/summarization, async workflow), and Search Service (faceted search, semantic search, reconciliation / index repair)

  • Documents Service: stateless API. Issues presigned URLs, writes metadata, checks permissions. Deliberately lightweight; it never touches file bytes.
  • Object Storage (S3/GCS): the file bytes and all intermediate ML result files. The durable source of truth for content.
  • Metadata DB (PostgreSQL): filename, owner, size, type, and a per-document processing state.
  • Message Queue (Kafka / SQS): buffers work and decouples upload from processing.
  • ML Pipeline Orchestrator (Temporal / Step Functions): drives the DAG, handles retries and state persistence.
  • GPU Worker Pool: runs OCR and the NLP models; autoscales on queue depth.
  • Search Engine (Elasticsearch): full-text (BM25) plus faceted search over metadata and ML-enriched fields.
  • CDC (Debezium): captures metadata writes from Postgres and triggers the pipeline.

Step 3: Deep Dive on Core Component Design

1. Documents Service (Upload / Download / Preview)

Documents Service flow: the user client uploads/downloads directly to S3 and calls the Documents Service (/auth, /upload, /complete) which mints a UUID and writes to the Metadata DB; a scheduled task reconciles S3 against the Metadata DB, applying a lifecycle policy to clean up corrupted documents and periodically refreshing the data

  • Client types: browser, direct API call, or third-party application.
  • Flow:
    1. The client requests a pre-signed URL from the Documents Service.
    2. The Document Service checks permissions (RBAC) and communicates with S3 to generate the URL.
    3. The client uses the pre-signed URL to upload, download, or preview files directly from S3.
  • Large file handling: partial uploads are possible; an S3 lifecycle policy is enabled to automatically clean up incomplete or corrupted upload artifacts.

The service never proxies the bytes; storage handles the bandwidth and the service stays cheap to scale. Put a CDN (CloudFront) in front of hot documents.

API payload. The upload is a three-call handshake: mint a record and a presigned URL, PUT the bytes straight to S3, then confirm. The idempotency token makes a retried mint a no-op instead of a second document.

POST /documents/upload
Authorization: Bearer <jwt>
Idempotency-Key: 5c1f8b0e-2a...
Content-Type: application/json

{
  "filename": "acme-msa.pdf",
  "content_type": "application/pdf",
  "size_bytes": 4194304
}
// 201 Created — service writes the row (state: "uploaded") and returns a presigned PUT
{
  "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "s3_key": "document/d290f1ee-6c54-4b01-90e6-d701748f0851/original",
  "upload_url": "https://s3.amazonaws.com/docs-bucket/document/d290f1ee.../original?X-Amz-Signature=...",
  "expires_in": 900,
  "state": "uploaded"
}
// Client uploads bytes directly to storage (no service in the path)
PUT <upload_url>
Content-Type: application/pdf
<file bytes>
// Confirm the upload; the service flips state and CDC triggers the pipeline
POST /documents/d290f1ee-6c54-4b01-90e6-d701748f0851/complete
Authorization: Bearer <jwt>
// 200 OK
{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "state": "processing" }

Data model. The Metadata DB (PostgreSQL) is the source of truth for document records: one row per document, keyed by the global UUID that every downstream system uses.

CREATE TABLE documents (
  id           UUID PRIMARY KEY,      -- global document UUID, used everywhere downstream
  owner_id     UUID NOT NULL,         -- user or tenant
  filename     TEXT NOT NULL,
  content_type TEXT NOT NULL,         -- application/pdf, image/png, ...
  s3_key       TEXT NOT NULL,         -- document/{id}
  size_bytes   BIGINT NOT NULL,
  state        TEXT NOT NULL,         -- uploaded | processing | indexed | failed
  created_at   TIMESTAMPTZ NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL
);

Fine-grained sharing lives in a separate document_permissions (document_id, principal_id, role) table; simple ownership stays on documents.owner_id.

Access patterns:

  • Get a document: point lookup on documents.id (PK).
  • List a user's documents: filter by owner_id, paginate by created_at; index (owner_id, created_at DESC).
  • Search: never touches Postgres. Elasticsearch holds a denormalized document keyed by the UUID (metadata + entities + classification + summary), so one query spans content and metadata.
  • Scale: partition/shard by owner_id or created_at, add read replicas, and keep Postgres as the write source of truth while CDC projects changes into Elasticsearch.

2. Metadata Indexing

Metadata indexing data flow: the Metadata DB streams changes via CDC/WAL to Kafka/Kinesis, which updates the metadata index in Elasticsearch; separately, the user client queries the Search Service, which reads from Elasticsearch

  • The Metadata DB is the source of truth for document records (UUID, filename, owner, type, state).
  • CDC tails the DB's write-ahead log (WAL) and publishes each change to Kafka/Kinesis, so an update can't be silently lost (no dual-write).
  • A consumer projects those events into the metadata index in Elasticsearch, so metadata becomes searchable within seconds.
  • The Search Service answers user queries by reading Elasticsearch, letting users search across metadata and content in one place.

Why CDC over a dual-write? After writing metadata, something must trigger downstream work. Writing to Postgres and publishing to Kafka in the app is a dual-write with no shared transaction: if the second write fails, the pipeline never runs. CDC reads Postgres's write-ahead log and publishes reliably from a durable offset, so a triggered pipeline can never silently go missing.

3. Machine Learning Pipeline

Machine learning pipeline and search index update flow: Kafka/Kinesis feeds the metadata index into Elasticsearch, while the ML pipeline (OCR to Extract Entities to classification/summarization to update Index) batch-updates the enriched fields into Elasticsearch; stages run as micro services with exponential backoff retry and a DLQ that pages oncall on exhausted retries

  • Triggered by a Kafka/Kinesis topic subscriber, and managed end-to-end by AWS Step Functions to drive stage transitions and handle failures.

  • Staged processing:

    1. OCR: extracts raw text from scanned images or image-based PDFs.
    2. Entity Extraction: identifies and extracts predefined entities from the text.
    3. Classification: categorizes the document from the extracted entities.
    4. Summarization: generates a document summary.

    Classification and Summarization run in parallel after Entity Extraction, then converge on the index update.

  • Staged files are persisted per stage under the document UUID (doc-uuid-original, doc-uuid-ocr, doc-uuid-extracted, ...), so a stage can retry from the last good artifact instead of redoing prior work.

  • Each stage runs as a micro service with exponential backoff retry; on exhausted retries the message lands in a DLQ that pages oncall.

  • The final update Index stage batch-updates the enriched fields (entities, classification, summary) into Elasticsearch rather than writing per document, cutting write amplification. A batch flushes on whichever fires first: an iteration age (max time a batch waits) or a number of records (max batch size), balancing search freshness against write cost.

  • Elasticsearch is also fed the metadata index via CDC to Kafka/Kinesis, so users search across content, metadata, and enriched fields in a single query.

Step 4: Optimization & Trade-offs (Where You Win the Interview)

Now walk the whole system against the technical (non-functional) requirements and decide where to optimize, naming the trade-off each choice makes.

Scalability

  • A load balancer sits in front of the Documents Service to distribute traffic across multiple instances.
  • The system is designed to absorb bulk uploads without performance degradation.
  • Kafka/Kinesis iteration age is monitored: a large lag means too many messages are queued, which triggers a scale-up of the ML pipeline.
  • The number of messages flowing through the stream is also tracked to confirm the system is live and processing.

Idempotency

  • Clients send an idempotency token with each request to prevent duplicate processing.
  • If the same token arrives again, the API call is not re-triggered, so no duplicate records are created in the database or object storage.
  • Each document is assigned a unique UUID stored in the database; this UUID is used throughout the entire system to deduplicate records.

Fault Tolerance & Partial Failure Handling

  • Partial upload failure: S3 lifecycle policies clean up incomplete multipart uploads.
  • Upload/database desync: a reconciliation task runs periodically (e.g., hourly or nightly) to sync documents present in S3 but missing from the metadata database, resolving cases where the upload succeeded but the database write failed due to network or service issues.
  • ML pipeline mid-stage failure: rather than restarting the entire pipeline, each stage saves its output to a deterministic path in S3:
document/{UUID}/original
document/{UUID}/ocr
document/{UUID}/extracted

On restart, the pipeline checks which outputs already exist and resumes from the last completed stage, avoiding expensive GPU reprocessing.

  • Microservice failures: all microservices (metadata index updater, ML pipeline stages) implement exponential backoff retry.
  • Dead Letter Queue (DLQ): after retries are exhausted, failed messages are sent to a DLQ to alert on-call engineers.

Consistency

  • The system operates on an eventual consistency model, which is acceptable for the asynchronous document processing flow.
  • CDC ensures no data change is lost between the metadata database and downstream consumers.

Data Correctness

  • Concurrent re-upload: a user may replace a document while its pipeline is still running. Apply last-write-wins using the upload timestamp: a stale in-flight run does not overwrite a newer committed result, so the freshest content wins in search.
  • Partial writes: ML result files are small (KB–MB), so write each with a single, atomic PUT. Do not use multipart upload for result files: multipart is not atomic and can leave a half-written file that a downstream stage reads as valid.
  • Non-deterministic ML output: the same document can classify differently on retry. Either fix a random seed for reproducibility, or explicitly accept last-write-wins. State which one you chose and why.

Say this out loud: "These concerns are one story. Idempotency makes retries safe; retries give me fault tolerance; autoscaling keeps me within SLA; eventual consistency is the price I pay for that throughput." Naming the relationship, not just the words, is the staff-level signal.

End-to-End Data Flow

Putting it all together: a user uploads or downloads through a load balancer to the Documents Service (/auth, /upload, /complete), which mints a UUID, writes bytes to S3, and persists metadata to the Metadata DB. A scheduled task reconciles S3 against the DB and a lifecycle policy sweeps corrupted uploads. CDC streams metadata changes over the WAL to Kafka/Kinesis, which fans out to the metadata index and the ML pipeline (OCR to entity extraction to parallel classification/summarization to a batched index update). Everything converges on Elasticsearch, which the Search Service queries for content and metadata.

End-to-end data flow: the user client goes through a load balancer to the Documents Service, which writes files to S3 and metadata to the Metadata DB; a scheduled task reconciles S3 with the DB; CDC/WAL streams changes to Kafka/Kinesis, feeding both the metadata index and the ML pipeline (OCR, entity extraction, classification, summarization, batched index update); all paths converge on Elasticsearch, which the Search Service reads. Annotated with the functional and technical requirements the design satisfies

DecisionChosenAlternativeWhy
Data propagationCDC (Debezium)Outbox + SQSFewer moving parts. Outbox is cheaper (no Kafka) but adds a table + poller.
File uploadPresigned URL direct to storageUpload through the serviceService stays lightweight; storage handles bandwidth. Cost: more client logic.
OrchestrationTemporal / Step FunctionsCustom microserviceDon't reinvent DAGs, retries, and state persistence.
ConsistencyEventualStrongFine for docs; users tolerate a few seconds of search lag.
Search engineElasticsearchPostgres full-textES is purpose-built for full-text + faceted search at scale.
ML result writeBatch update (flush on age or record count)Incremental per stageFewer ES writes and less write amplification; the pipeline already takes minutes.
Large-file uploadMultipart + S3 lifecycle cleanupSingle PUT through the serviceParallel chunked upload with per-chunk retry; lifecycle policy sweeps incomplete uploads. Cost: more client logic.
IdempotencyClient token + global UUIDNo dedupRepeated requests become no-ops; the UUID dedupes records across every store.

Failure Scenarios

FailureImpactSolution
Storage upload failsNo metadata writtenNo issue, nothing to clean up.
Storage OK, metadata write failsOrphan filePeriodic sync job re-creates metadata; lifecycle policy sweeps old orphans.
CDC / connector downPipeline not triggeredMonitor connector health, auto-restart; CDC resumes from last WAL offset, no data loss.
Queue messages staleProcessing delayAlert on message age.
ML stage failsPipeline stallsBackoff retry → DLQ → operator.
ES write failsDoc not searchableRetry → DLQ; document state stays "processing".
Orchestrator crashesRuns interruptedOrchestrator persists state, resumes from last completed stage.
User re-uploads mid-pipelineStale result overwrites newLast-write-wins by upload timestamp; the newer upload's result wins in search.
Duplicate request (same idempotency token)Would double-processToken check makes it a no-op; the UUID prevents duplicate records.

Common Mistakes

  • Proxying file bytes through the service. Kills your bandwidth and scaling story; use presigned URLs.
  • Skipping idempotency. Retries then double-charge GPU time and corrupt results. It is the foundation, not a nice-to-have.
  • Restarting pipelines from scratch on failure. Wastes the most expensive stage (OCR). Resume from the last completed stage.
  • Demanding strong consistency between Postgres and ES. Over-engineered; eventual consistency via CDC is the right call.
  • Reaching for multipart on tiny ML result files. It's not atomic, so you invite partial writes.

Interviewer Follow-Up Questions

"How do you handle a bulk upload of 10,000 files?" Same architecture. The client caps concurrency (5–10 parallel); the Documents Service batch-generates presigned URLs; the queue buffers the processing work; workers autoscale on iteration age. Return a batch-id so the client can poll overall progress.

"How do you enforce access control?" The Documents Service checks RBAC before it issues any URL, and presigned URLs are short-lived (e.g., 15 min) so a leaked link expires quickly. Clients never touch the bucket directly, and permission is re-checked on both upload and download.

"How do you delete a document and guarantee it's purged everywhere?" Soft-delete the metadata row first; CDC propagates the delete event so downstream consumers remove the object from S3, the Elasticsearch index, and any cache. A reconciliation job verifies the purge completed, and an audit log records it (important for GDPR / compliance).

"How do you stop Postgres and Elasticsearch from drifting?" CDC is the primary propagation path, but you still run a periodic reconciliation job that diffs the two stores and repairs gaps. For a full rebuild, replay from a CDC offset or a DB snapshot; a per-document updated_at (or sequence number) lets you detect and drop stale index entries.

"How do you scale reads for hot documents?" Serve downloads via presigned GET behind a CDN (CloudFront) so S3 isn't the bottleneck; cache hot metadata; add read replicas for the metadata DB. Search fan-out is handled by Elasticsearch itself.

"How do you protect against a noisy tenant overwhelming the system?" Per-tenant rate limits and upload quotas at the API gateway; the queue absorbs bursts; isolate expensive ML work per tenant (fair scheduling or separate queues) so one bulk upload can't starve everyone else.

"How would you support document versioning?" Keep the documents row as the stable identity and add a document_versions table (document_id, version, s3_key, created_at) with a current_version pointer on the document. Each upload appends a new version instead of overwriting, so history and rollback come for free; reads default to the current version, and the pipeline processes each version independently under document/{UUID}/{version}/.... This is a deliberate scope-out of the base design: state that you'd add it only if the product needs history or audit.

Key Terminology

TermMeaning
DAGDirected acyclic graph: tasks with dependencies and parallel branches, no cycles.
CDCChange Data Capture: streams DB changes from the WAL to a queue.
Outbox PatternA table written in the same DB transaction; a poller forwards it. Alternative to CDC.
Dual-WriteWriting two systems with no shared transaction; inconsistency risk that CDC/Outbox fixes.
Idempotency TokenClient-supplied key that makes a repeated request a no-op, preventing duplicate processing.
DLQDead-letter queue: holds messages that failed after retries are exhausted, for oncall to inspect.
Iteration AgeHow long the oldest unprocessed message has waited in the stream; a lag and autoscaling signal.
OCROptical Character Recognition: images/scans to machine-readable text.
Entity ExtractionNLP (NER) that identifies names, dates, amounts, companies.
BM25Elasticsearch's default relevance-scoring algorithm.
ReconciliationPeriodic job that compares two data sources and repairs drift.
Confidence ScoreModel certainty (0–1); below threshold routes to human review.
Faceted SearchFiltering/drill-down on results by dimensions (category, date, amount).

Summary: Your Interview Plan

TimeWhat to Do
0–5 minClarify scope: upload, search, processing. Ask about scale and file types.
5–10 minDraw the high-level architecture: storage + metadata DB + queue + workers + search.
10–20 minDetail the ML DAG (OCR → entities → classify/summarize in parallel → index).
20–30 minOptimization & trade-offs: scalability, idempotency, fault tolerance, eventual consistency.
30–35 minData correctness (partial writes, non-deterministic output) and proactive trade-offs.

Work the happy path first, then layer in reliability. And every time you draw a component, say "I chose X over Y because…"; proactively volunteering trade-offs is the strongest signal of principal-level thinking.