Designing an Event-Driven Document Synchronization Platform on AWS
This is not a tutorial about connecting three AWS services. It is a system-design case study about preserving a fast upload experience while several independent systems validate, enrich, replicate, and index the same document.
The central question: how can an upload be accepted quickly without claiming the document is ready before every required downstream projection is trustworthy?
1. The operational problem—not the happy path
A synchronous implementation looks attractive: save metadata, call every downstream API, update search, then respond. It also couples upload latency and availability to the slowest dependency. One timeout can leave the caller uncertain even when part of the work succeeded, and retries can duplicate side effects.
| Requirement | Architecture consequence |
|---|---|
| Uploads must remain responsive | Acknowledge after durable acceptance, not after every integration finishes |
| Consumers run at different speeds | Give each integration its own queue and concurrency controls |
| Failures must be diagnosable | Track status, attempts, error category, and timestamps per destination |
| Search must not expose partial data | Publish to the searchable read model only after the readiness policy passes |
| Retries are unavoidable | Treat delivery as at least once and make every consumer idempotent |
| New integrations will be added | Route versioned domain events without changing the upload API |
Uploads must remain responsive
Acknowledge after durable acceptance, not after every integration finishes
Consumers run at different speeds
Give each integration its own queue and concurrency controls
Failures must be diagnosable
Track status, attempts, error category, and timestamps per destination
Search must not expose partial data
Publish to the searchable read model only after the readiness policy passes
Retries are unavoidable
Treat delivery as at least once and make every consumer idempotent
New integrations will be added
Route versioned domain events without changing the upload API
2. Architecture and responsibility boundaries
End-to-end document synchronization
EventBridge handles routing; SQS owns buffering and backpressure; Lambda runs stateless workers; PostgreSQL remains the source of truth; OpenSearch is a derived read model. Those boundaries matter because an event bus is not a work queue, and a search index should not become the authority for workflow state.
This complements my enterprise document pipeline monitor, which focuses on secure storage operations and production observability. Here, the emphasis is the backend coordination model behind the pipeline.
3. Closing the database-to-event reliability gap
Saving a document row and calling PutEvents are two separate writes. If the database commits and event publication fails, the document is stranded. If the event is published and the transaction rolls back, consumers act on state that does not exist.
I would use a transactional outbox: write the document, required synchronization targets, and an outbox event in one PostgreSQL transaction. A separate publisher claims unpublished rows, sends them to EventBridge, and records the publication result. AWS documents this exact dual-write risk in its transactional outbox guidance.
The API can now return 202 Accepted with a document ID and status URL after durable acceptance. The client receives an honest promise: the work was recorded, not that every projection is already complete.
4. Event contract design
Events carry stable identifiers and routing facts—not the document binary or an accidental copy of the database model.
{
"specversion": "1.0",
"id": "evt_01J...",
"type": "document.accepted.v1",
"source": "document-ingestion-api",
"time": "2026-08-07T08:30:00Z",
"data": {
"documentId": "doc_01J...",
"tenantId": "tenant_123",
"objectKey": "tenant_123/doc_01J.../source.pdf",
"contentType": "application/pdf",
"correlationId": "req_01J..."
}
}- Version event types explicitly so producers and consumers can evolve independently.
- Include tenant, correlation, event, and document identifiers for authorization and tracing.
- Keep sensitive metadata and presigned URLs out of long-lived events.
- Store schemas in source control and run compatibility checks in CI.
- Treat event ordering as scoped to a document, not globally guaranteed.
5. Why EventBridge and SQS are both present
| Component | Job | Failure it isolates |
|---|---|---|
| EventBridge | Content-based routing and fan-out | Producer does not know every consumer |
| One SQS queue per consumer | Durable buffer and backpressure boundary | A slow search indexer does not slow validation |
| Lambda event source mapping | Batch polling and elastic worker execution | Consumers scale according to their own workload |
| Consumer DLQ | Quarantine poison messages | Repeated bad inputs stop consuming retry capacity |
EventBridge
- Job
- Content-based routing and fan-out
- Failure it isolates
- Producer does not know every consumer
One SQS queue per consumer
- Job
- Durable buffer and backpressure boundary
- Failure it isolates
- A slow search indexer does not slow validation
Lambda event source mapping
- Job
- Batch polling and elastic worker execution
- Failure it isolates
- Consumers scale according to their own workload
Consumer DLQ
- Job
- Quarantine poison messages
- Failure it isolates
- Repeated bad inputs stop consuming retry capacity
EventBridge target delivery failure and consumer processing failure are different operational events. An EventBridge DLQ captures events the bus could not deliver to a target; each consumer queue needs its own redrive policy for messages that arrived but could not be processed. AWS explains the former in its EventBridge DLQ documentation.
6. Idempotency: designing for at-least-once delivery
Duplicate delivery is normal. A Lambda can complete its side effect and time out before SQS receives the acknowledgement. The retry must become a no-op, not a second index entry or duplicate notification.
A unique key such as (event_id, consumer_name) prevents duplicate claims. Updates use compare-and-set semantics so a stale worker cannot overwrite a terminal success. For batches, partial batch responses retry only failed records; AWS also recommends idempotent Lambda consumers because SQS event source mappings process at least once in the Lambda and SQS guide.
7. The status model is a state machine
| Entity | Key fields | Purpose |
|---|---|---|
| document | id, tenant_id, object_key, overall_status, version | Canonical lifecycle and optimistic concurrency |
| document_sync_target | document_id, target, required, status, attempts, next_retry_at, error_code | Independent truth for every projection |
| outbox_event | event_id, aggregate_id, event_type, payload, published_at | Reliable event publication |
| processed_event | event_id, consumer, processed_at | Idempotency ledger |
| document_status_history | from_status, to_status, actor, reason, occurred_at | Auditable transitions |
document
- Key fields
- id, tenant_id, object_key, overall_status, version
- Purpose
- Canonical lifecycle and optimistic concurrency
document_sync_target
- Key fields
- document_id, target, required, status, attempts, next_retry_at, error_code
- Purpose
- Independent truth for every projection
outbox_event
- Key fields
- event_id, aggregate_id, event_type, payload, published_at
- Purpose
- Reliable event publication
processed_event
- Key fields
- event_id, consumer, processed_at
- Purpose
- Idempotency ledger
document_status_history
- Key fields
- from_status, to_status, actor, reason, occurred_at
- Purpose
- Auditable transitions
The aggregator calculates readiness from required target rows in one transaction. It never trusts a worker-supplied “all done” flag. A document becomes READY only when every required projection has reached its terminal success state; optional enrichments can fail and produce DEGRADED without hiding an otherwise usable record.
8. Search visibility without partial records
Search indexing is a projection, not the final source of truth. The search consumer builds a complete document from canonical data, writes a deterministic index ID, and records the source document version. Queries filter on is_searchable=true and tenant scope. If an older event arrives late, version comparison prevents it from overwriting newer content.
- Index with document ID as the stable OpenSearch _id.
- Persist source_version and reject stale updates.
- Use an alias for zero-downtime index migrations and reindexing.
- Run reconciliation jobs that compare READY records with the search projection.
- Delete through the same event path so search and downstream replicas converge.
9. Failure taxonomy and recovery
| Failure class | Response | Example |
|---|---|---|
| Transient | Exponential backoff with jitter | Downstream 503 or throttling |
| Permanent data error | Quarantine immediately | Unsupported file type or invalid schema |
| Poison message | Move to consumer DLQ after bounded attempts | Repeatable parser crash |
| Delivery failure | Inspect EventBridge target DLQ | Missing queue permission |
| Silent drift | Detect and repair through reconciliation | READY row missing from search |
Transient
- Response
- Exponential backoff with jitter
- Example
- Downstream 503 or throttling
Permanent data error
- Response
- Quarantine immediately
- Example
- Unsupported file type or invalid schema
Poison message
- Response
- Move to consumer DLQ after bounded attempts
- Example
- Repeatable parser crash
Delivery failure
- Response
- Inspect EventBridge target DLQ
- Example
- Missing queue permission
Silent drift
- Response
- Detect and repair through reconciliation
- Example
- READY row missing from search
Redrive is an audited operation, not “send everything again.” An operator fixes the cause, selects messages by error category, records the reason, and replays them with the original event ID so idempotency remains intact.
10. Backpressure, scaling, and cost controls
Unlimited concurrency can turn a traffic spike into a downstream outage. Each queue gets a reserved concurrency budget based on dependency capacity. Batch size, batching window, visibility timeout, maximum receive count, and payload duration are tuned from measurements—not copied from defaults.
- Scale on queue age as well as queue depth; age reveals whether the user-facing SLA is at risk.
- Cap search-writer concurrency to protect the cluster during ingestion bursts.
- Use S3 object references rather than placing document bodies in events.
- Batch compatible writes, but preserve per-record failure reporting.
- Set retention and log sampling policies so observability cost does not grow without bounds.
11. Security and tenant isolation
Authentication at the API is only the first boundary. Every message carries a tenant ID, but workers verify that the document and S3 key belong to that tenant before access. IAM roles are separated per consumer, S3 and SQS are encrypted, secrets remain in Secrets Manager, and personally identifiable metadata is excluded from logs and event payloads.
For the broader authorization model—permission composition, backend enforcement, cache invalidation, and auditability—see my enterprise RBAC architecture deep dive.
12. Observability tied to user impact
| Signal | What it answers |
|---|---|
| End-to-end readiness latency p50/p95/p99 | How long until an accepted document is usable? |
| ApproximateAgeOfOldestMessage | Which consumer is threatening the processing SLA? |
| Failure and DLQ rate by target/error code | Is the issue systemic, data-specific, or permission-related? |
| Outbox unpublished age | Are committed documents failing to enter the event system? |
| Search reconciliation drift | Does user-visible search match canonical state? |
| Correlation trace | What happened to one document across every boundary? |
End-to-end readiness latency p50/p95/p99
How long until an accepted document is usable?
ApproximateAgeOfOldestMessage
Which consumer is threatening the processing SLA?
Failure and DLQ rate by target/error code
Is the issue systemic, data-specific, or permission-related?
Outbox unpublished age
Are committed documents failing to enter the event system?
Search reconciliation drift
Does user-visible search match canonical state?
Correlation trace
What happened to one document across every boundary?
CloudWatch dashboards are useful only when they lead to action. Alarms map to runbooks for stuck outbox rows, queue backlog, permission failures, throttling, and DLQ growth. User-facing notifications are emitted from state transitions; the delivery design is covered in my production notification system tutorial.
13. Delivery strategy and verification
I would deliver one consumer end to end first, then add routes behind the same contract. Infrastructure is defined as code, deployments use aliases and gradual traffic shifting, and contract tests validate events before promotion.
- Load test burst traffic and verify backpressure protects dependencies.
- Kill a worker after its side effect to prove retry idempotency.
- Break queue permissions to prove EventBridge delivery alarms and DLQ capture.
- Inject malformed documents to prove quarantine behavior.
- Replay duplicate and out-of-order events to prove version guards.
- Rebuild the search index from canonical records to prove recoverability.
14. Trade-offs and alternatives I considered
| Alternative | When I would choose it | Why not the default here |
|---|---|---|
| SNS + SQS | Simple broadcast without rich routing | EventBridge gives cleaner content-based routing and integration evolution |
| Step Functions | A bounded workflow needs explicit sequencing or compensation | Independent projections do not need central orchestration for every step |
| SQS FIFO | Strict per-document ordering is mandatory | Lower flexibility and throughput are unnecessary when consumers use version guards |
| DynamoDB + Streams | The canonical model fits key-value access and CDC | Relational transactions and operational queries fit this workflow well |
| Direct Lambda targets | Very small, low-risk fan-out | Queues provide the backpressure and failure isolation required here |
SNS + SQS
- When I would choose it
- Simple broadcast without rich routing
- Why not the default here
- EventBridge gives cleaner content-based routing and integration evolution
Step Functions
- When I would choose it
- A bounded workflow needs explicit sequencing or compensation
- Why not the default here
- Independent projections do not need central orchestration for every step
SQS FIFO
- When I would choose it
- Strict per-document ordering is mandatory
- Why not the default here
- Lower flexibility and throughput are unnecessary when consumers use version guards
DynamoDB + Streams
- When I would choose it
- The canonical model fits key-value access and CDC
- Why not the default here
- Relational transactions and operational queries fit this workflow well
Direct Lambda targets
- When I would choose it
- Very small, low-risk fan-out
- Why not the default here
- Queues provide the backpressure and failure isolation required here
15. Outcome: what this architecture proves
The result is a platform that acknowledges uploads quickly, contains downstream failures, exposes truthful progress, and prevents incomplete records from leaking into search. More importantly, it is designed around the uncomfortable realities of distributed systems: dual writes, duplicates, stale events, poison messages, partial failure, recovery, and cost.
That is the engineering strength this case study demonstrates: I do not begin with a list of AWS services. I begin with invariants, failure modes, data ownership, and operational outcomes—then choose the smallest set of services that makes those guarantees understandable and evolvable.
Names, volumes, identifiers, and domain-specific workflows are intentionally generalized to protect confidential implementation details.