Production Case Study: B2B Voucher Redemption
Design a fictional modular-monolith voucher platform with atomic redemption, idempotency, audit history, settlement outbox, security, testing, delivery, and operations.
Learning objectives
- Design an atomic and idempotent voucher redemption boundary
- Connect architecture decisions to tests, deployment, monitoring, incidents, and rollback
What it is
This fictional B2B Voucher and Settlement Platform lets authorized issuers create voucher batches, merchants validate and redeem vouchers, operations users review exceptions, and finance users export settlement reports. It is a learning architecture, not a copy of any employer system.
Why it matters
Voucher redemption concentrates backend concerns: a code is sensitive, concurrent requests must not redeem twice, clients retry after timeouts, authorization spans tenant and merchant ownership, settlement is asynchronous, audit history matters, and operators need safe recovery.
How it works
Default modular-monolith architecture
Simple example
| Actor | Allowed capability |
|---|---|
| Issuer operator | Create and activate batches for own tenant |
| Merchant cashier | Validate and redeem for assigned merchant |
| Operations reviewer | Investigate exceptions with reason and audit trail |
| Finance analyst | Read settlement reports; cannot redeem |
| Platform administrator | Tenant administration with separately audited privilege |
The API never accepts tenant or merchant authority merely because the client sends an ID. It derives actor scope from authenticated server-side identity and enforces ownership in service policy and SQL predicates.
Backend example
CREATE TABLE voucher (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
code_hash bytea NOT NULL,
status varchar(20) NOT NULL CHECK (status IN ('ISSUED','REDEEMED','EXPIRED','CANCELLED')),
expires_at timestamptz NOT NULL,
redeemed_at timestamptz,
version integer NOT NULL DEFAULT 0,
CONSTRAINT voucher_tenant_code_uk UNIQUE (tenant_id, code_hash)
);
CREATE TABLE redemption (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
merchant_id bigint NOT NULL,
voucher_id bigint NOT NULL REFERENCES voucher(id),
idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
result_code varchar(40) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT redemption_idempotency_uk
UNIQUE (tenant_id, merchant_id, idempotency_key)
);UPDATE voucher
SET status = 'REDEEMED',
redeemed_at = :now,
version = version + 1
WHERE id = :voucherId
AND tenant_id = :tenantId
AND status = 'ISSUED'
AND expires_at > :now;One affected row means the state transition won. Zero means the voucher is absent, out of scope, expired, or already terminal; the disclosure policy determines the external response. The idempotency unique key resolves concurrent replay races.
Production example
- 1
Request
Authenticate cashier, derive tenant/merchant scope, normalize and hash the voucher code, validate request, and compute a request fingerprint.
- 2
Transaction
Read any existing idempotency result, conditionally update the voucher, insert redemption and audit rows, and insert a settlement outbox event in one short transaction.
- 3
Response
Return the stored result for an exact replay. Reject reuse of the same key with a different request fingerprint.
- 4
Async settlement
A worker claims outbox records, calls a fictional provider with its own idempotency key and deadline, records attempt outcome, and retries bounded transient failures.
- 5
Observe
Measure redemption success/conflict/error rates, latency, database pool/lock state, outbox age, settlement failures, and security-denial patterns without logging raw voucher codes.
| Failure | Expected behavior |
|---|---|
| Client timeout after commit | Replay returns stored redemption result |
| Two cashiers race | Conditional update/unique constraint permits one business redemption |
| Settlement provider down | Redemption stays committed; outbox retry remains visible |
| Worker crashes after provider success | Provider idempotency and local attempt reconciliation prevent duplicate financial effect |
| Database pool exhausted | Readiness/load shedding and alerts protect recovery; no blind retry storm |
Common mistakes
- Storing or logging raw voucher codes without classification
- Checking status and then updating in separate unprotected steps
- Using Redis as the correctness boundary for redemption
- Calling settlement inside the redemption transaction
- Equating one message delivery with one business effect
- Starting with microservices despite one team and one atomic core
- Omitting restore and reconciliation procedures
Best practices
- Hash sensitive lookup codes with an appropriate keyed design and document threat assumptions
- Use constraints and conditional SQL as concurrency arbiters
- Scope idempotency to actor and operation and bind it to the request
- Keep immutable audit events separate from mutable operational status
- Use outbox plus idempotent downstream behavior
- Adopt expand/contract migrations
- Run reconciliation and incident drills
Trade-offs
| Decision | Why chosen | Revisit when |
|---|---|---|
| Modular monolith | One team, shared transaction, lower operational cost | Modules need independent ownership, scale, or isolation |
| PostgreSQL is source of truth | Constraints and transaction semantics | Never replace correctness with cache |
| Redis optional | Rate limits or short-lived non-authoritative cache | Only with measured need and fallback |
| Outbox | Atomic business state and publish intent | Operate relay, duplicates, and retention |
| Next.js client | Typed user/admin interface | Backend contract remains client-independent |
Interview questions
- Prove that two concurrent redemptions cannot both succeed
- What does the idempotency request hash protect?
- Why is settlement outside the transaction?
- How do you handle a worker crash after provider success?
- Which data is safe to log?
- When would you split the settlement module?
- How do you reconcile provider and local state?
Hands-on task
- 1
Milestone 1 — requirements and threat model
Goal: define actors, flows, abuse, privacy, and non-goals. Acceptance: reviewed API/state diagrams and threat controls. Tests: misuse cases. Risks: copying confidential behavior. Production: name owners and audit scope.
- 2
Milestone 2 — schema and migrations
Goal: encode lifecycle, tenant scope, idempotency, audit, and outbox constraints. Acceptance: upgrade from empty and previous schema. Tests: constraint, plan, migration, and restore. Risks: incompatible enum/index change. Production: expand/contract and backup evidence.
- 3
Milestone 3 — identity and authorization
Goal: authenticate users and enforce role, tenant, merchant, and ownership policy. Acceptance: no client-controlled authority. Tests: 401, 403, horizontal access, CSRF/token cases. Risks: privilege leakage. Production: audit and revocation.
- 4
Milestone 4 — atomic redemption
Goal: one durable outcome per voucher and idempotent request. Acceptance: replay is stable and concurrent races create one effect. Tests: rollback, changed-payload conflict, and bounded concurrency. Risks: read/write race. Production: short transaction and explicit errors.
- 5
Milestone 5 — settlement outbox
Goal: reliable asynchronous delivery without holding database locks. Acceptance: visible retry and reconciliation state. Tests: duplicate delivery, provider timeout, crash-after-success. Risks: unbounded retry. Production: dead-letter/runbook and outbox-age alert.
- 6
Milestone 6 — administration and reporting
Goal: safe search, pagination, status operations, and export. Acceptance: scoped query plans and immutable audit access. Tests: authorization, keyset order, large export. Risks: data leakage. Production: retention and asynchronous export limits.
- 7
Milestone 7 — quality and delivery
Goal: unit/integration/security tests and immutable non-root delivery. Acceptance: CI gates and smoke-tested digest. Tests: image, workflow, deployment, graceful shutdown. Risks: migration/app mismatch. Production: approval and rollback rehearsal.
- 8
Milestone 8 — operations
Goal: logs, metrics, traces, dashboards, alerts, backup/restore, and incidents. Acceptance: pool-exhaustion and provider-outage drills. Tests: probes, alerts, restore, reconciliation, rollback. Risks: sensitive telemetry and alert fatigue. Production: runbook owners and postmortem actions.
References
- Spring: Spring Boot 3.5.16 System Requirements — https://docs.spring.io/spring-boot/3.5/system-requirements.html
- PostgreSQL Global Development Group: Transaction Isolation — https://www.postgresql.org/docs/18/transaction-iso.html
- MyBatis: MyBatis Spring Boot Starter Reference — https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/
- Spring: Servlet Authentication Architecture — https://docs.spring.io/spring-security/reference/6.5/servlet/authentication/architecture.html
- OWASP: Application Security Verification Standard 5.0 — https://owasp.org/www-project-application-security-verification-standard/
- Microservices.io: Pattern: Transactional Outbox — https://microservices.io/patterns/data/transactional-outbox.html
- OpenTelemetry: OpenTelemetry Concepts — https://opentelemetry.io/docs/concepts/
Retrieval check
Before you continue
- Explain the main idea in your own words without rereading the page.
- Name one production problem this knowledge helps prevent.
- Describe where you would apply it in a Spring Boot project.
Source metadata
Primary references checked on 2026-07-16. Version scope is stated at the top of this page.
Spring Boot 3.5.16 System Requirements (opens in a new tab)
Spring · official-docs
Transaction Isolation (opens in a new tab)
PostgreSQL Global Development Group · official-docs
MyBatis Spring Boot Starter Reference (opens in a new tab)
MyBatis · official-docs
Servlet Authentication Architecture (opens in a new tab)
Spring · official-docs
Application Security Verification Standard 5.0 (opens in a new tab)
OWASP · standard
Pattern: Transactional Outbox (opens in a new tab)
Microservices.io · reference
OpenTelemetry Concepts (opens in a new tab)
OpenTelemetry · official-docs