Business logic & race conditions: protect the invariant
A request can be valid on its own and unsafe in combination. Test the business rule across concurrency, retries and state transitions.
By ProteQon Research
Start with a rule the system must never violate
A business-logic defect occurs when an application accepts a sequence of otherwise valid actions that violates a business rule. A race condition is one way that can happen: concurrent operations observe compatible starting states but produce an invalid combined result. The strongest starting point for testing is an invariant, such as “this reward may be redeemed once per account” or “the remaining balance must never become negative.”
Write the invariant with the product owner before designing the test. Include scope: once per person is different from once per account, and once per campaign is different from once per code. Identify the authoritative record and every entry point that can change it: web requests, mobile APIs, webhooks, administrative actions and background jobs. A safe primary endpoint cannot compensate for a second writer that bypasses the same rule.
Why check-then-act can fail
Illustrative sequence: one redemption allowed
Request A: reads "not redeemed"
Request B: reads "not redeemed"
Request A: grants the test reward
Request B: grants the test reward
Request A: records "redeemed"
Request B: records "redeemed"
Final flag: redeemed
Actual grants: two
Invariant: violatedThe final status may look correct while an external side effect occurred twice. Checking only the response body or the redemption flag can therefore miss the defect. Collect evidence from the authoritative ledger, counter or downstream action as well as the HTTP result. A single successful duplicate is meaningful; a failed attempt does not prove that all concurrent interleavings are safe.
Build a small, controlled test
- Establish a normal baseline with one authorized request, and verify the resulting state and side effect.
- Reset a dedicated fixture, then issue a small, agreed number of concurrent requests for the same logical operation. Set a concurrency ceiling and stop condition.
- Compare repeated requests with distinct valid sessions, retries after timeouts and requests that arrive through different supported entry points.
- Record state before and after, logical operation identifiers and relevant timestamps. Do not report duplicated status messages as duplicated value unless the underlying effect is verified.
- Keep resource-exhaustion testing separate. Increasing load indefinitely is not required to demonstrate a logic violation and may exceed authorization.
Put the invariant at the authoritative write
For a local database operation, conditional updates, unique constraints and appropriate transaction boundaries are stronger than a separate application-level “already used?” query. The example below models a reward that is claimable once in an application-owned test database. Both state changes belong in the same transaction, and the application must treat zero returned rows as “not granted.”
-- Illustrative PostgreSQL model; adapt to the real invariant.
BEGIN;
WITH claimed AS (
UPDATE test_rewards
SET redeemed_at = CURRENT_TIMESTAMP
WHERE id = $1
AND account_id = $2
AND redeemed_at IS NULL
RETURNING id, account_id, credit_amount
)
INSERT INTO test_credit_ledger (reward_id, account_id, amount)
SELECT id, account_id, credit_amount
FROM claimed
RETURNING reward_id;
COMMIT;
-- The ledger should also enforce UNIQUE (reward_id).
-- Zero rows returned means no reward was granted.
-- Roll back and handle database errors; do not report success.Under PostgreSQL Read Committed, a concurrent UPDATE waits for a conflicting updater and re-evaluates its WHERE condition against the updated row. That behavior supports this narrow conditional-update pattern. It is not a general proof that Read Committed protects multi-row business rules. For broader invariants, use a design appropriate to the data model—such as consistent row locking or serializable transactions—and handle serialization failures by retrying the complete transaction safely.
Idempotency is not just a request header
An idempotency key should identify one logical operation within a defined actor and operation scope. Atomically claim the key, bind it to a request fingerprint and retain the result so a retry can receive the original outcome. Reject an attempt to reuse the same key with different inputs. A process-local map or a “key exists?” check followed by an independent write does not coordinate multiple application instances.
External effects require an additional boundary. A database rollback cannot unsend an email or automatically reverse a third-party payment. Where appropriate, record the intended effect in a transactional outbox, use a provider-supported idempotency mechanism and make workers tolerant of repeated delivery. Define recovery and reconciliation for uncertain outcomes instead of claiming that a local transaction guarantees exactly-once behavior everywhere.
Retest the business outcome, not only the endpoint
- Repeat the controlled concurrent test and verify the invariant in authoritative records and downstream effects.
- Test a retry after the client loses the response, a worker restart and an expired or reused idempotency key.
- Confirm that two independent legitimate operations can still succeed; an overly broad lock can turn a correctness fix into unnecessary serialization.
- Document the atomic boundary, database constraints, retry policy and external-system assumptions so future endpoints preserve the same rule.