Concurrency Conflicts
Overlapping claim, redeem, and release requests for the same campaign and voucher must resolve to one processing order.
`commerce/event-sourced-promotion-voucher-campaign` records every change to campaigns and vouchers in sequence in a PostgreSQL event store. A basic CRUD implementation cannot reliably handle concurrent requests, retries, read-model lag, or operational recovery. This example shows how to solve those problems by separating command processing from read-model updates.
The example issues, redeems, and releases vouchers within one promotion campaign while addressing four production concerns.
Overlapping claim, redeem, and release requests for the same campaign and voucher must resolve to one processing order.
When a client retries after losing a response, the system must return the previous result without storing duplicate events.
A completed write does not guarantee an up-to-date read model. The API must report its current processing position and lag.
Poison events, projector restarts, full rebuilds, and personal-data erasure must be handled without taking the service offline.
Instead of coordinating simultaneous writes across multiple tables, the system records every state change as an event first. Current state and read models are derived from that event stream.
Separate business rules from storage mechanisms while making transaction boundaries and event-processing positions explicit in code.
Replay events to restore current state, then validate the command and determine which new events to record.
Perform the idempotency check, state rehydration, business decision, event storage, and final-response storage in one use case.
Record the stream lock, `append fence`, event payload, and global processing position in one transaction.
Read stored events in batches, then deduplicate events, update the read model, and advance the checkpoint in one transaction.
Provide failed-event replay, consistency checks, and generation-based rebuilds while measuring connection-pool usage and processing lag.
Start with the Testcontainers integration tests to verify the core behavior. To exercise the full flow, connect the application to PostgreSQL and send HTTP mutation and query requests in sequence.
Verify command processing, idempotency, read-model updates, and failure recovery without configuring a separate local PostgreSQL instance.
Set the PostgreSQL data source and HMAC secret in environment variables. The default key included with the example is for local execution only and must not be used in production.
Create a campaign through the operator API with the common authentication headers, an idempotency key, and `If-None-Match: *`.
POST /operator/api/v1/campaigns
{ campaignId, startsAt, endsAt, capacity, perUserLimit, redemptionTtlSeconds }
Send the `revision` from the create response as `expectedRevision` to detect whether another request changed the state first.
POST /operator/api/v1/campaigns/{campaignId}/activate
{ "expectedRevision": 0 }
Convert the user reference to an HMAC-based pseudonym, then validate the overall issuance capacity and per-user limit in the aggregate.
POST /api/v1/campaigns/{campaignId}/claims
{ "userRef": "member-42" }
Send the stream position from the mutation response as the minimum required projection position. This ensures the read model includes the preceding change.
GET /api/v1/campaigns/{campaignId}
X-Min-Stream-Position: {position}
If projection processing is delayed, retry only the GET request. If the entire read model diverges from the event history, rebuild it through the operator API and run the consistency check.
POST /operator/api/v1/projections/{projection}/rebuilds
GET .../rebuilds/{generation}
Command processing, read-model updates, rebuilds, and security and operations are implemented as separate concerns. Every state change passes through the PostgreSQL event history.
When a mutation succeeds, the event and idempotency result are stored first. The read model becomes current only after its projection checkpoint advances through that event.
When read-model processing is delayed, the API returns `202 PROJECTION_PENDING`.
The following Kotlin classes implement the transaction scopes and state-change rules described above.
Read the event-stream position and read-model position together. If the requested position has not been projected, wait for the configured interval, then return `202 PROJECTION_PENDING` with lag information in the response headers.
Store raw user data in an erasable mapping to HMAC-based pseudonyms. Use a snapshot as the starting point for event replay only after it passes validation.
The API responses and operational data make failure states explicit. Callers can interpret response codes and processing positions, while operators can recover through controlled retries and rebuilds.