Build CoreOrdered learning track

HTTP Validation, Idempotency, and Errors

Learn Production Grade Contract-First Java Orchestration Platform - Part 017

HTTP validation, idempotency, and error handling untuk platform contract-first: validation taxonomy, Jakarta Validation, Problem Details, idempotency fingerprint, PostgreSQL idempotency table, race condition, retry semantics, dan production failure model.

13 min read2569 words
PrevNext
Lesson 1740 lesson track0922 Build Core
#java#jax-rs#jersey#openapi+7 more

Part 017 — HTTP Validation, Idempotency, and Errors

A production API is not production-grade because it can parse JSON.

It is production-grade when it can answer these questions precisely:

Is this request structurally valid?
Is this request semantically valid?
Is the caller allowed to do this action?
Is the target resource in a state that accepts this action?
Was this request already attempted before?
If the client retries, will the system duplicate the side effect?
If the database rejects the write, can we explain it deterministically?
If the workflow fails later, did the HTTP response lie?

Most API bugs happen because teams treat validation, idempotency, and errors as separate concerns.

They are not separate.

They are one boundary system.

In our regulatory enforcement platform, a POST /v1/cases request may create a case, write audit records, start a Camunda process, enqueue a Kafka event, and return 202 Accepted to the caller. A network timeout, duplicate click, gateway retry, worker crash, database deadlock, or process incident can turn one user action into multiple conflicting effects if the boundary is weak.

This part builds the HTTP boundary that prevents that.


1. The Mental Model: HTTP Boundary as a Decision Machine

A good HTTP endpoint is not a function that receives JSON and returns JSON.

It is a decision machine.

Each step has a different question.

BoundaryQuestionExample FailureTypical Status
EdgeCan this request enter the service?Body too large, unsupported route413, 404, 405
ParsingCan the request be decoded?Invalid JSON400
Media typeIs the content type supported?text/plain sent to JSON endpoint415
Contract validationDoes it match OpenAPI/DTO rules?Missing caseType400
Semantic validationDoes it make business sense?incidentDate is in the future422 or 400
AuthorizationCan this principal do this?Officer cannot access this jurisdiction403
IdempotencyIs this duplicate or conflicting replay?Same key, different body409
State transitionIs this action allowed now?Close already closed case409
PersistenceDid durable invariants hold?Unique constraint violation409 or 500
WorkflowDid process accept command?Camunda incident after async boundary202 plus later incident, or 500 before boundary

The exact status mapping must be decided by your API contract. The important point is not whether every organization chooses 400 or 422 for a semantic validation failure. The important point is that the mapping is consistent, documented, tested, and observable.


2. Do Not Treat Validation as One Thing

The word "validation" hides multiple different checks.

A top-tier engineer separates them.

syntax validation      -> can we parse the input?
shape validation       -> does the payload match the contract?
field validation       -> are primitive fields valid?
semantic validation    -> is the meaning valid?
authorization check    -> can this actor do this operation?
state validation       -> is the aggregate/workflow in a valid state?
idempotency check      -> is this a safe retry or a conflicting duplicate?
persistence invariant  -> does the database still accept the final write?

These checks are not interchangeable.

A database unique constraint cannot tell the client that reportedBy.email has invalid format. Bean Validation cannot know whether the caller has jurisdiction access. OpenAPI cannot know whether a case is already closed. A JAX-RS resource method should not manually duplicate all database constraints.

The design rule:

Validate as early as possible, but enforce invariants at the layer that owns the truth.

That gives us a layered model.

No layer is trusted alone.

Each layer reduces the set of bad requests that can reach the next layer.


3. Contract-First Request Shape

Start with the OpenAPI operation.

paths:
  /v1/cases:
    post:
      operationId: submitCase
      summary: Submit a regulatory enforcement case for intake
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            minLength: 20
            maxLength: 128
        - name: X-Correlation-Id
          in: header
          required: false
          schema:
            type: string
            maxLength: 128
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitCaseRequest'
      responses:
        '202':
          description: Case accepted for intake processing
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '500':
          $ref: '#/components/responses/InternalServerError'

Then define the body schema.

SubmitCaseRequest:
  type: object
  required:
    - externalReference
    - caseType
    - subject
    - allegation
  additionalProperties: false
  properties:
    externalReference:
      type: string
      minLength: 1
      maxLength: 80
    caseType:
      type: string
      enum: [LICENSING_BREACH, MARKET_ABUSE, CONDUCT_RISK, REPORTING_FAILURE]
    subject:
      $ref: '#/components/schemas/CaseSubject'
    allegation:
      $ref: '#/components/schemas/Allegation'
    evidenceReferences:
      type: array
      maxItems: 50
      items:
        $ref: '#/components/schemas/EvidenceReference'

The contract prevents vague payloads.

Important contract choices:

additionalProperties: false
bounded string lengths
bounded array sizes
enums for stable public vocabulary
required fields for minimum useful command
explicit error responses
explicit idempotency header

These constraints are not cosmetic. They protect the service from ambiguity, resource exhaustion, and invalid downstream assumptions.


4. DTO Validation with Jakarta Validation

In Java, Jakarta Validation gives us a declarative way to express field-level and object-level constraints.

The DTO should reflect the external contract, not the domain entity.

public record SubmitCaseRequestDto(
        @NotBlank
        @Size(max = 80)
        String externalReference,

        @NotNull
        CaseTypeDto caseType,

        @NotNull
        @Valid
        CaseSubjectDto subject,

        @NotNull
        @Valid
        AllegationDto allegation,

        @Size(max = 50)
        List<@Valid EvidenceReferenceDto> evidenceReferences
) {}

Nested DTO:

public record CaseSubjectDto(
        @NotBlank
        @Size(max = 120)
        String legalName,

        @NotBlank
        @Pattern(regexp = "^[A-Z0-9-]{4,40}$")
        String registrationNumber,

        @NotBlank
        @Size(min = 2, max = 2)
        String jurisdictionCode
) {}

Header validation can be explicit in the resource or in a request mapper.

public record SubmitCaseHttpHeaders(
        @NotBlank
        @Size(min = 20, max = 128)
        String idempotencyKey,

        @Size(max = 128)
        String correlationId
) {}

Resource method:

@POST
public Response submitCase(
        @HeaderParam("Idempotency-Key") String idempotencyKey,
        @HeaderParam("X-Correlation-Id") String correlationId,
        @Valid SubmitCaseRequestDto body
) {
    SubmitCaseHttpHeaders headers = new SubmitCaseHttpHeaders(
            idempotencyKey,
            correlationId
    );

    validator.validateAndThrow(headers);

    SubmitCaseCommand command = mapper.toCommand(headers, body);
    SubmitCaseResult result = submitCaseUseCase.submit(command);

    return responseMapper.toResponse(result);
}

This example validates headers manually because JAX-RS parameter validation behavior depends on runtime integration. Manual header validation also makes the boundary explicit and testable.


5. Validation Groups: Useful but Dangerous

Validation groups can model different validation contexts.

public interface Submit {}
public interface Amend {}

public record AllegationDto(
        @NotBlank(groups = Submit.class)
        @Size(max = 4000)
        String narrative,

        @NotNull(groups = Submit.class)
        LocalDate incidentDate,

        @Size(max = 50, groups = Amend.class)
        String amendmentReason
) {}

Use groups when the same DTO truly supports multiple operations.

But be careful.

If SubmitCaseRequestDto and AmendCaseRequestDto have different meanings, prefer different DTOs.

Bad design:

public record CaseRequestDto(
        String id,
        String status,
        String reason,
        String comment,
        String decision,
        String assignee,
        Boolean urgent
) {}

Then use groups to pretend every operation is well-modeled.

Better design:

SubmitCaseRequestDto
AmendCaseRequestDto
AssignCaseRequestDto
CloseCaseRequestDto
EscalateCaseRequestDto

Validation groups are a tool, not a substitute for operation-specific contracts.


6. Semantic Validation Belongs After DTO Validation

Field validation answers:

Is the value shaped correctly?

Semantic validation answers:

Does the value make sense in our domain?

Example semantic rules:

incidentDate cannot be after receivedAt
jurisdictionCode must be supported by the tenant
caseType must be accepted by the intake channel
subject registration number must match jurisdiction format
at least one allegation category must be reportable
externalReference must be unique per reporting institution

These rules need services, database reads, or reference data.

They do not belong inside simple annotation constraints unless the rule is local and deterministic.

Use a semantic validator inside the application boundary.

public final class SubmitCaseSemanticValidator {
    private final JurisdictionCatalog jurisdictionCatalog;
    private final IntakePolicyRepository intakePolicyRepository;

    public ValidationResult validate(SubmitCaseCommand command) {
        ValidationResult result = ValidationResult.ok();

        if (!jurisdictionCatalog.exists(command.subject().jurisdictionCode())) {
            result = result.add("subject.jurisdictionCode", "UNSUPPORTED_JURISDICTION");
        }

        if (command.allegation().incidentDate().isAfter(command.receivedAt().toLocalDate())) {
            result = result.add("allegation.incidentDate", "INCIDENT_DATE_IN_FUTURE");
        }

        if (!intakePolicyRepository.accepts(command.channel(), command.caseType())) {
            result = result.add("caseType", "CASE_TYPE_NOT_ACCEPTED_FOR_CHANNEL");
        }

        return result;
    }
}

Design rule:

Use annotation validation for local shape.
Use application semantic validation for cross-field and reference-data rules.
Use database constraints for durable invariants.

7. Problem Details as the Error Envelope

Do not invent a different JSON error format for every endpoint.

Use a single error envelope based on Problem Details.

Example validation response:

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Request validation failed",
  "status": 400,
  "detail": "The request contains invalid fields.",
  "instance": "/v1/cases",
  "errorCode": "REQ_VALIDATION_FAILED",
  "correlationId": "01JZ9R6Q8N3E9HB0Y0S53AV3HZ",
  "violations": [
    {
      "field": "subject.registrationNumber",
      "code": "PATTERN",
      "message": "must match registration number format"
    },
    {
      "field": "allegation.incidentDate",
      "code": "INCIDENT_DATE_IN_FUTURE",
      "message": "incident date cannot be after received date"
    }
  ]
}

Keep message safe.

Do not leak:

SQL queries
stack traces
internal hostnames
security policy internals
Camunda execution IDs unless explicitly safe
Kafka broker details
raw JWT claims

A good error response helps the caller fix the request without helping an attacker map the system.


8. Error Taxonomy for This Platform

Create a registry.

Error CodeOwnerStatusRetryableExample
REQ_MALFORMED_JSONHTTP Adapter400NoInvalid JSON body
REQ_VALIDATION_FAILEDHTTP Adapter400NoMissing required field
REQ_SEMANTIC_INVALIDApplication422/400NoUnsupported jurisdiction
AUTH_REQUIREDSecurity401Maybe after authMissing/invalid token
AUTH_FORBIDDENSecurity403NoNo access to case jurisdiction
IDEMPOTENCY_KEY_REQUIREDHTTP Adapter400NoMissing key on POST
IDEMPOTENCY_REPLAYIdempotency Store200/202No extra side effectSame key and same fingerprint
IDEMPOTENCY_CONFLICTIdempotency Store409NoSame key, different request
CASE_STATE_CONFLICTDomain409NoCase already closed
CASE_NOT_FOUNDApplication404NoUnknown case ID
DEPENDENCY_TIMEOUTInfrastructure503/504YesDownstream timeout
DB_DEADLOCK_RETRYABLEPersistence503/409Yes internallyTransaction deadlock
INTERNAL_ERRORPlatform500UnknownUnhandled exception

This registry becomes a contract.

Every new endpoint must map failures into it or explicitly add a new error code.


9. JAX-RS Exception Mapping

Centralize exception mapping.

Do not write try/catch blocks in every resource method.

@Provider
public final class ConstraintViolationExceptionMapper
        implements ExceptionMapper<ConstraintViolationException> {

    private final ProblemFactory problemFactory;

    public Response toResponse(ConstraintViolationException exception) {
        ProblemDetails problem = problemFactory.validationProblem(
                exception.getConstraintViolations().stream()
                        .map(this::toViolation)
                        .toList()
        );

        return Response.status(Response.Status.BAD_REQUEST)
                .type("application/problem+json")
                .entity(problem)
                .build();
    }

    private Violation toViolation(ConstraintViolation<?> violation) {
        return new Violation(
                normalizePath(violation.getPropertyPath()),
                violation.getConstraintDescriptor()
                        .getAnnotation()
                        .annotationType()
                        .getSimpleName()
                        .toUpperCase(),
                violation.getMessage()
        );
    }
}

Domain exception mapping:

@Provider
public final class ApplicationExceptionMapper
        implements ExceptionMapper<ApplicationException> {

    private final ProblemFactory problemFactory;

    public Response toResponse(ApplicationException exception) {
        ProblemDetails problem = problemFactory.fromApplicationError(exception.error());

        return Response.status(exception.error().httpStatus())
                .type("application/problem+json")
                .entity(problem)
                .build();
    }
}

Unhandled exception mapping:

@Provider
public final class UnhandledExceptionMapper
        implements ExceptionMapper<Throwable> {

    private static final Logger log = LoggerFactory.getLogger(UnhandledExceptionMapper.class);
    private final ProblemFactory problemFactory;

    public Response toResponse(Throwable exception) {
        log.error("Unhandled HTTP request failure", exception);

        ProblemDetails problem = problemFactory.internalError();

        return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                .type("application/problem+json")
                .entity(problem)
                .build();
    }
}

The unhandled mapper must log the full internal error but return a safe external error.


10. Problem Factory

Keep problem construction in one place.

public final class ProblemFactory {
    private final RequestContext requestContext;

    public ProblemDetails validationProblem(List<Violation> violations) {
        return new ProblemDetails(
                URI.create("https://api.example.com/problems/validation-failed"),
                "Request validation failed",
                400,
                "The request contains invalid fields.",
                requestContext.path(),
                "REQ_VALIDATION_FAILED",
                requestContext.correlationId(),
                Map.of("violations", violations)
        );
    }

    public ProblemDetails fromApplicationError(ApplicationError error) {
        return new ProblemDetails(
                URI.create("https://api.example.com/problems/" + error.slug()),
                error.title(),
                error.httpStatus(),
                error.safeDetail(),
                requestContext.path(),
                error.code(),
                requestContext.correlationId(),
                error.extensions()
        );
    }

    public ProblemDetails internalError() {
        return new ProblemDetails(
                URI.create("https://api.example.com/problems/internal-error"),
                "Internal server error",
                500,
                "The request could not be processed.",
                requestContext.path(),
                "INTERNAL_ERROR",
                requestContext.correlationId(),
                Map.of()
        );
    }
}

Problem object:

public record ProblemDetails(
        URI type,
        String title,
        int status,
        String detail,
        String instance,
        String errorCode,
        String correlationId,
        Map<String, Object> extensions
) {}

In production, control JSON serialization carefully so extension fields appear as top-level properties or under a known field according to your public contract.


11. Idempotency Is Not Deduplication Only

Idempotency means a client can retry safely.

It does not mean:

drop duplicate requests blindly
make every operation PUT
ignore side effects
trust the client not to retry

For our platform, submitCase is not naturally idempotent. It creates a case.

So we make it idempotent by requiring an Idempotency-Key header and storing a fingerprint of the request.

The idempotency contract:

Same operation + same idempotency key + same request fingerprint
  -> return the original result, no new side effect.

Same operation + same idempotency key + different request fingerprint
  -> reject as conflict.

Different idempotency key
  -> may create a different operation attempt.

This is critical for:

browser retry
mobile retry
API gateway retry
client timeout
load balancer retry
user double submit
server crash after commit before response

12. Idempotency Fingerprint

The idempotency key alone is not enough.

A client could accidentally reuse the same key with a different payload.

Create a request fingerprint.

Fingerprint inputs:

HTTP method
canonical path template
tenant ID
principal/client ID
operation ID
canonical request body hash
selected stable headers
contract version

Example:

public final class IdempotencyFingerprintFactory {
    public IdempotencyFingerprint create(SubmitCaseCommand command) {
        String canonical = String.join("\n",
                "POST",
                "/v1/cases",
                command.tenantId().value(),
                command.principalId().value(),
                "submitCase",
                canonicalJsonHash(command.originalRequestBody()),
                "v1"
        );

        return IdempotencyFingerprint.sha256(canonical);
    }
}

Avoid including volatile values:

received timestamp generated by server
correlation ID
trace ID
random request metadata
non-deterministic JSON serialization

The fingerprint must be stable across retries.


13. PostgreSQL Idempotency Table

The idempotency store must be durable.

An in-memory cache cannot protect you from pod restart or multi-replica concurrency.

create table api_idempotency_key (
    tenant_id              uuid        not null,
    operation_id           text        not null,
    idempotency_key         text        not null,
    request_fingerprint     text        not null,
    status                 text        not null,
    response_status         integer,
    response_body           jsonb,
    resource_type           text,
    resource_id             uuid,
    error_code              text,
    locked_until            timestamptz,
    created_at              timestamptz not null default now(),
    updated_at              timestamptz not null default now(),
    expires_at              timestamptz not null,

    constraint pk_api_idempotency_key
        primary key (tenant_id, operation_id, idempotency_key),

    constraint chk_api_idempotency_status
        check (status in ('PROCESSING', 'COMPLETED', 'FAILED_RETRYABLE', 'FAILED_FINAL'))
);

create index idx_api_idempotency_expires_at
    on api_idempotency_key (expires_at);

Status meaning:

StatusMeaningClient Behavior
PROCESSINGFirst request is still in progress or crashed before finalizationRetry later or receive 409/425/202 depending contract
COMPLETEDOriginal request completed and response is storedReturn stored response
FAILED_RETRYABLEFailure happened before durable side effect or was explicitly safe to retryAllow controlled retry
FAILED_FINALRequest failed definitivelyReturn stored failure

For create commands, many teams only store completed success responses. That is often insufficient. You need to decide how to handle failures, especially failures that happen after partial durable effects.


14. Idempotency Execution Algorithm

High-level algorithm:

The hard case is crash timing.

Case inserted.
Transaction committed.
Pod crashes before HTTP response.
Client retries.

Without idempotency, the retry may create a second case.

With idempotency, the retry returns the stored result or reconstructs it from the resource ID associated with the idempotency record.


15. Single Transaction or Two Transactions?

There are two common designs.

Design A: Idempotency record and business write in one transaction

begin transaction
  insert idempotency PROCESSING
  insert case
  insert audit
  insert outbox
  update idempotency COMPLETED with response
commit

Benefits:

strong atomicity
simpler reasoning
no completed business write without idempotency finalization

Costs:

longer transaction
idempotency row locked while business logic runs
harder if operation spans multiple local transactions

Design B: Idempotency reservation before business transaction

transaction 1:
  reserve key as PROCESSING

transaction 2:
  perform business write
  mark completed

Benefits:

shorter reservation transaction
can expose in-progress status

Costs:

crash leaves PROCESSING records
requires stale-lock recovery
requires careful reconciliation

For our case platform, prefer one local PostgreSQL transaction for submitCase because the command creates local case state, audit, and outbox together.

When the operation becomes long-running, accept the request quickly, persist command state, and move work to asynchronous processing.


16. Idempotency Store Interface

Application-facing interface:

public interface IdempotencyStore {
    IdempotencyDecision begin(IdempotencyRequest request);

    void complete(IdempotencyCompletion completion);

    void failFinal(IdempotencyFailure failure);
}

Decision model:

public sealed interface IdempotencyDecision permits
        IdempotencyDecision.Acquired,
        IdempotencyDecision.Replay,
        IdempotencyDecision.Conflict,
        IdempotencyDecision.InProgress {

    record Acquired() implements IdempotencyDecision {}

    record Replay(int status, String body) implements IdempotencyDecision {}

    record Conflict(String existingFingerprint) implements IdempotencyDecision {}

    record InProgress(Instant lockedUntil) implements IdempotencyDecision {}
}

Resource/use case integration:

IdempotencyDecision decision = idempotencyStore.begin(request);

return switch (decision) {
    case IdempotencyDecision.Replay replay -> replayResponse(replay);
    case IdempotencyDecision.Conflict conflict -> throw idempotencyConflict(conflict);
    case IdempotencyDecision.InProgress inProgress -> throw idempotencyInProgress(inProgress);
    case IdempotencyDecision.Acquired acquired -> executeAndStoreResponse(command);
};

This is cleaner than hiding idempotency behind an annotation because it forces you to reason about replay semantics.


17. SQL Pattern for Reservation

One robust pattern is insert-or-select with locking.

Pseudo-logic:

insert into api_idempotency_key (
    tenant_id,
    operation_id,
    idempotency_key,
    request_fingerprint,
    status,
    locked_until,
    expires_at
)
values (?, ?, ?, ?, 'PROCESSING', now() + interval '30 seconds', now() + interval '24 hours')
on conflict (tenant_id, operation_id, idempotency_key)
do nothing;

Then select the row:

select *
from api_idempotency_key
where tenant_id = ?
  and operation_id = ?
  and idempotency_key = ?
for update;

Decision:

if fingerprint differs -> CONFLICT
if status COMPLETED -> REPLAY
if status PROCESSING and lock still valid -> IN_PROGRESS
if status PROCESSING and lock expired -> controlled takeover or recovery path
if current transaction inserted row -> ACQUIRED

Be careful with takeover.

A stale PROCESSING record does not always mean the original operation failed. The original request might have committed the business state but crashed before updating the idempotency row. The recovery path must inspect business state using resource references, external reference, or audit/outbox markers.


18. Idempotency and External References

Regulatory systems often receive an external reference from upstream systems.

Example:

reporting institution reference = BANK-2026-000391

Should external reference replace idempotency key?

Usually, no.

They solve different problems.

MechanismOwnerPurpose
Idempotency-KeyCaller/request attemptSafe retry of same HTTP operation
externalReferenceBusiness source systemBusiness-level duplicate detection
caseIdPlatformInternal resource identity
businessKeyCamunda/processProcess correlation identity

Use both.

External reference uniqueness rule:

create unique index ux_case_external_reference
    on enforcement_case (tenant_id, reporting_institution_id, external_reference)
    where external_reference is not null;

Idempotency rule:

same HTTP attempt must not duplicate side effects

Business duplicate rule:

two different HTTP attempts might still describe the same upstream case

Those are not the same invariant.


19. Status Code Semantics for Idempotency

Possible response rules:

ScenarioResponse
First successful submit202 Accepted with case ID and status URI
Retry same key/same payload after successsame 202 body as original
Retry same key/different payload409 Conflict
Retry while first request still processing409 Conflict, 425 Too Early, or 202 with status URI depending contract
Missing idempotency key400 Bad Request
Invalid idempotency key format400 Bad Request
Expired idempotency key reusedTreat as new only if contract explicitly allows; safer to reject or require new key

In many enterprise APIs, 409 Conflict for in-progress duplicate is practical because the client already violated the single-flight expectation. For public APIs, returning 202 with a status resource can be friendlier.

Pick one behavior and document it.


20. Mapping Persistence Errors to HTTP Errors

Do not parse PostgreSQL error messages.

Map based on SQLSTATE and constraint name.

Example:

public final class PostgresErrorTranslator {
    public ApplicationError translate(SQLException exception) {
        String sqlState = exception.getSQLState();
        String constraint = extractConstraintName(exception);

        if ("23505".equals(sqlState) && "ux_case_external_reference".equals(constraint)) {
            return ApplicationErrors.duplicateExternalReference();
        }

        if ("23503".equals(sqlState)) {
            return ApplicationErrors.invalidReference();
        }

        if ("40P01".equals(sqlState)) {
            return ApplicationErrors.retryableDatabaseDeadlock();
        }

        if ("40001".equals(sqlState)) {
            return ApplicationErrors.retryableSerializationFailure();
        }

        return ApplicationErrors.internalPersistenceFailure();
    }
}

Typical SQLSTATEs:

SQLSTATEMeaningHTTP Mapping
23505unique violation409
23503foreign key violation400/409 depending caller fault
23514check violation400/409 or internal bug
40001serialization failureretry internally, then 503/409 if exhausted
40P01deadlock detectedretry internally, then 503 if exhausted

Database constraints are the final guard. If a constraint violation happens for something the API should have caught earlier, treat it as a signal to improve validation or close a race condition.


21. Internal Retry vs Client Retry

Not all retry belongs to the client.

Use internal retry for short, safe, transient database failures:

serialization failure
deadlock
brief connection acquisition failure

Do not retry internally for:

semantic validation failure
authorization failure
idempotency conflict
unique violation caused by different business command
Camunda business error
request body invalid

Internal retry must be bounded.

Example:

public <T> T executeWithDatabaseRetry(Supplier<T> operation) {
    int attempt = 0;
    while (true) {
        try {
            return operation.get();
        } catch (ApplicationException ex) {
            if (!ex.error().isRetryableDatabaseConcurrencyError()) {
                throw ex;
            }
            attempt++;
            if (attempt >= 3) {
                throw ex;
            }
            sleep(backoffWithJitter(attempt));
        }
    }
}

Retry must not create duplicate side effects. That is why outbox, idempotency, and transaction boundaries matter.


22. Validation and Authorization Order

Should you validate before authorization?

It depends.

A safe default:

1. Parse and basic shape validation.
2. Authenticate principal.
3. Authorize access to operation/resource scope.
4. Semantic validation that needs domain data.
5. Execute state transition.

Why not run all validation before authorization?

Because detailed validation errors can leak information about resources the caller cannot access.

Example:

PATCH /v1/cases/{caseId}/close

If the caller has no access to the case, avoid returning:

"case cannot be closed because it is under appeal"

That leaks case state.

Return 403 or a deliberately indistinguishable 404 depending the security policy.


23. State Conflict vs Validation Failure

A common mistake is returning validation errors for state conflicts.

Example:

Close case request body is valid.
Caller is authorized.
But case is already closed.

That is not request-shape validation.

It is a state conflict.

{
  "type": "https://api.example.com/problems/case-state-conflict",
  "title": "Case state conflict",
  "status": 409,
  "detail": "The case cannot be closed from its current state.",
  "errorCode": "CASE_STATE_CONFLICT",
  "correlationId": "01JZ9T2D5G3X3AZM7AE9Y8N4PF",
  "currentState": "CLOSED",
  "allowedStates": ["UNDER_REVIEW", "PENDING_DECISION"]
}

Only include currentState and allowedStates if the caller is allowed to know them.


24. Command Handler Integration

The resource should not know every validation step.

Use case flow:

public final class SubmitCaseUseCase {
    private final IdempotencyService idempotencyService;
    private final SubmitCaseSemanticValidator semanticValidator;
    private final AuthorizationService authorizationService;
    private final CaseRepository caseRepository;
    private final OutboxRepository outboxRepository;
    private final TransactionRunner transactionRunner;

    public SubmitCaseResult submit(SubmitCaseCommand command) {
        authorizationService.requireCanSubmitCase(command.principal(), command.tenantId());
        semanticValidator.validate(command).throwIfInvalid();

        return idempotencyService.execute(
                command.idempotencyScope(),
                command.fingerprint(),
                () -> transactionRunner.required(() -> createCase(command))
        );
    }

    private SubmitCaseResult createCase(SubmitCaseCommand command) {
        EnforcementCase created = EnforcementCase.submit(command);

        caseRepository.insert(created);
        outboxRepository.append(created.toCaseSubmittedEvent());

        return SubmitCaseResult.accepted(created.caseId(), created.caseNumber());
    }
}

This keeps resource thin while keeping idempotency close to side effects.


25. What About Camunda Start Process?

Do not start Camunda directly from the HTTP resource if the request must return quickly and be resilient.

Preferred design for submitCase:

HTTP submit
  -> PostgreSQL transaction inserts case + audit + outbox event
  -> response 202
  -> outbox publisher publishes CaseSubmitted
  -> process starter consumes event or local worker starts process
  -> Camunda process begins intake workflow

Why?

Because the durable system of record is PostgreSQL. Camunda process start can fail independently. Kafka can lag. The job executor can be down.

If HTTP returns 201 Created after directly starting process and then database/audit/outbox is inconsistent, your operational recovery becomes harder.

For this platform, HTTP acceptance means:

The command has been durably accepted and will be processed.

It does not mean:

Every downstream workflow step has completed.

That distinction must be clear in the API contract.


26. Response Shape for Accepted Commands

Example success body:

{
  "caseId": "018fd6f1-6ca7-7d7e-90cb-d71b87c83764",
  "caseNumber": "ENF-2026-000391",
  "status": "INTAKE_ACCEPTED",
  "links": {
    "self": "/v1/cases/018fd6f1-6ca7-7d7e-90cb-d71b87c83764",
    "status": "/v1/cases/018fd6f1-6ca7-7d7e-90cb-d71b87c83764/status"
  },
  "correlationId": "01JZ9T7MSAA2SCKJQ9NQS7SY04"
}

Why not return the entire case?

Because creation may be asynchronous. The case might still need enrichment, deduplication, assignment, process start, or SLA calculation.

Return the durable accepted identity and status link.


27. Logging Strategy for Validation and Errors

Log one line per completed request.

Safe fields:

correlation_id
trace_id
tenant_id
principal_id or service_client_id
operation_id
http_method
path_template
status_code
error_code
latency_ms
idempotency_decision
case_id if created

Do not log by default:

full request body
full Problem Details violations if they contain sensitive values
JWT token
API key
raw evidence references if sensitive
personal data unless explicitly approved

Example structured log:

{
  "event": "http_request_completed",
  "operationId": "submitCase",
  "method": "POST",
  "pathTemplate": "/v1/cases",
  "status": 202,
  "tenantId": "tnt_001",
  "principalId": "usr_1932",
  "correlationId": "01JZ9T7MSAA2SCKJQ9NQS7SY04",
  "idempotencyDecision": "ACQUIRED",
  "latencyMs": 84
}

28. Metrics

Minimum metrics:

http.server.requests by method/path/status/error_code
api.validation.failures by operation/error_code
api.idempotency.decisions by operation/decision
api.semantic_validation.failures by rule
api.db.retry.count by sqlstate
api.error.count by error_code

Useful idempotency decisions:

ACQUIRED
REPLAY
CONFLICT
IN_PROGRESS
EXPIRED
RECOVERED

Alert candidates:

sudden spike in IDEMPOTENCY_CONFLICT
increase in REQ_MALFORMED_JSON
increase in AUTH_FORBIDDEN for one principal/client
increase in DB_DEADLOCK_RETRYABLE
increase in INTERNAL_ERROR

Validation failure spikes often indicate upstream contract drift or abuse.


29. Testing Strategy

Contract tests

Use generated samples and OpenAPI examples.

Test:

valid request returns 202
missing required field returns 400 Problem Details
unknown enum returns 400
wrong media type returns 415
same idempotency key/same body replays response
same idempotency key/different body returns 409
unauthorized request returns 401
forbidden principal returns 403
closed case command returns 409

Race tests

Use concurrent requests with same idempotency key.

@Test
void concurrentSubmitWithSameKeyCreatesOnlyOneCase() {
    int concurrency = 20;

    List<Response> responses = runConcurrently(concurrency, () ->
            client.submitCase(idempotencyKey, validBody)
    );

    assertThat(caseRepository.countByExternalReference(externalReference)).isEqualTo(1);
    assertThat(responses).allMatch(r -> r.status() == 202 || r.status() == 409);
}

Persistence translation tests

Force database errors.

unique violation -> DUPLICATE_EXTERNAL_REFERENCE
foreign key violation -> INVALID_REFERENCE
serialization failure -> retry path
check violation -> mapped or treated as internal bug

Crash recovery tests

Harder, but valuable.

commit case but crash before response
retry same idempotency key
expect replay/recovery instead of duplicate case

You can simulate this by injecting a failure after commit hook in an integration test environment.


30. Production Checklist

[ ] all modifying POST/PATCH endpoints define idempotency policy
[ ] OpenAPI documents Idempotency-Key requirement where needed
[ ] request schemas bound string length, array length, and additional properties
[ ] DTO validation exists for field-level constraints
[ ] semantic validation exists for domain rules
[ ] authorization runs before leaking sensitive domain state
[ ] all errors map to Problem Details
[ ] error codes are stable and documented
[ ] exception mappers are centralized
[ ] unhandled errors return safe messages
[ ] SQLSTATE and constraint-name mapping exist
[ ] idempotency table has tenant + operation + key primary key
[ ] idempotency fingerprint detects key reuse with different request
[ ] idempotency replay returns original response
[ ] concurrent duplicate requests are tested
[ ] database retry is bounded and side-effect safe
[ ] validation/error metrics include operation and error code
[ ] sensitive payload fields are not logged

31. Anti-Patterns

Anti-Pattern 1: Validation Only in the UI

The UI validates, so backend accepts everything.

Fix:

Backend owns the public contract.
Frontend validation is only usability.

Anti-Pattern 2: Idempotency Key Without Fingerprint

Same key reused for different body accidentally replays old result.

Fix:

Store request fingerprint and reject key/body mismatch.

Anti-Pattern 3: Unique Constraint as User Experience

The API waits for database unique violation and returns generic 500.

Fix:

Translate constraint violations into stable domain errors.
Still keep the constraint as final invariant.

Anti-Pattern 4: Every Error is 400

Authorization failure, state conflict, duplicate submission, invalid JSON, and internal timeout all return 400.

Fix:

Use error taxonomy with status, code, owner, and retryability.

Anti-Pattern 5: Catch Exception and Return Message

catch (Exception ex) {
    return Response.status(500).entity(ex.getMessage()).build();
}

Fix:

Centralized exception mapper.
Safe Problem Details response.
Full detail only in internal logs.

Anti-Pattern 6: Idempotency in Redis Only

Redis may be fine for performance, but if it is not the durable source of truth for the command side effect, it cannot be the only idempotency guard.

Fix:

Use PostgreSQL-backed idempotency for commands whose side effects are persisted in PostgreSQL.
Use cache only as optimization.

32. The Core Lesson

Validation, idempotency, and error handling are one production boundary.

Validation decides whether the command is meaningful.

Idempotency decides whether executing it is safe.

Error handling decides whether the client, operator, and downstream systems can understand what happened.

A contract-first API is not complete when OpenAPI compiles.

It is complete when every failure path is also part of the contract.

The next part moves one level outward: API security and edge-aware services behind NGINX and Kubernetes.


References

  • RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457.html
  • IETF HTTPAPI Working Group draft — Idempotency-Key HTTP Header Field: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
  • Jakarta Validation Specification: https://jakarta.ee/specifications/bean-validation/3.1/
  • Jakarta RESTful Web Services Specification: https://jakarta.ee/specifications/restful-ws/3.0/
  • PostgreSQL Error Codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
  • PostgreSQL Constraints: https://www.postgresql.org/docs/current/ddl-constraints.html
Lesson Recap

You just completed lesson 17 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.