Designing a Personal Data Export System
A complete, ground-up walkthrough of building a platform that lets any user request and download their entire personal data archive, in line with data portability laws such as GDPR Article 20, the CCPA, and India’s DPDP Act.
Introduction and History
Imagine you have been using an online service for ten years. Over that decade, the service has collected your profile details, your messages, your photos, your purchase history, your search history, your location history, and dozens of other small fragments of your digital life. One day, you decide you want a copy of all of it — either because you want to move to a different service, or because you simply want to see what the company holds about you. This is exactly the problem a personal data export system (also called a data portability system or “data download” system) solves.
A data export system is a piece of infrastructure that, when triggered by a user, gathers every piece of personal data that a company’s various internal systems hold about that specific user, packages it into a human-readable and machine-readable format, and delivers it back to the user securely.
1.1 Where this requirement came from
For a long time, companies stored user data across dozens of internal databases and microservices, and there was no single place where “all of a user’s data” lived. Engineers could query one system at a time, but no one had built a way to pull everything together in one shot. Regulators eventually stepped in because users had a right to know, and a right to move their data elsewhere, without needing an engineering team to do it manually.
European Union — GDPR
The General Data Protection Regulation introduced Article 20 — the Right to Data Portability. It requires companies to let users receive their personal data “in a structured, commonly used, and machine-readable format,” and to transmit that data to another controller where feasible.
California — CCPA / CPRA
The California Consumer Privacy Act, later strengthened by the CPRA, gave California residents a similar right to request and receive a copy of their data.
India — DPDP Act
The Digital Personal Data Protection Act 2023 introduced comparable rights for Indian citizens, requiring data fiduciaries (companies) to provide data principals (users) a summary of their personal data and processing activities on request.
Industry-driven initiatives
Even before these laws, large consumer platforms built “download your data” tools voluntarily, partly as a trust-building feature and partly in anticipation of regulation. These early tools were the engineering blueprint that today’s compliance-driven systems are built on.
Think of a large hospital where your medical history is scattered across different departments — the lab has your blood test results, radiology has your X-rays, the pharmacy has your prescription history, and the billing department has your payment records. If you switch hospitals, you don’t want to visit every department individually and beg for a printout. You want one office — a “medical records office” — that collects everything from every department and hands you a single folder. A data export system is that medical records office, built for software.
“Why can’t the user just query each service’s database directly?” A strong answer covers: services often don’t expose direct data access to end users, data may be encrypted or normalized in ways unusable outside its owning service, and centralizing extraction avoids exposing internal schemas or granting risky direct database access to outside parties.
Problem and Motivation
At first glance, “collect all the user’s data and zip it up” sounds simple. In a real company, it is one of the harder distributed systems problems you can be asked to design, because of a few specific difficulties:
Data fragmentation
A modern platform can easily have 50–200 microservices, each owning its own database. A user’s data is spread across all of them — profile service, orders service, messaging service, analytics service, support-ticket service, and more.
Scale & latency
Some users have been active for a decade and have gigabytes of data (photos, videos, message history). Collecting, compressing, and packaging that volume cannot happen synchronously within an HTTP request.
Security & privacy
The exported archive is one of the most sensitive artifacts a system can produce — it is a single file containing everything about a person. A leak of this file is far worse than a single database row leaking.
Regulatory deadlines
Most portability laws require a response within a fixed window (commonly 30 days, sometimes extendable). The system must track and enforce these deadlines automatically, not rely on manual follow-up.
Partial failures
If 40 microservices need to contribute data and 2 of them are down or slow, should the export fail entirely, proceed with a note about missing data, or retry indefinitely? This is a genuine architectural decision.
Cost
Running a full data pull across every internal service for every user request, at scale, can be very expensive in compute and storage if not designed carefully — this needs rate limiting and cost-aware scheduling.
The rest of this tutorial builds a system, piece by piece, that solves every one of these problems in a way that could plausibly run in production at a company with tens of millions of users.
2.1 Data ownership is never as clean as it looks
In theory, every piece of a user’s data belongs to exactly one service. In practice, large companies acquire other companies, merge platforms, and let old systems linger for years after a “replacement” was shipped. A user’s payment history might genuinely live in three different systems: an old legacy billing database that predates a platform migration, the current payments microservice, and a third-party payment processor’s records that get periodically synced back. A data export system has to grapple with this messiness rather than assume a clean, single source of truth exists for every domain.
Imagine a person who has moved apartments four times over ten years, and their old landlords still have some of their forwarding mail. Asking “give me everything anyone has ever received on your behalf” is a lot harder than asking their current landlord alone. Real companies often look more like this than like a single tidy filing cabinet.
2.2 Third-party and shared data
Not all data about a user is generated solely by that user. A group chat contains messages from multiple participants; a shared calendar event has attendees; a marketplace transaction has both a buyer and a seller. Deciding exactly how much of this “co-owned” data to include in one person’s export — and how much of it might reveal another person’s private information — is a genuine product and legal design decision that the engineering architecture must support, typically through per-domain redaction rules described later in this tutorial.
2.3 Why this is a genuinely hard distributed systems problem
It is worth being explicit about why this deserves a full system design treatment rather than a single background script. The core difficulty is coordinating many independent, unreliable, differently-owned systems toward one consistent outcome, under a hard deadline, while producing an artifact that is itself a major security risk if mishandled. That combination — distributed coordination, partial failure tolerance, deadline enforcement, and extreme sensitivity of the output — is what makes this a favorite topic in senior and staff-level system design interviews.
Core Concepts
Before drawing any diagram, let’s define the vocabulary we will use throughout. Every term is explained with what it is, why it exists, and a simple example.
Data Export Request
What it is: A record created the moment a user clicks “Download my data.” It has a unique ID, a status (pending, processing, ready, failed, expired), and a timestamp.
Why it exists: Because the actual work of gathering data takes time, we cannot do it inline while the user waits on a web page. We need a durable record that survives even if the user closes their browser.
Example: User “Asha” clicks a button on July 30. The system creates request export-88213 with status PENDING, and immediately tells Asha “we’ll email you when it’s ready.”
Data Domain / Data Category
What it is: A logical grouping of personal data owned by one microservice — for example, “Profile,” “Orders,” “Messages,” “Payment Methods,” “Location History.”
Why it exists: Instead of treating “all user data” as one giant blob, we break it into domains so that each owning service can independently produce its own slice, in parallel, in a format it understands best.
Export Job / Task
What it is: A unit of asynchronous work — “go fetch Asha’s order history and write it to a file” is one job. A single export request typically fans out into dozens of jobs, one per data domain.
Job Orchestrator
What it is: A service (or workflow engine) that knows the full list of data domains that must be collected, creates a job for each one, tracks completion, and knows when all jobs are done so it can trigger packaging.
Software example: This is commonly implemented with a workflow engine such as Temporal, AWS Step Functions, or a custom saga coordinator built on a job queue.
Data Collector (Producer)
What it is: A small piece of code, usually living inside or next to each owning microservice, whose only job is: “given a user ID, produce this domain’s data in the agreed export format.”
Archive Packager
What it is: The component that takes all the completed domain files, validates them, compresses them, optionally encrypts them, and produces the final downloadable archive (commonly a .zip containing JSON and media files).
Signed URL / Pre-Signed Link
What it is: A time-limited, cryptographically signed URL that grants temporary access to a private file in object storage, without making the file public.
Domain Registry
What it is: A centrally maintained, machine-readable list of every data domain that must be included in a full export, along with metadata about which service owns it, roughly how long it takes to collect, and whether it contains large media files.
Why it exists: Without a single authoritative list, it becomes easy for a new microservice to launch, start storing personal data, and simply never get plugged into the export system — an invisible compliance gap that nobody notices until an audit or a user complaint surfaces it.
Practical example: When the Payments team ships a new “saved cards” feature, their service onboarding checklist includes a step: “register a data domain with the export system,” making the connection explicit and mandatory rather than optional.
Retention Window
What it is: The fixed period of time (commonly 7–30 days) that a completed archive remains available for download before it is automatically deleted from storage.
Why it exists: Balancing user convenience (enough time to actually download a large file) against security exposure (an old archive sitting in storage indefinitely is a growing liability) is exactly what the retention window is designed to manage.
Think of data domains like chapters in a book. The “Orders” chapter is written by the Orders team, the “Messages” chapter is written by the Messaging team. Each team writes their chapter independently, and later someone binds all the chapters into one final book.
A signed URL is like a movie ticket with today’s date printed on it. It works today, at this specific gate, and becomes useless tomorrow. Nobody without a valid ticket can walk in, and even someone with today’s ticket can’t reuse it next week.
“Why not just email the actual data file as an attachment?” Good answers mention: email attachment size limits, email is a weaker security boundary than an authenticated download link, and a signed URL lets you control expiry, revoke access, and log every download attempt.
Requirements
4.1 Functional requirements
- A logged-in user can request an export of all their personal data.
- The system collects data from every internal service that stores information about that user.
- The user receives a notification (email/push) when the export is ready.
- The user can download the export as a single compressed archive via a secure, time-limited link.
- The user can see the status of an in-progress request (pending, processing, ready, failed).
- Admins/compliance teams can audit every export request: who requested it, when, what was included, and who downloaded it.
- Users can request in specific formats where required (JSON for machine portability, human-readable HTML/PDF summary for readability).
These functional requirements deliberately go beyond “let the user click a button and get a file.” Status visibility matters because export jobs can take hours, and a user staring at a blank screen with no feedback will assume the feature is broken. Auditability matters because compliance teams, and sometimes regulators directly, need to demonstrate that the process actually works as documented — not just that it worked once during a demo.
4.2 Non-functional requirements
| Requirement | Target | Why it matters |
|---|---|---|
| Regulatory turnaround | Complete within legal deadline (e.g., 30 days, ideally within hours to a few days) | Non-compliance carries legal and financial penalties |
| Data completeness | All in-scope domains included or explicitly reported as failed | Silent data omission is a compliance risk |
| Security | Encrypted at rest and in transit; time-limited access | The archive is maximally sensitive — a single point of full identity exposure |
| Scalability | Support bursty demand (e.g., after a privacy news event, requests can spike 50x) | Avoid overloading upstream services during collection |
| Idempotency | Re-running a failed job must not duplicate or corrupt data | Failures and retries are the norm at this scale, not the exception |
| Auditability | Immutable audit log of every request and download | Regulators and internal compliance teams need proof of process |
| Cost control | Rate-limit and schedule off-peak where possible | Full data pulls are compute and storage intensive at scale |
High-Level Architecture
Now let’s put the pieces together. Every box in the diagram below plays a specific, named role — no generic “backend” boxes. This is the same shape of architecture you would sketch on a whiteboard in a real system design interview.
Notice that the diagram uses three distinct queues (export-requested, per-domain jobs, job-completed events) rather than one. This is deliberate: each queue represents a different fan-out/fan-in stage, and separating them lets each stage scale, retry, and fail independently without one slow domain blocking the entry point for new requests.
Component Deep Dive
Load Balancer
What it is: A Layer 7 (or Layer 4) traffic distributor that sits in front of a fleet of API Gateway or application instances, spreading incoming requests across healthy servers.
Why it exists: A single server cannot handle all traffic and, more importantly, if that one server dies, the entire system goes down with it. The load balancer removes this single point of failure and lets us scale horizontally by adding more instances behind it.
Where it’s used here: Every incoming HTTP request — creating an export, checking status, or downloading the archive — first hits the load balancer, which picks a healthy backend using round robin or least-connections, and continuously health-checks instances.
Production example: Large platforms like Netflix and Amazon run load balancers such as AWS Elastic Load Balancer or Envoy in front of virtually every internal and external service, often layered.
API Gateway
What it is: A single, well-defined entry point that all client requests pass through before reaching internal services. It handles cross-cutting concerns so individual services don’t have to reimplement them.
Why it exists: Centralizing authentication, rate limiting, and request logging in one place means these policies are enforced consistently everywhere rather than duplicated (and buggy) per service.
What it does here: verifies identity tokens (delegating to Auth), applies rate limits, routes /export/request, /export/status/{id}, and /export/download/{id} to the correct backend, and rejects malformed input before it reaches business logic.
Auth Service
What it is: The component responsible for verifying who the caller is, and — for a request as sensitive as a full personal data export — often requiring stronger proof than a normal login, such as re-authentication or multi-factor confirmation.
Export Request Service
What it is: The service that owns the lifecycle of an export request: creating it, exposing its status, and eventually marking it ready or failed. This is the “front door” business logic service, distinct from the orchestrator that does the heavy lifting.
Rate Limiter
What it is: A component (often a Redis-backed token bucket or sliding window counter) that restricts how frequently a given user, or the system as a whole, can trigger new export jobs.
Why it exists: Generating a full export is expensive — it touches dozens of downstream services. Without a limiter, a user (or a bug, or an attacker) repeatedly hitting “export” could cause a self-inflicted denial-of-service across the whole backend estate.
Export Metadata Database
What it is: A durable store (typically a relational database like PostgreSQL) holding one row per export request, including status, timestamps, requested domains, and a foreign key to the audit log.
Message Queue / Job Queue
What it is: A durable, ordered (or at-least-once) messaging system — such as Kafka, Amazon SQS, or RabbitMQ — used to decouple producers from consumers.
Why it exists: Collecting data from 50 services cannot happen synchronously in one HTTP call — it could take minutes or hours. The queue lets the API respond instantly (“request accepted”) while the actual work happens asynchronously, and it naturally absorbs bursts of demand instead of overwhelming downstream collectors all at once.
Job Orchestrator (Workflow Engine)
What it is: The brain of the system. Given an export request, it knows the full list of data domains to collect, dispatches one job per domain, tracks completion, retries failed ones, and finally triggers packaging once all domains report done (or permanently failed after retries).
Software example: Temporal or AWS Step Functions, because the overall process can span hours and must survive process restarts, deployments, and partial failures without losing state.
Data Collectors
What it is: One small handler per owning service (or per domain), responsible only for “given a user ID, export this domain’s data.” These typically live as a lightweight endpoint inside each existing microservice, rather than as one giant external scraper trying to reach into every database directly.
Staging Object Storage
What it is: A private, encrypted bucket (e.g., Amazon S3, Google Cloud Storage) where each collector writes its intermediate output file. This is temporary storage, cleaned up after packaging.
Archive Packager
What it is: Once the orchestrator confirms all domain jobs are complete, this component reads every intermediate file, validates it against an expected schema, compiles a human-readable summary (often HTML or PDF) alongside the raw machine-readable JSON, compresses everything into a single archive, and encrypts it.
Final Object Storage
What it is: A separate, private bucket holding the completed, encrypted archive, governed by a lifecycle policy that automatically deletes the file after a fixed retention window (commonly 7–30 days) so it doesn’t sit around indefinitely as a security liability.
Signed URL Generator
What it is: A small utility that produces a cryptographically signed, time-limited URL pointing at the archive in the Final Object Storage bucket, so the user’s browser can download directly from storage without routing the (potentially large) file through the application servers.
Notification Service
What it is: A service that sends the user an email or push notification once their export is ready, containing (or linking to) the signed download URL, and separately notifies them if the export failed.
Download Endpoint & Audit Log
What it is: A gateway-routed endpoint that validates the signed URL/token, streams or redirects to the file, and — critically — writes an immutable audit record of exactly who downloaded what, and when. This audit trail is what compliance teams point to as proof the process worked correctly.
Dead Letter Queue (DLQ)
What it is: A holding queue for jobs that have failed repeatedly and exhausted their retry budget. Instead of silently dropping them or retrying forever, they land here for a human or an automated remediation process to inspect.
Production example: Managed queue systems such as Amazon SQS and Google Pub/Sub support DLQs natively, automatically routing a message to a configured DLQ topic once it exceeds a set number of delivery attempts.
Web Application Firewall (WAF)
What it is: A filtering layer sitting in front of the load balancer or gateway that inspects incoming traffic for known attack signatures (SQL injection attempts, malicious bots, credential-stuffing patterns) and blocks it before it reaches application code.
Why it exists here: The export creation and download endpoints are high-value targets for credential-stuffing attacks, since a successful account takeover followed by an export request is an efficient way to steal a victim’s entire data footprint.
Content Delivery Network (CDN)
What it is: A globally distributed network of edge servers that cache and serve static content close to the user’s physical location, and commonly also handles TLS termination at the edge.
Where it’s used here: The CDN serves the static web application (the settings page where users request and check on exports) quickly worldwide, though the actual archive download bypasses the CDN and goes straight to object storage via the signed URL, since caching a private, user-specific file at the edge would be both wasteful and a security risk.
Because a full data export is one of the most damaging things an attacker with a hijacked session could obtain, many production systems require step-up authentication (re-entering a password or completing MFA) before allowing an export request to be created, even if the user is already logged in.
The team that owns the Orders database understands its schema, its business meaning, and its sensitive fields far better than a central team ever could. Asking the Orders team to expose a well-defined exportUserOrders(userId) endpoint is safer and more maintainable than giving a central export system raw database access to every service in the company.
“Why use a workflow engine instead of a simple counter in the database?” A strong answer: a counter can track “how many jobs are done” but doesn’t handle retries, timeouts, compensating actions, or crash recovery cleanly. A workflow engine persists the full execution state, so if the orchestrator process itself crashes mid-way, the workflow resumes exactly where it left off.
Data Flow and Lifecycle
Let’s trace a single request end to end, from click to download, following the sequence below.
7.1 Lifecycle states
| State | Meaning | Next possible states |
|---|---|---|
PENDING | Request accepted, not yet started | PROCESSING, CANCELLED |
PROCESSING | Jobs dispatched, collection in progress | PACKAGING, PARTIAL_FAILURE |
PACKAGING | All domains collected, archive being built | READY, FAILED |
READY | Archive available for download | EXPIRED |
PARTIAL_FAILURE | Some domains failed after retries | PACKAGING (with a “missing data” notice) or FAILED |
FAILED | Export could not be completed | (terminal, user can retry) |
EXPIRED | Retention window passed, file deleted | (terminal) |
“What happens if one of the 50 collector services is down when the export runs?” This is a deliberate design decision, not a bug to fix. A production-grade answer: retry with exponential backoff for a bounded number of attempts; if it still fails, mark that domain as failed but continue packaging the rest, include a clear note in the archive listing which domains are missing and why, and automatically re-attempt just that domain later — rather than failing the entire export and forcing the user to start over.
7.2 Case study walkthrough — following one real request
To make this concrete, let’s trace a single realistic request from start to finish. Meet Priya, a user who has had an account on the platform for six years, with data spread across a profile service, an orders service, a messaging service, a payments service, and a media service holding several hundred uploaded photos.
- Priya opens her account settings and clicks “Download my data.” Because this is a sensitive action, the Auth Service requires her to re-enter her password before the request proceeds.
- The Export Request Service creates a new row in the metadata database with status
PENDING, publishes anexport-requestedevent, and immediately returns a request ID to Priya’s browser along with an estimated ready time. - The Job Orchestrator picks up the event, updates status to
PROCESSING, and dispatches five domain jobs — one each for profile, orders, messages, payments, and media. - Four of the five collectors finish within a couple of minutes. The media collector, however, needs to process several hundred photos and takes considerably longer, streaming files directly into staging storage rather than loading them all into memory.
- Partway through, the payments collector fails due to a transient database timeout. The orchestrator retries it automatically after a short backoff delay, and the second attempt succeeds.
- Once all five domains report
DONE, the orchestrator moves the request toPACKAGINGand triggers the Archive Packager. - The packager streams every domain’s staging file into a single compressed archive, adds a human-readable HTML summary, encrypts the result using a freshly generated data encryption key wrapped by the platform’s KMS master key, and uploads it to Final Object Storage.
- The metadata database is updated to
READY, and the Notification Service emails Priya a link to her account settings page (not the raw file link directly) where she can trigger a fresh signed URL on demand. - Priya clicks the download button. The Download Endpoint validates her session, generates a signed URL valid for the next few hours, and her browser downloads the encrypted archive directly from object storage.
- Every step above — the original request, each domain’s completion, the packaging event, and the download itself — is written to the immutable audit log, giving the compliance team a complete, timestamped record if it is ever needed.
Seven days later, per the platform’s retention policy, the archive is automatically deleted from Final Object Storage, and the metadata record transitions to EXPIRED.
Data Model and Storage
The metadata database is the source of truth for request state. A simplified relational schema:
-- export_requests: one row per user-initiated request
CREATE TABLE export_requests (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, PROCESSING, READY, FAILED, EXPIRED
requested_at TIMESTAMP NOT NULL,
ready_at TIMESTAMP,
expires_at TIMESTAMP,
archive_url_ref VARCHAR(255), -- pointer to object storage key, not a raw signed URL
format VARCHAR(10) NOT NULL -- JSON, JSON+HTML
);
-- export_domain_jobs: one row per domain per request
CREATE TABLE export_domain_jobs (
id UUID PRIMARY KEY,
request_id UUID REFERENCES export_requests(id),
domain_name VARCHAR(100) NOT NULL, -- e.g. "orders", "messages"
status VARCHAR(20) NOT NULL, -- QUEUED, RUNNING, DONE, FAILED
attempt_count INT DEFAULT 0,
staging_key VARCHAR(255),
last_error TEXT
);
-- export_audit_log: append-only, never updated or deleted
CREATE TABLE export_audit_log (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL, -- REQUEST_CREATED, DOMAIN_DONE, ARCHIVE_READY, DOWNLOADED
actor_id UUID,
ip_address VARCHAR(64),
occurred_at TIMESTAMP NOT NULL
);Export requests are relatively low-volume (compared to, say, page views) but require strong consistency — we must never lose track of a request’s true status. A relational database with transactions is the right tool here, whereas the actual bulky user data payloads live in object storage, not in this database.
8.1 Indexing strategy
Two query patterns dominate this schema, and the indexes should be built around them directly rather than added ad hoc later:
- “Find all requests for user X” — powers the export history endpoint, so an index on
export_requests(user_id, requested_at)keeps this fast even for long-tenured users with many past requests. - “Find all unresolved domain jobs for request Y” — the orchestrator checks this constantly while waiting for fan-in to complete, so an index on
export_domain_jobs(request_id, status)keeps this check cheap even under heavy concurrent export volume.
The audit log table, by contrast, is optimized purely for appending and for occasional compliance-driven lookups by request_id, so a simpler index on request_id alone, plus reliance on the database’s natural insertion order for chronological reads, is normally sufficient.
Fan-Out Data Collection
This is the heart of the system. The orchestrator must reliably fan out to many independent services, and fan back in once they’re done, while tolerating partial failure. This is a variant of the well-known Saga pattern used for distributed transactions, adapted here for distributed data collection rather than distributed writes.
9.1 Orchestrator logic (Java example)
public class ExportOrchestrator {
private final JobQueuePublisher jobQueue;
private final ExportMetadataRepository metadataRepo;
private final List<String> allDataDomains; // e.g. ["profile", "orders", "messages", ...]
public void startExport(UUID requestId, UUID userId) {
metadataRepo.updateStatus(requestId, ExportStatus.PROCESSING);
for (String domain : allDataDomains) {
DomainJob job = DomainJob.builder()
.requestId(requestId)
.userId(userId)
.domainName(domain)
.attemptCount(0)
.build();
metadataRepo.saveDomainJob(job);
jobQueue.publish("export.domain.job", job);
}
}
// Called when a collector reports completion or failure
public void onDomainJobResult(DomainJobResult result) {
if (result.isSuccess()) {
metadataRepo.markDomainDone(result.getJobId(), result.getStagingKey());
} else if (result.getAttemptCount() < MAX_RETRIES) {
retryWithBackoff(result);
} else {
metadataRepo.markDomainFailed(result.getJobId(), result.getError());
}
if (metadataRepo.allDomainsResolved(result.getRequestId())) {
triggerPackaging(result.getRequestId());
}
}
private void retryWithBackoff(DomainJobResult result) {
long delayMs = (long) Math.pow(2, result.getAttemptCount()) * 1000;
jobQueue.publishDelayed("export.domain.job", result.toRetryJob(), delayMs);
}
private void triggerPackaging(UUID requestId) {
metadataRepo.updateStatus(requestId, ExportStatus.PACKAGING);
jobQueue.publish("export.packaging.trigger", requestId);
}
}9.2 A single data collector (Java example)
// Lives inside the Orders microservice
public class OrdersExportCollector implements DataDomainCollector {
private final OrdersRepository ordersRepository;
private final ObjectStorageClient storageClient;
@Override
public DomainJobResult collect(DomainJob job) {
try {
List<Order> orders = ordersRepository.findAllByUserId(job.getUserId());
String json = JsonWriter.toPortableJson(orders); // structured, machine-readable
String stagingKey = "staging/" + job.getRequestId() + "/orders.json";
storageClient.putEncryptedObject(stagingKey, json.getBytes(StandardCharsets.UTF_8));
return DomainJobResult.success(job.getId(), job.getRequestId(), stagingKey);
} catch (Exception e) {
return DomainJobResult.failure(job.getId(), job.getRequestId(),
job.getAttemptCount() + 1, e.getMessage());
}
}
}Because jobs may be retried, collect() must be safe to run more than once for the same job — writing to the same staging key each time (overwrite, not append) achieves this naturally.
Packaging, Encryption and Delivery
10.1 Packaging steps
- Read every domain’s staging file for the request.
- Validate each file against its expected schema (catch a collector bug before it reaches the user).
- Generate a human-readable index/summary (often an HTML or PDF overview) alongside the raw JSON files.
- Compress everything (domain JSON files, media files, the summary) into a single archive, typically
.zip. - Encrypt the archive at rest using a per-archive data encryption key, itself encrypted by a master key held in a key management service (envelope encryption).
- Upload to Final Object Storage with a lifecycle rule to auto-delete after the retention window.
- Delete the staging files (they’ve served their purpose).
10.2 Signed URL generation (Java example)
public class DownloadUrlService {
private final S3Presigner presigner;
private static final Duration LINK_TTL = Duration.ofHours(24);
public String generateSignedDownloadUrl(String objectKey) {
GetObjectRequest getRequest = GetObjectRequest.builder()
.bucket("final-export-archives")
.key(objectKey)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(LINK_TTL)
.getObjectRequest(getRequest)
.build();
PresignedGetObjectRequest presigned = presigner.presignGetObject(presignRequest);
return presigned.url().toString();
}
}“Why generate the signed URL fresh on each download attempt instead of storing it?” Because signed URLs expire, storing a stale one is useless after the TTL passes. Storing the object storage key (a stable reference) and generating the signed URL on demand, each time the user clicks download, keeps every link short-lived and revocable.
10.3 Archive Packager (Java example)
The packager reads every completed domain’s staging file, validates it, and streams everything into a single compressed, encrypted archive without holding the whole thing in memory at once:
public class ArchivePackager {
private final ObjectStorageClient storageClient;
private final KmsClient kmsClient;
private final ExportMetadataRepository metadataRepo;
public void packageArchive(UUID requestId) {
List<DomainJob> completedJobs = metadataRepo.getCompletedDomainJobs(requestId);
List<DomainJob> failedJobs = metadataRepo.getFailedDomainJobs(requestId);
String finalKey = "archives/" + requestId + "/export.zip";
DataEncryptionKey dek = kmsClient.generateDataKey();
try (OutputStream storageStream = storageClient.openUploadStream(finalKey);
CipherOutputStream encryptedStream = dek.wrapStream(storageStream);
ZipOutputStream zip = new ZipOutputStream(encryptedStream)) {
for (DomainJob job : completedJobs) {
addDomainFileToZip(zip, job);
}
if (!failedJobs.isEmpty()) {
zip.putNextEntry(new ZipEntry("MISSING_DATA_NOTICE.txt"));
zip.write(buildMissingDataNotice(failedJobs).getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
zip.putNextEntry(new ZipEntry("summary.html"));
zip.write(buildHumanReadableSummary(completedJobs).getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
} catch (IOException e) {
metadataRepo.updateStatus(requestId, ExportStatus.FAILED);
throw new PackagingException("Failed to build archive for " + requestId, e);
}
kmsClient.storeWrappedKeyReference(finalKey, dek.getWrappedKey());
ExportStatus finalStatus = failedJobs.isEmpty()
? ExportStatus.READY
: ExportStatus.PARTIAL_FAILURE;
metadataRepo.markReady(requestId, finalKey, finalStatus);
}
private void addDomainFileToZip(ZipOutputStream zip, DomainJob job) throws IOException {
zip.putNextEntry(new ZipEntry(job.getDomainName() + ".json"));
try (InputStream in = storageClient.openDownloadStream(job.getStagingKey())) {
in.transferTo(zip); // streams in chunks, avoids loading full file into memory
}
zip.closeEntry();
}
}Notice that addDomainFileToZip streams each staging file directly into the zip entry rather than reading it fully into a byte array first — this is the streaming approach referenced earlier that keeps memory usage roughly constant regardless of total archive size.
API Design
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/export/request | POST | Create a new export request for the authenticated user |
/api/v1/export/{requestId}/status | GET | Check the current status of a request |
/api/v1/export/{requestId}/download | GET | Redirect to (or return) a fresh signed download URL |
/api/v1/export/history | GET | List the user’s past export requests |
/api/v1/export/{requestId} | DELETE | Cancel a pending request or delete a ready archive early |
POST /api/v1/export/request
Authorization: Bearer <token>
Content-Type: application/json
{
"format": "JSON_AND_HTML",
"domains": ["ALL"]
}
Response 202 Accepted
{
"requestId": "8f14e45f-ceea-4a2b-8b1a-1e2f3a4b5c6d",
"status": "PENDING",
"estimatedReadyBy": "2026-08-02T10:00:00Z"
}HTTP 202 explicitly communicates “your request has been accepted for asynchronous processing, not completed yet” — this is the semantically correct status code for any long-running, queued operation, and it’s a small detail interviewers notice.
11.1 Status polling vs webhooks
Clients need to learn when an export becomes ready. Two common approaches, often offered together:
- Polling: The client periodically calls
GET /export/{requestId}/status. Simple to implement, but naive clients polling too aggressively can add unnecessary load — the API should return aRetry-Afterheader suggesting a sensible polling interval. - Push notification / webhook: The Notification Service emails or pushes the user directly when status changes to
READYorFAILED, which is the primary mechanism in practice — polling exists mainly to support the “check status” button on a settings page.
11.2 Representative error responses
// Rate limited
HTTP 429 Too Many Requests
{
"error": "RATE_LIMITED",
"message": "You can request a new export once every 24 hours.",
"retryAfterSeconds": 43200
}
// Requesting status of someone else's export (authorization failure)
HTTP 403 Forbidden
{
"error": "FORBIDDEN",
"message": "You do not have access to this export request."
}
// Attempting to download before it is ready
HTTP 409 Conflict
{
"error": "NOT_READY",
"message": "This export is still being prepared.",
"status": "PROCESSING"
}“Should the status endpoint reveal that a request ID exists at all if it belongs to another user, or return 404 instead of 403?” A privacy-conscious answer favors returning 404 for another user’s request ID rather than 403 — 403 confirms the ID is valid and simply not yours, which is a small information leak an attacker could use to enumerate valid request IDs; 404 avoids revealing that distinction.
11.3 Advantages, disadvantages and trade-offs of this architecture
| Aspect | Advantage | Trade-off / cost |
|---|---|---|
| Asynchronous, queue-based design | Absorbs bursts gracefully; API stays responsive under load | Adds end-to-end latency compared to a (theoretical, impractical) synchronous approach; requires users to wait for a notification rather than getting an instant result |
| Decentralized collectors per service | Preserves service ownership boundaries; each team controls its own export logic and redaction rules | Requires ongoing coordination — a central registry must track every domain, and new services must remember to register |
| Best-effort partial completion | Meets deadlines even when some domains are temporarily unavailable; better user experience than all-or-nothing failure | Adds complexity: the packager must communicate missing data clearly, and users may need to re-request later for full completeness |
| Short-lived signed URLs and retention windows | Minimizes the window during which a leaked link or forgotten archive is exploitable | Users must actively download promptly; expired links require regenerating access, adding minor friction |
| Workflow-engine-based orchestration | Durable, crash-resilient coordination of long-running, multi-step processes | Adds an operational dependency (the workflow engine itself) and a learning curve for teams unfamiliar with it |
No architecture is free of trade-offs, and it’s worth being able to articulate these explicitly — in a system design interview, naming the cost of a decision alongside its benefit is usually more convincing than only listing benefits.
Security
Because the output of this system is uniquely sensitive — one file containing everything about a person — security has to be treated as a first-class design concern, not an afterthought.
Strong authentication
Require step-up authentication (password re-entry or MFA) before allowing a new export request, since a hijacked session should not be enough on its own.
Encryption everywhere
TLS in transit for every hop; envelope encryption at rest for staging files and the final archive, using a Key Management Service (KMS) for master keys.
Least-privilege access
Data collectors can only read the specific user’s data they’re asked for — never bulk-query — enforced via scoped service credentials and query parameters, not application logic alone.
Time-limited access
Signed URLs expire quickly (hours, not days); the archive itself is deleted from storage after a short retention window regardless of whether it was downloaded.
Immutable audit trail
Every request, every domain completion, and every download attempt is logged to an append-only audit store, satisfying regulator requirements for proof of process.
Abuse prevention
Rate limiting on request creation prevents an attacker (or compromised account) from repeatedly triggering exports to exfiltrate data or to run up infrastructure cost as a denial-of-service vector.
If an attacker gains access to a victim’s account, the data export feature becomes an extremely efficient way to steal everything about that person in one click. This is exactly why step-up authentication and immediate email notification (“someone just requested an export of your data — was this you?”) are treated as mandatory controls, not optional nice-to-haves.
12.1 Rate limiter (Java example)
A simple token-bucket rate limiter, backed by Redis, prevents a single account from creating export requests faster than a reasonable pace (for example, one new request per 24 hours, with a small burst allowance):
public class ExportRateLimiter {
private final StringRedisTemplate redis;
private static final int MAX_REQUESTS_PER_WINDOW = 1;
private static final Duration WINDOW = Duration.ofHours(24);
public boolean allowRequest(UUID userId) {
String key = "export-rate-limit:" + userId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, WINDOW);
}
return count != null && count <= MAX_REQUESTS_PER_WINDOW;
}
}12.2 Circuit breaker around a flaky collector (Java example)
If one domain’s collector service is unhealthy, a circuit breaker stops sending it new work for a cool-down period, instead of letting every export request pile up retries against a service that is clearly struggling:
public class CollectorCircuitBreaker {
private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
private volatile Instant openUntil = Instant.EPOCH;
private static final int FAILURE_THRESHOLD = 5;
private static final Duration COOL_DOWN = Duration.ofMinutes(10);
public boolean isOpen() {
return Instant.now().isBefore(openUntil);
}
public void recordSuccess() {
consecutiveFailures.set(0);
}
public void recordFailure() {
int failures = consecutiveFailures.incrementAndGet();
if (failures >= FAILURE_THRESHOLD) {
openUntil = Instant.now().plus(COOL_DOWN);
}
}
}When the breaker is open, the orchestrator routes affected domain jobs directly to a “deferred” state rather than dispatching them, and retries once the cool-down period elapses — protecting a struggling downstream service from being made worse by a flood of export traffic on top of whatever is already causing it trouble.
12.3 Defense in depth summary
| Layer | Control |
|---|---|
| Network edge | WAF, DDoS protection, TLS termination |
| Gateway | Authentication, rate limiting, input validation |
| Application | Step-up auth for export creation, per-user request throttling |
| Data | Envelope encryption at rest, scoped least-privilege service credentials |
| Delivery | Short-lived signed URLs, single-use download tokens where feasible |
| Observability | Immutable audit log, real-time alerting on anomalous access patterns |
Scalability and Performance
13.1 Handling burst demand
Export requests are naturally spiky — a privacy news story, a regulatory deadline, or a competitor’s data breach can cause a sudden surge. Because the queue-based architecture decouples request intake from actual processing, the API layer can always respond quickly (202 Accepted) even while the backend processes a growing backlog at a sustainable rate. The job queue acts as a shock absorber.
13.2 Controlling load on downstream services
Fifty simultaneous export jobs each hitting the Orders database at once could degrade normal application traffic. Two common techniques address this:
- Per-domain concurrency limits: Cap how many concurrent export jobs any single downstream service will process, queuing the rest.
- Off-peak scheduling: For non-urgent bulk collection, schedule jobs to run during a domain’s known low-traffic window.
13.3 Large media files
Photos and videos can dominate archive size. Rather than routing large binary files through the packager, collectors for media-heavy domains can write directly to object storage and simply record references (keys) that the packager includes by reference in the final archive manifest, or streams directly into the zip without loading the whole file into memory.
“How would you avoid running out of memory when zipping a 20GB archive?” A solid answer: stream-based (chunked) compression rather than loading the entire archive into memory at once — read each source file in fixed-size chunks, write compressed chunks directly to the destination stream, so peak memory usage stays roughly constant regardless of total archive size.
13.4 Estimating capacity
A useful back-of-envelope exercise before building this system is estimating expected load. Suppose a platform has 50 million total users, and historically about 0.1% of active users request an export in a given month — that is roughly 50,000 requests per month, or a modest steady-state average of under two requests per minute. The real design challenge is not the average, but the tail: a single privacy-related news story or a bulk “delete my account” campaign can spike this by one or two orders of magnitude within a single day. Sizing the job queue, per-domain concurrency limits, and worker pool autoscaling around the 99th-percentile burst, not the average, is what keeps the system stable during exactly the moments it matters most.
| Scenario | Approx. requests/day | Design implication |
|---|---|---|
| Steady state | ~1,500 | A small, always-on worker pool comfortably keeps up |
| Moderate spike (news event) | ~30,000 | Autoscaling adds workers; queue depth grows temporarily but drains within SLA |
| Severe spike (regulatory deadline, mass account deletion) | ~200,000+ | Per-domain concurrency limits protect downstream services; queue absorbs backlog over several hours |
High Availability and Reliability
An export system that quietly loses track of requests, or that cannot survive a single service restart without corrupting state, will eventually cause a missed regulatory deadline. The reliability techniques below are what make the difference between a demo-quality prototype and something safe to run in production.
14.1 Retries with exponential backoff
Transient failures — a downstream database briefly overloaded, a momentary network blip — are common at this scale and should not be treated as permanent failures. Retrying immediately, however, can make a struggling service worse by adding more load exactly when it’s least able to handle it. Exponential backoff (doubling the delay between each retry, as shown in the orchestrator code earlier) gives a struggling dependency room to recover between attempts.
14.2 Dead letter queues for persistent failures
Not every failure resolves itself through retries. Jobs that exhaust their retry budget are routed to a dead letter queue rather than being silently dropped or, worse, retried forever in a way that could mask a genuine, ongoing outage. A growing DLQ is itself a signal — as noted in the alerting table earlier — that something systemic needs attention.
14.3 Idempotent operations
Because retries are a normal part of this system’s operation, every write needs to be safe to repeat. Writing a domain’s staging file always overwrites the same key rather than appending, and status transitions check the current state before applying an update, so a duplicate message from an at-least-once delivery queue never corrupts the final result.
14.4 Durable workflow state
The orchestrator’s progress — which domains are done, which are still pending, how many retries each has used — is persisted outside the orchestrator process itself, either in the metadata database or in a workflow engine’s own durable execution log. This means a crash, a routine deployment, or an autoscaling event that kills and restarts the orchestrator does not lose in-flight requests; a new instance simply reads the persisted state and continues.
14.5 Multi-AZ and multi-region redundancy
Object storage buckets and the metadata database are configured for replication across multiple availability zones at minimum, so that a single data center outage doesn’t strand in-flight export requests or make a completed archive briefly or permanently unreachable.
14.6 Circuit breakers
As shown in the security section’s code example, a circuit breaker around each domain collector stops sending it new work once it starts failing consistently, giving it room to recover instead of compounding an existing problem — a small addition that meaningfully improves the system’s behavior during a partial outage.
Monitoring, Logging and Metrics
| Metric | Why it matters |
|---|---|
| Requests created per hour | Detects unusual spikes (abuse or genuine demand surge) |
| Time-to-ready (p50/p95/p99) | Tracks whether the system is meeting its internal and regulatory SLAs |
| Per-domain failure rate | Surfaces a specific unhealthy downstream collector quickly |
| DLQ depth | A growing DLQ signals a systemic issue needing attention |
| Download success/failure rate | Detects signed-URL or storage-layer problems |
| Storage cost per archive | Feeds capacity planning and cost optimization |
Distributed tracing (e.g., via OpenTelemetry) is particularly valuable here, since a single export request’s trace spans dozens of services — being able to see one request’s full journey, end to end, across every collector, is essential for debugging a specific user’s failed export.
15.1 Example alerting rules
| Alert | Condition | Severity |
|---|---|---|
| SLA breach risk | p95 time-to-ready exceeds 80% of the regulatory deadline | High — page on-call |
| Elevated domain failure rate | A single domain’s failure rate exceeds 5% over a 15-minute window | Medium — notify owning team |
| Growing DLQ | DLQ depth increases for three consecutive hours without manual intervention | Medium — investigate systemic cause |
| Download anomaly | A single account triggers unusually many download attempts in a short window | High — possible account takeover, notify security team |
15.2 Multi-region and data residency
For a global platform, personal data is often stored in region-specific databases to satisfy data residency requirements — European user data staying within EU data centers, for example. The export system has to respect these same boundaries: a collector for a European user’s data should run within the EU region and write staging files to an EU-based bucket, rather than pulling everything into one central region for convenience. The orchestrator becomes region-aware, routing each domain job to the correct regional deployment of that collector, and the final archive is assembled and stored in the region tied to the requesting user’s data residency requirements.
“How would this design change for a user whose data spans two regions, for example after moving countries?” A thoughtful answer: the orchestrator can dispatch jobs to collectors in both regions, but the final packaging and storage step should still happen in whichever region has the stricter residency requirement, since combining data doesn’t relax the strictest applicable constraint.
Deployment and Cloud
16.1 Containerized, independently scalable services
The Export Request Service, Orchestrator, Packager, and Notification Service are best deployed as separate containerized services on an orchestration platform such as Kubernetes, each with its own horizontal pod autoscaler. This matters because these components have very different load profiles: the Export Request Service scales with incoming HTTP traffic, while the Packager scales with completed jobs waiting to be assembled — coupling them into one monolithic deployment would force one component’s scaling needs onto all the others.
16.2 Managed messaging infrastructure
Running your own message broker cluster is a significant operational burden — patching, scaling, and monitoring a distributed queueing system is a full discipline on its own. Using a managed offering such as Amazon SQS/SNS, a managed Kafka service, or Google Pub/Sub lets the team focus engineering effort on the export system’s actual business logic rather than on operating queue infrastructure.
16.3 Object storage lifecycle rules
Cloud object storage services natively support automatic expiration policies at the bucket or prefix level. This is precisely how the retention window requirement described earlier gets enforced in practice — rather than writing and maintaining a custom cleanup job that must run reliably forever, a single storage lifecycle rule declares “delete anything under this prefix after N days” and the cloud provider guarantees it happens, removing an entire class of potential bugs around forgotten or failed cleanup jobs.
16.4 Infrastructure as Code
Given how compliance-sensitive this system is, its infrastructure — bucket policies, retention rules, IAM roles governing which services can read which staging buckets — is best defined declaratively using a tool such as Terraform rather than configured by hand through a cloud console. Every change to a bucket’s retention policy or a collector’s IAM permissions then goes through the same code review process as application code, producing a reviewable, auditable history of exactly when and why access controls changed — itself a useful artifact during a compliance audit.
16.5 Blue-green and canary deployments
Because in-flight export requests can span many hours, deploying a new version of the orchestrator carelessly risks disrupting active workflows mid-execution. A blue-green or canary deployment strategy, combined with the durable workflow state described in the reliability section, lets new orchestrator versions roll out gradually while existing in-flight requests continue running against whichever version they started with, or safely resume on the new version thanks to persisted state.
Design Patterns and Anti-Patterns
17.1 Patterns used
Saga / Orchestration
A central orchestrator coordinates many independent steps (domain collections) and handles partial failure gracefully, tracking each step’s outcome and deciding what to do next rather than assuming every step always succeeds.
Fan-out / Fan-in
One request spreads into many parallel jobs, then results are gathered back into a single output — the same shape used in MapReduce-style batch processing, applied here to a much smaller, per-user scale.
Outbox (optional refinement)
Services can write “export-relevant” events to an outbox table alongside their normal writes, ensuring exported data reflects a consistent snapshot even under concurrent updates, rather than risking a collector reading a half-updated record mid-write.
Envelope encryption
A per-file data key, itself encrypted by a master key in a KMS, limits blast radius if any single key is compromised, since rotating or revoking the master key doesn’t require re-encrypting every archive individually.
Circuit breaker
Protects a struggling downstream collector from cascading failure by temporarily halting new requests to it, covered in detail in the security section.
Bulkhead isolation
Giving each domain collector its own concurrency limit and worker pool means one misbehaving domain (for example, an unusually slow media collector) cannot starve resources needed by faster, unrelated domains — the same principle as watertight compartments in a ship’s hull.
17.2 Anti-patterns to avoid
Direct database access
Letting a central export job query every service’s database directly bypasses ownership boundaries, breaks encapsulation, and makes future schema changes dangerous.
Synchronous collection
Trying to collect all data within a single HTTP request will time out for any user with a non-trivial amount of data.
All-or-nothing failure
Failing the entire export because one minor domain is temporarily down frustrates users and creates unnecessary regulatory risk from missed deadlines.
Permanent archive storage
Leaving completed archives in storage indefinitely turns them into a long-term security liability with no compliance benefit.
Best Practices and Common Mistakes
18.1 Practices worth adopting
Maintain a living domain registry
Treat the list of data domains as a centrally maintained, machine-readable registry that every new microservice must register with as part of its own launch checklist. Without this discipline, new product features quietly launch, start storing personal data, and never get connected to the export pipeline — an invisible gap that usually only surfaces during an audit or a user complaint, at which point it is far more expensive to fix.
Offer both machine and human-readable formats
Regulations generally require a structured, machine-readable format such as JSON, but in practice the overwhelming majority of users simply want to browse their data in a readable page. Shipping both a raw JSON export and a generated HTML summary in the same archive satisfies the legal requirement while actually being useful to the person who requested it.
Notify immediately on request
Sending an email the moment an export is requested — separately from the “it’s ready” notification — gives a legitimate account holder an early warning if someone else triggered the request without their knowledge, turning a routine feature into a lightweight account-takeover detection signal at essentially no extra engineering cost.
Version the export format
Include a format version number in the archive’s manifest file from day one. As domains evolve and new fields are added or restructured over time, a version marker lets any tooling built to consume these archives (including the user’s own future automation) detect and handle differences gracefully instead of breaking silently.
18.2 Common mistakes to avoid
- Forgetting derived or inferred data. It’s easy to focus on obviously “owned” data like messages and orders while forgetting that machine-learning-derived attributes — a computed preference score, a churn-risk label, a recommendation profile — are also personal data under most definitions. A thorough domain registry review should explicitly ask each team, “do you generate any inferred data about users,” not just “do you store data users typed in.”
- Assuming all domains are similarly sized. A design that processes domains sequentially, one after another, might work fine in testing with small sample accounts, but will badly miss its SLA for a long-tenured, heavy user with years of message history and thousands of photos. Parallel fan-out, sized around the heaviest realistic domain rather than the average one, is what makes the SLA achievable across the whole user base, not just typical accounts.
- Persisting signed URLs. Storing the actual signed URL string in the database, rather than the stable underlying object key, means that URL becomes stale and useless the moment its TTL expires — and worse, a database backup or log line containing that URL becomes a real, if time-limited, access credential. Always store the key, and generate a fresh signed URL only at the moment of use.
- Treating this as a one-time project. Because the domain registry only stays accurate through continuous team discipline, treating the export system as a project that ships once and is “done” almost guarantees drift over time as the product evolves. Periodic automated audits — comparing the list of services that store personal data against the list registered with the export system — catch this drift before it becomes a compliance problem.
Real-World Examples
Google Takeout
Lets users select which of dozens of Google products to include (Gmail, Photos, Drive, Maps history, and more), and generates the archive asynchronously, notifying the user by email when it is ready — a direct real-world instance of the request/notify/download lifecycle covered in this tutorial. It predates GDPR, having launched originally as a trust-building initiative called the “Data Liberation Front.”
Facebook “Download Your Information”
Offers both a machine-readable JSON export and a browsable HTML version so users can open the archive directly in a browser without any special tooling — mirroring the dual-format approach (structured JSON plus a human-readable summary) described in the packaging section above.
Twitter/X data archive
Packages tweets, direct messages, and account activity into a downloadable zip generated asynchronously and delivered via in-app and email notification, closely following the same fan-out-collect-package-notify pattern used throughout this tutorial.
Spotify “Download Your Data”
Splits the export into an “account data” tier available quickly and an “extended streaming history” tier that can take longer to prepare — a real-world example of tiering export domains by cost and latency rather than treating all data as equally cheap to collect.
Across all of these examples, three architectural choices repeat consistently: the request is always handled asynchronously with a notification on completion, the output almost always offers a structured machine-readable format to satisfy portability requirements, and the download is always delivered through a time-limited, authenticated link rather than an email attachment or an always-public URL.
Compliance Deep Dive
| Regulation | Jurisdiction | Key requirement relevant to this system |
|---|---|---|
| GDPR Article 20 | European Union | Structured, commonly used, machine-readable format; response typically within one month |
| CCPA / CPRA | California, USA | Consumers can request disclosure/portability of personal information collected in the prior 12 months |
| DPDP Act 2023 | India | Data principals can request a summary of personal data and processing activities from data fiduciaries |
This tutorial describes a general architectural pattern for educational purposes; actual compliance obligations vary by jurisdiction, data type, and company size, and should be validated with a qualified legal/privacy team before implementation.
20.1 Identity verification before fulfilling a request
Regulators generally expect a company to take reasonable steps to confirm a data export request genuinely comes from the account holder before releasing anything, precisely because the archive is so sensitive. This is exactly why the architecture places step-up authentication in front of the Export Request Service rather than treating a valid session alone as sufficient — the legal requirement to verify identity directly motivates a concrete engineering control.
20.2 Response deadlines are a first-class system constraint
Most portability laws specify a maximum response time, and many allow a one-time extension for complex requests if the company notifies the user within the original window. Practically, this means the metadata database’s requested_at timestamp isn’t just informational — it should feed directly into the alerting rules described in the monitoring section, so an approaching deadline breach pages an engineer well before it becomes a compliance incident, not after.
20.3 Cross-border data transfer
When a user downloads their archive, that download itself can constitute a data transfer, and if the user is located in a different country than where their data resides, some jurisdictions have specific rules about transfers to certain destinations. This rarely changes the architecture significantly, since the download is triggered directly by the data subject themselves rather than the company choosing to move data elsewhere, but it is worth documenting in a system’s compliance review.
Data Minimization and Redaction
Not everything a system stores about a user should necessarily go into the export, and not everything in a co-owned record (like a group chat) should reveal a third party’s private details. This section covers how the architecture handles that boundary.
21.1 What typically gets excluded or redacted
- Other users’ personal data inside shared records: In a group chat export, other participants’ display names might be retained for context, but their own private account details (email, phone number) are not included just because they appear in a shared thread.
- Internal fraud and risk signals: Some jurisdictions and legal interpretations allow excluding internal risk-scoring data if disclosing it would materially undermine fraud prevention effectiveness — this is a legal determination made per-domain, not a default assumption.
- Data about other people entirely: If User A is mentioned in User B’s support ticket, that ticket belongs to User B’s export, not User A’s, even though User A is named in it.
21.2 Where redaction happens in the architecture
Redaction logic lives inside each domain’s own collector, not in a central “scrub everything” step. The team that owns the Messaging service is best positioned to know which fields in a message record are safe to export and which belong to someone else, exactly the same ownership argument made earlier for why collectors are decentralized in the first place.
public class MessagesExportCollector implements DataDomainCollector {
@Override
public DomainJobResult collect(DomainJob job) {
List<Message> rawMessages = messageRepository.findAllInvolvingUser(job.getUserId());
List<ExportableMessage> redacted = rawMessages.stream()
.map(m -> ExportableMessage.builder()
.messageId(m.getId())
.sentAt(m.getSentAt())
.content(m.getContent())
.otherParticipantDisplayName(m.getOtherParticipant().getDisplayName())
// deliberately NOT including other participant's email, phone, or internal ID
.build())
.collect(Collectors.toList());
return writeAndUpload(job, redacted);
}
}“How would you make sure a new microservice doesn’t accidentally leak another user’s private data in someone’s export?” A strong answer: enforce a mandatory schema review whenever a new domain collector is registered in the central domain registry, and add automated tests (see the next section) that assert an export never contains an identifier belonging to any user other than the requester.
Testing Strategy
Because this system’s failure modes are unusually costly — either missing legally required data, or leaking someone else’s private information — testing deserves particular attention beyond typical unit tests.
Per-collector contract tests
Each domain collector is tested against a schema contract that the packager also validates against, catching mismatches before they ever reach a real user’s archive.
Cross-user leakage tests
Automated tests seed two test users with interlinked data (e.g., a shared conversation) and assert that User A’s export never contains any identifier unique to User B beyond an approved allow-list of fields (like a display name).
Chaos / failure-injection tests
Deliberately fail a subset of collectors during a test export run and assert the orchestrator still produces a partial archive with a clear “missing data” notice, rather than hanging indefinitely or silently omitting the notice.
Load tests against realistic burst patterns
Simulate the “severe spike” scenario from the capacity planning table and confirm per-domain concurrency limits and autoscaling keep downstream services within their normal operating envelope.
Retention and deletion tests
Confirm that archives are actually deleted from storage once the retention window lapses, since a bug here silently defeats a core security control.
Signed URL expiry tests
Confirm a signed URL genuinely stops working after its TTL, and that a fresh valid link is generated on the next download attempt.
Cost Optimization and Capacity Planning
Full data exports are inherently more expensive per request than typical read traffic, because a single request touches dozens of services and produces a potentially large file that must be stored, even temporarily. A few techniques keep this sustainable at scale:
- Tiered domain collection: Cheap, fast domains (profile, settings) are collected first and can be made available quickly; expensive domains (full media libraries, long streaming history) can be offered as a separate, slower-arriving tier, similar to the Spotify example above.
- Compression before storage: Compressing text-heavy domains (JSON, logs, message history) before writing to staging storage significantly reduces both storage cost and the final archive size, since structured text compresses very well.
- Short retention windows: A shorter retention period (for example, 7 days instead of 30) reduces steady-state storage cost while still giving users a reasonable window to download, and it doubles as a security improvement.
- Off-peak scheduling for non-urgent bulk exports: Where the legal deadline allows some flexibility, scheduling the heaviest collection jobs during a domain’s low-traffic hours reduces the need for over-provisioning downstream services purely to absorb export load.
- Right-sized worker autoscaling: Scaling collector worker pools based on queue depth, rather than a fixed always-on fleet sized for worst-case burst, keeps steady-state compute cost proportional to actual demand.
Glossary
| Term | Meaning |
|---|---|
| Data Portability | The legal right for an individual to receive their personal data in a usable format and, where feasible, move it elsewhere. |
| Fan-Out / Fan-In | A pattern where one unit of work splits into many parallel tasks (fan-out) and their results are later combined back into one (fan-in). |
| Envelope Encryption | Encrypting data with a data key, then encrypting that data key with a separate master key held in a key management service. |
| Signed URL | A time-limited, cryptographically signed link granting temporary access to a private storage object. |
| Dead Letter Queue (DLQ) | A holding queue for messages/jobs that repeatedly failed processing, set aside for manual review instead of being retried forever. |
| Idempotency | A property where performing the same operation multiple times produces the same result as performing it once. |
| Circuit Breaker | A safeguard that stops sending requests to a failing dependency for a cool-down period, to avoid making an existing problem worse. |
| Saga Pattern | A way to coordinate a sequence of distributed operations (here, data collections) with compensating or retry logic for partial failure. |
Frequently Asked Questions
Why not just build one giant service that owns everything instead of many collectors?
It would violate service ownership boundaries, create a massive coupling point, and require duplicating knowledge of every other team’s schema — the distributed collector approach keeps each team responsible for its own data’s correctness.
What happens to in-flight exports during a deployment?
Because job state lives in a durable queue and database rather than in-memory, a rolling deployment of the orchestrator or collectors does not lose progress — new instances simply pick up where the queue left off.
How do you prevent someone from requesting an export of someone else’s data?
Every domain collector’s query is scoped strictly to the authenticated user’s own ID, passed through the trusted internal message payload — never a client-supplied user ID — so there is no path for cross-user data leakage even if the request payload were tampered with.
Should deleted data be included in an export?
Generally, only data the company currently retains should be included; data already deleted per a retention policy is, by definition, no longer held and cannot be exported.
What if a user requests an export, then deletes their account before it finishes?
A well-designed orchestrator treats an in-flight export as independent of the account deletion workflow for a short grace period, allowing the already-triggered export to complete and remain downloadable briefly, since the user explicitly asked for their data before deciding to leave — the two workflows should coordinate rather than one silently cancelling the other.
How is this different from a routine nightly data backup?
A backup is an operational copy of everything, meant for disaster recovery, generally inaccessible to end users and often unstructured. An export is user-triggered, scoped strictly to one individual, formatted for portability and readability, and delivered through a controlled, audited channel — the two systems solve entirely different problems even though both involve “copying data somewhere.”
Can this same architecture be reused for “right to erasure” (deletion) requests?
The fan-out/fan-in shape is very similar — dispatch a job per domain, track completion, handle partial failure — but the collectors perform deletion instead of extraction, and the packaging/download stages are replaced with a final verification and confirmation step. Many teams do build both features on the same underlying orchestration platform.
How large can a single user’s export realistically get, and does the design change for very large archives?
For most users, a full export is a few megabytes of structured text plus whatever photos or videos they’ve uploaded, but a small fraction of long-tenured, heavy users can reach many gigabytes. The streaming approach used throughout the packaging pipeline — reading and writing in fixed-size chunks rather than loading whole files into memory — is what allows the same code path to handle both a small text-only export and a multi-gigabyte media-heavy one without special-casing.
Who is responsible for keeping the domain registry accurate over time?
Ownership typically sits with a central privacy or platform engineering team, but accuracy depends on every product team following the registration step in their own service launch checklist, backed by periodic automated audits that flag services storing personal data without a corresponding registry entry.
Does the user need to specify which data domains they want, or is it always everything?
Many real-world implementations default to “everything” for simplicity and full compliance coverage, but also expose an optional selective mode — letting a user choose just “messages” or just “order history,” for example — which reduces both wait time and archive size for users who don’t need the full dataset.
How do you handle a user who submits many export requests in quick succession before the first one even finishes?
The rate limiter covered in the security section is the primary control — capping new request creation to roughly one per day per user makes this scenario rare by design. On top of that, the Export Request Service can simply return the existing in-progress request’s ID if one is already active, rather than spinning up a duplicate, redundant workflow for the same underlying need.
Summary and Key Takeaways
What we covered
- A personal data export system solves the problem of gathering a user’s data, which is scattered across dozens of independently owned services, into one secure, downloadable archive.
- The architecture is fundamentally asynchronous: an API layer accepts the request instantly, a durable orchestrator fans work out to per-domain collectors and fans results back in, and a packager assembles the final encrypted archive.
- Every component in the request path — CDN, load balancer, WAF, API gateway, auth service, rate limiter, job queues, orchestrator, collectors, object storage, packager, signed URL generator, and notification service — plays one clearly scoped role, which is exactly the level of specificity a strong system design answer should demonstrate.
- Security is not optional polish — step-up authentication, envelope encryption, time-limited signed URLs, and immutable audit logging are core requirements given how sensitive the output is.
- Partial failure handling (retries, dead letter queues, “best-effort with a clear missing-data notice”) is what separates a production-grade design from a toy one.
- Redaction and data minimization logic belongs inside each domain’s own collector, not a central scrubbing step, for the same ownership reasons that justify decentralized collection in the first place.
- Regulations like GDPR, CCPA, and India’s DPDP Act are the reason this system exists, but the underlying engineering challenges — fan-out/fan-in, idempotency, scalability, security — are universal distributed systems problems worth understanding on their own merits.
26.1 Quick recap — if asked to whiteboard this in 10 minutes
- Start with the client-to-gateway path: Client → CDN → Load Balancer → WAF → API Gateway → Auth Service.
- Show the request being accepted (202) and dropped onto a queue, with a metadata database recording status.
- Draw the orchestrator fanning out to N domain collectors in parallel, each writing to staging storage.
- Show fan-in back to the orchestrator, then the packager building the final encrypted archive once all domains resolve (success or permanent failure).
- Close the loop with a signed URL, a notification, and an audit log entry for every meaningful event.
- Mention retries, DLQs, and rate limiting proactively — these are exactly what separates a senior-level answer from a junior one on this specific problem.
A great personal data export system is judged less by the beauty of its zip file and more by how confidently it can prove — to the user, to compliance, and to a regulator — that every byte in the archive belongs to the right person, arrived within the deadline, and left no trace behind once its retention window expired.