Build CoreOrdered learning track

Jersey Resource Design

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

Desain resource JAX-RS/Jersey production-grade untuk platform contract-first: resource boundary, request pipeline, DTO mapping, filters, providers, exception mappers, validation, response semantics, testability, dan failure model.

9 min read1698 words
PrevNext
Lesson 1640 lesson track0922 Build Core
#java#jax-rs#jersey#rest-api+6 more

Part 016 — Jersey Resource Design

A JAX-RS resource class looks simple.

@Path("/cases")
public class CaseResource {
    @POST
    public Response create(CreateCaseRequest request) {
        return Response.ok().build();
    }
}

That simplicity is dangerous.

Many production codebases slowly turn resource classes into a mix of:

HTTP parsing
validation
business logic
transaction management
SQL calls
Kafka publishing
Camunda correlation
logging
security checks
error mapping
response construction

The endpoint still works.

But the boundary is gone.

In a contract-first platform, the Jersey resource has one job:

Adapt the HTTP contract to the application use case and adapt the application result back to the HTTP contract.

It should not become the domain model, process engine, repository, event publisher, or transaction script.

This part designs the Jersey HTTP layer for our regulatory enforcement case platform.

We will build it as a production-grade adapter around OpenAPI contracts, typed Java application services, error mapping, request context, and observability.


1. The Mental Model: Resource Class as Adapter

Think of the resource class as a plug converter.

It sits between two worlds:

HTTP world:
  method, path, headers, query params, body, status code, media type

Application world:
  command, query, domain identity, use case result, domain error

It should translate between them and then get out of the way.

Resource design invariant:

A resource method must be easy to read as a contract implementation.

Good resource method shape:

extract path/query/header/body
map request DTO to command/query
call application service
return mapped response

Bad resource method shape:

parse JSON manually
start DB transaction
run SQL
mutate process variables
send Kafka event
catch every exception
manually build inconsistent errors

The difference is not aesthetic. It decides whether the API can evolve safely.


2. Contract-First Resource Implementation

Part 006 designed the OpenAPI contract.

That contract should drive the resource boundary.

A simplified OpenAPI fragment:

paths:
  /v1/cases:
    post:
      operationId: submitCase
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
        - name: X-Correlation-Id
          in: header
          required: false
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitCaseRequest'
      responses:
        '202':
          description: Case accepted for processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubmitCaseResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

The Jersey resource should look like an implementation of that operation.

@Path("/v1/cases")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public final class CaseResource {
    private final SubmitCaseUseCase submitCaseUseCase;
    private final CaseHttpMapper mapper;

    public CaseResource(SubmitCaseUseCase submitCaseUseCase,
                        CaseHttpMapper mapper) {
        this.submitCaseUseCase = Objects.requireNonNull(submitCaseUseCase);
        this.mapper = Objects.requireNonNull(mapper);
    }

    @POST
    public Response submitCase(
            @HeaderParam("Idempotency-Key") String idempotencyKey,
            @HeaderParam("X-Correlation-Id") String correlationId,
            SubmitCaseRequestDto body
    ) {
        SubmitCaseCommand command = mapper.toSubmitCaseCommand(
                idempotencyKey,
                correlationId,
                body
        );

        SubmitCaseResult result = submitCaseUseCase.submit(command);

        return mapper.toSubmitCaseResponse(result);
    }
}

Notice what is not here:

No SQL.
No Kafka producer.
No Camunda runtime service.
No transaction boundary.
No domain mutation.
No generic catch block.
No raw environment lookup.

The resource is thin, but not dumb.

It owns HTTP adaptation.


3. Package Boundary

A clean package layout makes architecture harder to violate.

case-api/
  src/main/java/com/example/caseplatform/api/
    JerseyApplication.java
    resource/
      CaseResource.java
      CaseTaskResource.java
      CaseDecisionResource.java
      HealthResource.java
    filter/
      CorrelationIdFilter.java
      AccessLogFilter.java
      SecurityContextFilter.java
    mapper/
      CaseHttpMapper.java
      ProblemDetailsMapper.java
    provider/
      JsonProvider.java
      ValidationExceptionMapper.java
      DomainExceptionMapper.java
      UnhandledExceptionMapper.java
    dto/
      generated/...

case-application/
  SubmitCaseUseCase.java
  AssignCaseTaskUseCase.java
  DecideCaseUseCase.java
  model/...

case-domain/
  CaseId.java
  CaseLifecycleState.java
  CaseCommand.java
  CaseError.java

The dependency direction:

Allowed:

case-api depends on case-application
case-api depends on generated DTOs
case-api maps HTTP DTO to application command

Forbidden:

case-domain depends on JAX-RS
case-application depends on Jersey Response
case-resource uses MyBatis mapper directly
case-resource calls Kafka producer directly
case-resource reaches Camunda RuntimeService directly

This is how we prevent resource classes from becoming mini monoliths.


4. Jersey Application Registration

In production systems, explicit registration is usually easier to reason about than magical classpath scanning.

A Jersey application can be configured with a ResourceConfig.

public final class JerseyApplication extends ResourceConfig {
    public JerseyApplication(AppComponents components) {
        register(new CaseResource(
                components.submitCaseUseCase(),
                components.caseHttpMapper()
        ));

        register(new CaseTaskResource(
                components.assignTaskUseCase(),
                components.caseTaskHttpMapper()
        ));

        register(new HealthResource(
                components.healthService()
        ));

        register(new CorrelationIdFilter());
        register(new AccessLogFilter());
        register(new SecurityContextFilter(components.authVerifier()));

        register(new ValidationExceptionMapper(components.problemDetailsMapper()));
        register(new DomainExceptionMapper(components.problemDetailsMapper()));
        register(new UnhandledExceptionMapper(components.problemDetailsMapper()));

        register(components.jsonProvider());
    }
}

The point is not that every project must register this way.

The point is that the HTTP layer should be explicit enough that you can answer:

Which resources are exposed?
Which filters run before resource methods?
Which exception mapper handles which error?
Which JSON provider is used?
Which dependencies are injected into each resource?

If you cannot answer those questions, debugging production behavior becomes guesswork.


5. Request Context: Correlation, Caller, and Tenant

Every request needs context.

Not everything belongs in the body.

For this platform, request context contains:

correlation id
request id
caller identity
caller roles/groups
tenant or jurisdiction, if multi-tenant
source IP / forwarded chain, if trusted
idempotency key, for mutating operations
received timestamp

Do not pass these as random strings everywhere.

Create a request context type:

public record RequestContext(
        CorrelationId correlationId,
        RequestId requestId,
        CallerIdentity caller,
        Instant receivedAt
) {}

A JAX-RS filter can create the context.

@Provider
@Priority(Priorities.AUTHENTICATION)
public final class CorrelationIdFilter implements ContainerRequestFilter, ContainerResponseFilter {
    public static final String HEADER = "X-Correlation-Id";
    public static final String PROPERTY = "requestContext";

    @Override
    public void filter(ContainerRequestContext requestContext) {
        String incoming = requestContext.getHeaderString(HEADER);
        CorrelationId correlationId = CorrelationId.fromNullable(incoming);
        RequestId requestId = RequestId.newId();

        RequestContext context = new RequestContext(
                correlationId,
                requestId,
                CallerIdentity.anonymous(),
                Instant.now()
        );

        requestContext.setProperty(PROPERTY, context);
    }

    @Override
    public void filter(ContainerRequestContext requestContext,
                       ContainerResponseContext responseContext) {
        RequestContext context = (RequestContext) requestContext.getProperty(PROPERTY);
        if (context != null) {
            responseContext.getHeaders().putSingle(HEADER, context.correlationId().value());
            responseContext.getHeaders().putSingle("X-Request-Id", context.requestId().value());
        }
    }
}

Then the resource can receive the low-level JAX-RS context and convert it once:

@Context
private ContainerRequestContext containerRequestContext;

private RequestContext requestContext() {
    return (RequestContext) containerRequestContext.getProperty(CorrelationIdFilter.PROPERTY);
}

Or inject a small wrapper if your DI style supports it.

The invariant:

Request context is created before resource method execution and propagated into application commands.

Example command:

public record SubmitCaseCommand(
        RequestContext requestContext,
        IdempotencyKey idempotencyKey,
        ExternalReference externalReference,
        PartySnapshot reportingParty,
        List<EvidenceReference> evidence,
        String allegationSummary
) {}

This makes audit and event publication consistent later.


6. Resource Method Design

A resource method should be boring.

Boring is good.

Pattern:

@POST
public Response submitCase(
        @HeaderParam("Idempotency-Key") String idempotencyKey,
        SubmitCaseRequestDto body
) {
    RequestContext context = requestContext();
    SubmitCaseCommand command = mapper.toCommand(context, idempotencyKey, body);
    SubmitCaseResult result = submitCaseUseCase.submit(command);
    return mapper.toResponse(result);
}

A method can have light HTTP decisions.

Example:

@GET
@Path("/{caseId}")
public Response getCase(@PathParam("caseId") String caseId) {
    RequestContext context = requestContext();

    GetCaseQuery query = new GetCaseQuery(
            context,
            CaseId.parse(caseId)
    );

    GetCaseResult result = getCaseUseCase.get(query);

    return mapper.toResponse(result);
}

But avoid this:

@GET
@Path("/{caseId}")
public Response getCase(@PathParam("caseId") String caseId) {
    try (SqlSession session = sqlSessionFactory.openSession()) {
        CaseRow row = session.getMapper(CaseMapper.class).findById(caseId);
        if (row == null) return Response.status(404).build();
        RuntimeService runtimeService = camunda.getRuntimeService();
        ProcessInstance pi = runtimeService.createProcessInstanceQuery()
                .processInstanceBusinessKey(caseId)
                .singleResult();
        return Response.ok(Map.of("case", row, "process", pi.getId())).build();
    }
}

Why bad?

Because it creates hidden coupling:

HTTP endpoint now knows SQL session lifecycle
HTTP endpoint knows Camunda query model
HTTP endpoint owns response aggregation logic
HTTP endpoint owns error behavior accidentally

Better:

Resource -> GetCaseUseCase -> ports -> infrastructure adapters

7. DTO to Domain Mapping

Generated OpenAPI DTOs are not domain models.

They are boundary models.

Boundary models answer:

What did the client send?
What does the API return?

Domain models answer:

What does the system mean?
What invariants must hold?
What transitions are allowed?

Mapping code is not boilerplate. It is a contract enforcement point.

public final class CaseHttpMapper {
    public SubmitCaseCommand toCommand(RequestContext context,
                                       String rawIdempotencyKey,
                                       SubmitCaseRequestDto body) {
        if (body == null) {
            throw ApiValidationException.requiredBody("SubmitCaseRequest");
        }

        return new SubmitCaseCommand(
                context,
                IdempotencyKey.parse(rawIdempotencyKey),
                ExternalReference.parse(body.getExternalReference()),
                mapReportingParty(body.getReportingParty()),
                mapEvidence(body.getEvidence()),
                normalizeSummary(body.getAllegationSummary())
        );
    }

    private PartySnapshot mapReportingParty(ReportingPartyDto dto) {
        if (dto == null) {
            throw ApiValidationException.requiredField("reportingParty");
        }
        return new PartySnapshot(
                PartyName.parse(dto.getName()),
                ContactPoint.parse(dto.getContact())
        );
    }

    private List<EvidenceReference> mapEvidence(List<EvidenceDto> evidence) {
        if (evidence == null || evidence.isEmpty()) {
            throw ApiValidationException.requiredField("evidence");
        }
        return evidence.stream()
                .map(e -> EvidenceReference.parse(e.getType(), e.getUri()))
                .toList();
    }

    private String normalizeSummary(String value) {
        if (value == null || value.isBlank()) {
            throw ApiValidationException.requiredField("allegationSummary");
        }
        return value.trim();
    }
}

This mapper does three things:

converts HTTP strings to domain value objects
normalizes boundary data
throws API validation errors for invalid request shape

It does not:

query database
start process instance
publish Kafka event
make authorization decision

Keep mappers pure and easy to test.


8. Response Semantics

A production API should not return 200 OK for everything.

For case intake:

ScenarioHTTP StatusWhy
new case accepted asynchronously202 Acceptedprocess continues after response
duplicate idempotency key, same payload200 OK or 202 Accepted with same resultsafe duplicate
duplicate idempotency key, different payload409 Conflictclient reused key incorrectly
invalid request body400 Bad Requestmalformed or semantically invalid input
unauthorized401 Unauthorizedcaller not authenticated
forbidden403 Forbiddencaller authenticated but not allowed
case not found404 Not Foundresource does not exist or not visible
unsupported media type415 Unsupported Media Typebody format unsupported
dependency unavailable503 Service Unavailabletemporary server inability

For our submitCase response:

public Response toSubmitCaseResponse(SubmitCaseResult result) {
    return switch (result) {
        case SubmitCaseResult.Accepted accepted -> Response
                .accepted(new SubmitCaseResponseDto()
                        .caseId(accepted.caseId().value())
                        .status("ACCEPTED")
                        .processInstanceKey(accepted.processReference().value()))
                .location(URI.create("/v1/cases/" + accepted.caseId().value()))
                .build();

        case SubmitCaseResult.Duplicate duplicate -> Response
                .status(Response.Status.OK)
                .entity(new SubmitCaseResponseDto()
                        .caseId(duplicate.caseId().value())
                        .status("DUPLICATE_ACCEPTED"))
                .build();
    };
}

This example assumes Java sealed result types from earlier parts.

The important point:

HTTP status must reflect client-observable contract semantics, not internal implementation convenience.

9. Exception Mapping

Do not catch everything in every resource method.

JAX-RS supports exception mapping through providers. Jersey implements this mechanism.

Resource method:

@POST
public Response submitCase(@HeaderParam("Idempotency-Key") String idempotencyKey,
                           SubmitCaseRequestDto body) {
    SubmitCaseCommand command = mapper.toCommand(requestContext(), idempotencyKey, body);
    SubmitCaseResult result = submitCaseUseCase.submit(command);
    return mapper.toSubmitCaseResponse(result);
}

Exception mapper:

@Provider
public final class ApiValidationExceptionMapper
        implements ExceptionMapper<ApiValidationException> {

    private final ProblemDetailsMapper problemDetailsMapper;

    public ApiValidationExceptionMapper(ProblemDetailsMapper problemDetailsMapper) {
        this.problemDetailsMapper = problemDetailsMapper;
    }

    @Override
    public Response toResponse(ApiValidationException exception) {
        ProblemDetailsDto problem = problemDetailsMapper.validationProblem(exception);
        return Response.status(Response.Status.BAD_REQUEST)
                .type("application/problem+json")
                .entity(problem)
                .build();
    }
}

Domain exception mapper:

@Provider
public final class DomainExceptionMapper
        implements ExceptionMapper<DomainException> {

    private final ProblemDetailsMapper problemDetailsMapper;

    @Override
    public Response toResponse(DomainException exception) {
        ProblemDetailsDto problem = problemDetailsMapper.domainProblem(exception);
        Response.Status status = switch (exception.code()) {
            case "CASE_NOT_FOUND" -> Response.Status.NOT_FOUND;
            case "CASE_CONFLICT" -> Response.Status.CONFLICT;
            case "FORBIDDEN_TRANSITION" -> Response.Status.CONFLICT;
            default -> Response.Status.BAD_REQUEST;
        };

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

Unhandled exception mapper:

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

    private static final Logger log = LoggerFactory.getLogger(UnhandledExceptionMapper.class);
    private final ProblemDetailsMapper problemDetailsMapper;

    @Override
    public Response toResponse(Throwable exception) {
        RequestContext context = CurrentRequestContext.getOrUnknown();

        log.error("Unhandled API exception correlationId={} requestId={}",
                context.correlationId().value(),
                context.requestId().value(),
                exception);

        ProblemDetailsDto problem = problemDetailsMapper.internalServerError(context);

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

Do not expose stack traces in production error responses.

Do log enough context for operators to correlate.


10. Problem Details Shape

Part 012 introduced the error model.

The Jersey layer materializes it as HTTP problem details.

Example response:

{
  "type": "https://errors.example.com/case-platform/validation-error",
  "title": "Invalid request",
  "status": 400,
  "detail": "reportingParty is required",
  "instance": "/v1/cases",
  "correlationId": "corr-01JZ9MSZT6V7G4KHDXCN7K1RAQ",
  "errorCode": "API_VALIDATION_ERROR",
  "violations": [
    {
      "field": "reportingParty",
      "message": "must be provided"
    }
  ]
}

A mapper can create it consistently:

public final class ProblemDetailsMapper {
    public ProblemDetailsDto validationProblem(ApiValidationException ex) {
        RequestContext context = CurrentRequestContext.getOrUnknown();

        return new ProblemDetailsDto()
                .type("https://errors.example.com/case-platform/validation-error")
                .title("Invalid request")
                .status(400)
                .detail(ex.safeMessage())
                .correlationId(context.correlationId().value())
                .errorCode(ex.errorCode())
                .violations(ex.violations().stream()
                        .map(this::toViolationDto)
                        .toList());
    }
}

The invariant:

Every non-2xx API error has a stable machine-readable errorCode and correlationId.

That is what makes client behavior, support workflow, and incident debugging possible.


11. Validation Boundary

There are multiple validation layers.

Do not collapse them.

LayerExampleOwnerError Type
HTTP syntaxinvalid JSONJersey/Jackson400
OpenAPI shapemissing required fieldAPI contract400
API semanticinvalid idempotency key formatAPI mapper400
Authorizationcaller cannot submit caseapplication/security403
Domain invariantcase cannot transitiondomain/application409
Database invariantunique key violationpersistencemapped domain/data error

Validation in resource/mappers should stop bad requests before they enter the application service.

But resource validation should not know deep business rules.

Example:

allegationSummary missing -> API validation
case cannot be closed while investigation task active -> domain/application rule
unique idempotency key violation -> persistence translated to idempotency result/conflict

Bean Validation can be useful for simple DTO constraints.

But in contract-first systems, generated DTO constraints must be treated carefully:

- generated annotations are good for simple shape validation
- domain value objects still enforce domain meaning
- mapper still normalizes and converts
- application service still checks authorization and business invariants

Do not let annotation validation become your only model of correctness.


12. Filters and Interceptors

Filters operate around request/response metadata.

Interceptors operate around entity body read/write.

Use them deliberately.

12.1 Correlation Filter

Already covered above.

Responsibilities:

read or create correlation id
create request id
attach request context
write correlation headers to response

12.2 Security Context Filter

A security filter should verify caller identity before the resource method.

@Provider
@Priority(Priorities.AUTHENTICATION)
public final class SecurityContextFilter implements ContainerRequestFilter {
    private final AuthVerifier authVerifier;

    public SecurityContextFilter(AuthVerifier authVerifier) {
        this.authVerifier = authVerifier;
    }

    @Override
    public void filter(ContainerRequestContext requestContext) {
        String authorization = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
        CallerIdentity caller = authVerifier.verify(authorization);

        RequestContext existing = (RequestContext) requestContext
                .getProperty(CorrelationIdFilter.PROPERTY);

        RequestContext updated = new RequestContext(
                existing.correlationId(),
                existing.requestId(),
                caller,
                existing.receivedAt()
        );

        requestContext.setProperty(CorrelationIdFilter.PROPERTY, updated);
    }
}

Do not trust arbitrary forwarded headers unless NGINX/ingress trust boundary is configured.

12.3 Access Log Filter

Access logs should be structured.

@Provider
public final class AccessLogFilter implements ContainerRequestFilter, ContainerResponseFilter {
    private final Clock clock;

    @Override
    public void filter(ContainerRequestContext requestContext) {
        requestContext.setProperty("startedAt", clock.instant());
    }

    @Override
    public void filter(ContainerRequestContext requestContext,
                       ContainerResponseContext responseContext) {
        Instant startedAt = (Instant) requestContext.getProperty("startedAt");
        long durationMs = Duration.between(startedAt, clock.instant()).toMillis();
        RequestContext context = (RequestContext) requestContext.getProperty(CorrelationIdFilter.PROPERTY);

        log.info("http_request method={} path={} status={} durationMs={} correlationId={} requestId={}",
                requestContext.getMethod(),
                requestContext.getUriInfo().getPath(),
                responseContext.getStatus(),
                durationMs,
                context.correlationId().value(),
                context.requestId().value());
    }
}

Never log request bodies by default in a regulatory system.

They may contain personal data, evidence references, complaint text, or legal-sensitive information.


13. Resource Boundary and Transactions

A common question:

Should the Jersey resource start the database transaction?

Usually, no.

The transaction belongs to the application use case or lower application service boundary.

Why?

Because the use case knows the unit of consistency.

Example submit case flow:

HTTP request
-> SubmitCaseUseCase
   -> validate caller
   -> check idempotency
   -> insert case
   -> insert audit record
   -> insert outbox event
   -> start/correlate workflow command, depending architecture
-> return accepted result

The transaction boundary is around application state mutation, not around HTTP method mechanics.

Do not do this:

@POST
public Response submitCase(SubmitCaseRequestDto body) {
    transactionManager.inTransaction(() -> {
        SubmitCaseCommand command = mapper.toCommand(requestContext(), body);
        SubmitCaseResult result = submitCaseUseCase.submit(command);
        return mapper.toResponse(result);
    });
}

This makes the resource responsible for transaction policy.

Better:

@POST
public Response submitCase(SubmitCaseRequestDto body) {
    SubmitCaseCommand command = mapper.toCommand(requestContext(), body);
    SubmitCaseResult result = submitCaseUseCase.submit(command);
    return mapper.toResponse(result);
}

The use case decides transaction behavior internally.

This allows the same use case to be called from:

HTTP resource
Kafka consumer
Camunda delegate
batch repair tool

without duplicating transaction rules.


14. Async API Semantics Over HTTP

Case intake often starts asynchronous work.

The API should not pretend that the workflow is finished.

Bad:

POST /v1/cases returns 200 OK with status CLOSED

unless the case is truly closed synchronously.

Better:

POST /v1/cases returns 202 Accepted
Location: /v1/cases/{caseId}

Response:

{
  "caseId": "case_01JZ9N8NV4P6Q4XV7Y0G8WT73Y",
  "status": "ACCEPTED",
  "links": {
    "self": "/v1/cases/case_01JZ9N8NV4P6Q4XV7Y0G8WT73Y",
    "tasks": "/v1/cases/case_01JZ9N8NV4P6Q4XV7Y0G8WT73Y/tasks"
  }
}

This is honest.

It tells the client:

The request is accepted.
Processing continues.
Poll or subscribe to another channel for state changes.

In our architecture, the accepted request may result in:

PostgreSQL case row
idempotency row
outbox event
Camunda process start/correlation
Kafka event publication
human task creation

The HTTP resource should not wait for every asynchronous side effect unless the contract explicitly requires it.


15. Pagination and Query Resources

Query endpoints have different design pressure.

Example:

GET /v1/cases?status=UNDER_REVIEW&pageSize=50&pageToken=...

Resource method:

@GET
public Response searchCases(@QueryParam("status") String status,
                            @QueryParam("pageSize") Integer pageSize,
                            @QueryParam("pageToken") String pageToken) {
    SearchCasesQuery query = mapper.toSearchCasesQuery(
            requestContext(),
            status,
            pageSize,
            pageToken
    );

    SearchCasesResult result = searchCasesUseCase.search(query);

    return mapper.toSearchCasesResponse(result);
}

Do not expose database pagination mechanics directly.

Bad:

GET /v1/cases?offset=1000000&limit=1000

This invites expensive queries and unstable result windows.

Better:

GET /v1/cases?pageSize=50&pageToken=opaque-token

The token can encode stable cursor information.

The resource does not need to know the SQL cursor shape.

It only maps HTTP query params into a query object.


16. Health Resources

Health endpoints are resources too, but they have different contracts.

@Path("/health")
@Produces(MediaType.APPLICATION_JSON)
public final class HealthResource {
    private final HealthService healthService;

    @GET
    @Path("/live")
    public Response live() {
        return Response.ok(Map.of("status", "UP")).build();
    }

    @GET
    @Path("/ready")
    public Response ready() {
        Readiness readiness = healthService.readiness();
        if (readiness.ready()) {
            return Response.ok(readiness.safeView()).build();
        }
        return Response.status(Response.Status.SERVICE_UNAVAILABLE)
                .entity(readiness.safeView())
                .build();
    }
}

Rules:

/live should be cheap and should not deeply check dependencies
/ready should reflect safe traffic acceptance
health output must not leak secrets
readiness dependency checks must be bounded by timeout

In Kubernetes, these endpoints are not just diagnostics. They control traffic and restart behavior.

Design them carefully.


17. Testing Jersey Resources

Resource tests should verify HTTP adaptation, not the whole world.

17.1 Mapper Unit Test

@Test
void mapsSubmitCaseRequestToCommand() {
    SubmitCaseRequestDto dto = new SubmitCaseRequestDto()
            .externalReference("EXT-123")
            .reportingParty(new ReportingPartyDto().name("Alice").contact("alice@example.com"))
            .evidence(List.of(new EvidenceDto().type("DOCUMENT").uri("s3://bucket/file.pdf")))
            .allegationSummary("Possible violation");

    SubmitCaseCommand command = mapper.toCommand(
            sampleRequestContext(),
            "idem-123",
            dto
    );

    assertEquals("EXT-123", command.externalReference().value());
    assertEquals("idem-123", command.idempotencyKey().value());
}

17.2 Resource Test with Stub Use Case

@Test
void submitCaseReturns202() {
    SubmitCaseUseCase useCase = command -> new SubmitCaseResult.Accepted(
            CaseId.parse("case_123"),
            ProcessReference.parse("proc_456")
    );

    CaseResource resource = new CaseResource(useCase, new CaseHttpMapper());

    Response response = resource.submitCase(
            "idem-123",
            "corr-123",
            validRequestDto()
    );

    assertEquals(202, response.getStatus());
}

17.3 Provider Test

@Test
void validationExceptionMapsToProblemDetails() {
    ApiValidationException ex = ApiValidationException.requiredField("reportingParty");

    Response response = mapper.toResponse(ex);

    assertEquals(400, response.getStatus());
    assertEquals("application/problem+json", response.getMediaType().toString());
}

17.4 Contract Test

Contract tests should verify that actual HTTP behavior matches OpenAPI.

Examples:

- missing required field returns documented 400 shape
- successful submit returns documented 202 shape
- unsupported content type returns 415
- duplicate idempotency conflict returns 409
- response does not contain undocumented required-breaking fields

Do not rely only on generated code. Generated code can prove shape alignment, but behavior still needs tests.


18. Failure Model

FailureSymptomLikely Root CauseResource-Layer Response
invalid JSON400malformed bodyprovider maps parse error to problem details
missing required field400client contract violationmapper/validation reports field
invalid domain transition409request conflicts with current statedomain error mapper
unauthenticated caller401missing/invalid tokensecurity filter aborts
unauthorized caller403role/policy failureapplication/security error mapper
DB timeout503 or 500 depending policydependency failureapplication/persistence error translated safely
Kafka unavailable during required publish503dependency failureuse case returns retryable service error
unexpected bug500coding defectunhandled mapper logs correlation ID, hides stack trace
missing correlation headerno failureclient omitted optional headerfilter creates one
oversized request413client body too largeNGINX/Jersey limit, documented error

The resource layer should make failures understandable to clients and operators.

It should not hide failures behind generic 200 OK or leak internals through stack traces.


19. Production Checklist

Before exposing a Jersey resource in production:

[ ] operation exists in OpenAPI
[ ] resource path/method/status codes match contract
[ ] request DTO is not used as domain model
[ ] resource calls application use case, not infrastructure directly
[ ] request context is created before resource method
[ ] correlation ID is returned in response
[ ] authentication filter runs before protected resources
[ ] validation errors map to stable problem details
[ ] domain errors map to documented statuses
[ ] unhandled errors are logged with correlation ID
[ ] request/response logging avoids sensitive body data
[ ] health endpoints separate liveness and readiness
[ ] contract tests verify documented behavior
[ ] resource tests use stub application services
[ ] timeout behavior aligns with NGINX and downstream dependencies

20. Anti-Patterns

Anti-Pattern 1: Resource as Transaction Script

@POST
public Response submit(...) {
    insertCase();
    startCamundaProcess();
    publishKafkaEvent();
    return Response.ok().build();
}

Fix:

Resource adapts HTTP to SubmitCaseUseCase.
Use case owns transaction and side-effect policy.

Anti-Pattern 2: Generated DTO as Domain Entity

public void submit(SubmitCaseRequestDto dto) {
    caseRepository.save(dto);
}

Fix:

Map DTO to domain command/value objects.

Anti-Pattern 3: Catch-All in Every Method

try {
    ...
} catch (Exception ex) {
    return Response.serverError().build();
}

Fix:

Use consistent exception mappers and typed errors.

Anti-Pattern 4: Returning 200 for Business Failure

{
  "success": false,
  "message": "case not found"
}

with HTTP 200.

Fix:

Use HTTP status according to contract: 404, 409, 422/400, 403, etc.

Anti-Pattern 5: Logging Full Request Body

Regulatory case data can contain sensitive evidence.

Fix:

Log metadata, not sensitive payload.
Use explicit safe fields.

21. The Core Lesson

Jersey resources are not where the system becomes smart.

They are where the system becomes precise.

A production-grade resource layer does four things well:

It implements the OpenAPI contract honestly.
It translates HTTP data into application commands.
It translates application results into HTTP responses.
It applies consistent cross-cutting request behavior through filters and providers.

Everything else should move inward to the application layer or outward to infrastructure adapters.

The next part will deepen the most failure-prone parts of this boundary: validation, idempotency, and error handling.


References

  • Eclipse Jersey User Guide: https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/user-guide.html
  • Eclipse Jersey Application Deployment and Runtime Environments: https://eclipse-ee4j.github.io/jersey.github.io/documentation/3.0.1/deployment.html
  • Jakarta RESTful Web Services Specification: https://jakarta.ee/specifications/restful-ws/3.0/
  • Jakarta RESTful Web Services Specification Document: https://jakarta.ee/specifications/restful-ws/3.0/jakarta-restful-ws-spec-3.0.html
  • RFC 9457 — Problem Details for HTTP APIs: https://www.rfc-editor.org/rfc/rfc9457.html
Lesson Recap

You just completed lesson 16 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.