What is JAX-RS — and Why Does It Matter in 2025?
JAX-RS (Jakarta RESTful Web Services) is a
specification — a set of annotations and interfaces in
jakarta.ws.rs.* that standardizes how to map HTTP requests to
Java methods. Like JPA, it has no runtime code of its own: you need a
provider implementation at runtime. Jersey (the RI), RESTEasy (WildFly's
bundled impl), and Apache CXF are the three providers still in active use.
Before JAX-RS (2008), every framework had its own proprietary way to
handle REST: raw Servlets with switch statements over
request.getMethod(), framework-specific annotations, or
vendor-specific XML configuration. JAX-RS gave Jakarta EE a vendor-neutral
model that still ships inside every certified application server today.
| Without JAX-RS (raw Servlet) | With JAX-RS |
|---|---|
|
|
| Aspect | JAX-RS | Spring MVC / Spring REST |
|---|---|---|
| Origin | Jakarta EE specification (vendor-neutral) | Spring Framework (proprietary, but dominant) |
| Annotation set | @Path, @GET, @Produces, @QueryParam |
@RequestMapping, @GetMapping, @RequestParam |
| Where you'll find it | WildFly, Payara, WebSphere, Quarkus — any Jakarta EE server | Spring Boot, embedded Tomcat/Undertow |
| JSON binding | JSON-B (spec) or Jackson (provider-plugged) | Jackson (default, auto-configured) |
| Exception handling | ExceptionMapper<T> implementations |
@ExceptionHandler / @ControllerAdvice |
| Greenfield choice in 2025 | Quarkus (excellent JAX-RS via RESTEasy Reactive), Helidon | Spring Boot (still dominant in enterprise) |
Spring Boot is the more common choice for new projects, but JAX-RS is the standard in a large body of existing Jakarta EE systems and is the native model in Quarkus, which is gaining traction for cloud-native workloads. Understanding JAX-RS also makes Spring MVC trivial by comparison — the mental model is nearly identical, the annotations just have different names.
Bootstrapping a JAX-RS Application
The Application subclass — entry point in Jakarta EE
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api")
public class RestApplication extends Application {
// Empty body: the container discovers all @Provider and resource
// classes annotated with @Path on the classpath automatically.
// Alternatively, override getClasses() to register explicitly.
}
A complete, production-shaped resource class
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
@Path("/users")
@Produces(MediaType.APPLICATION_JSON) // class-level default
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {
@Inject
private UserService userService;
@GET
public List<UserDto> listUsers(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return userService.findAll(page, size);
}
@GET
@Path("/{id}")
public UserDto getUser(@PathParam("id") Long id) {
return userService.findById(id)
.orElseThrow(() -> new NotFoundException("User not found: " + id));
}
@POST
public Response createUser(@Valid CreateUserRequest request,
@Context UriInfo uriInfo) {
UserDto created = userService.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(String.valueOf(created.id()))
.build();
return Response.created(location).entity(created).build();
}
@PUT
@Path("/{id}")
public UserDto updateUser(@PathParam("id") Long id,
@Valid UpdateUserRequest request) {
return userService.update(id, request);
}
@DELETE
@Path("/{id}")
public Response deleteUser(@PathParam("id") Long id) {
userService.delete(id);
return Response.noContent().build();
}
}
// DTOs as records — immutable, no boilerplate, JSON-B/Jackson compatible
public record UserDto(Long id, String username, String email) {}
public record CreateUserRequest(@NotBlank String username,
@Email String email) {}
public record UpdateUserRequest(@Size(max = 50) String username) {}
Unlike Spring's singleton controllers, a JAX-RS resource class
receives a new instance per HTTP request. Any state held in fields
(counters, caches, partially built objects) is silently lost between
calls. Stateful logic belongs in injected CDI beans with appropriate
scope (@ApplicationScoped, @RequestScoped,
etc.), not in the resource class itself. Some providers (Jersey, CXF)
allow @Singleton on the resource class to opt out of
this default — but then you must handle concurrency yourself.
Parameter Injection — All the Ways to Bind Incoming Data
JAX-RS can inject virtually anything from the request into method
parameters or fields using annotations. Every injection annotation
handles type conversion from the raw String to the Java type
automatically, or throws a 400 Bad Request if conversion
fails.
@Path("/products")
public class ProductResource {
// @PathParam — from the URL segment declared in @Path
// GET /api/products/electronics/42
@GET
@Path("/{category}/{id}")
public ProductDto getByCategory(
@PathParam("category") String category,
@PathParam("id") Long id) { ... }
// @QueryParam — from the URL query string
// GET /api/products?q=laptop&minPrice=500&inStock=true&page=2&size=20
@GET
public List<ProductDto> search(
@QueryParam("q") String query,
@QueryParam("minPrice") @DefaultValue("0") BigDecimal minPrice,
@QueryParam("inStock") @DefaultValue("false") boolean inStock,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) { ... }
// @HeaderParam — from a specific HTTP header
// GET /api/products X-Tenant-Id: acme
@GET
@Path("/tenant")
public List<ProductDto> getByTenant(
@HeaderParam("X-Tenant-Id") String tenantId) { ... }
// @CookieParam — from a specific cookie
@GET
@Path("/personalized")
public List<ProductDto> getPersonalized(
@CookieParam("session_id") String sessionId) { ... }
// @Context — inject JAX-RS context objects (request, response, uriInfo...)
@POST
public Response create(CreateProductRequest request,
@Context UriInfo uriInfo,
@Context HttpHeaders headers,
@Context SecurityContext security) { ... }
}
| Annotation | Source | Null if missing? |
|---|---|---|
@PathParam | URL path segment (/{id}) | Only if the regex allows the segment to be absent — unusual. If the path matches, the value is always present |
@QueryParam | URL query string (?key=value) | Yes — null for reference types, 0/false for primitives unless @DefaultValue is set |
@HeaderParam | HTTP request header | Yes — null if the header is absent |
@CookieParam | HTTP cookie | Yes — null if the cookie is absent |
@FormParam | Form field (application/x-www-form-urlencoded) | Yes — null if the field is absent |
@BeanParam | Groups multiple injection annotations into one object | Object is always created; individual fields follow their own annotation rules |
@Context | JAX-RS context objects (UriInfo, HttpHeaders, Request, SecurityContext) | Never null — always injected by the container |
@BeanParam — grouping parameters to avoid long parameter lists
// Instead of 5 @QueryParam parameters on the method signature:
public class ProductSearchParams {
@QueryParam("q") public String query;
@QueryParam("minPrice") @DefaultValue("0") public BigDecimal minPrice;
@QueryParam("page") @DefaultValue("0") public int page;
@QueryParam("size") @DefaultValue("20") public int size;
@HeaderParam("Accept-Language") public String locale;
}
@GET
public List<ProductDto> search(@BeanParam ProductSearchParams params) {
return productService.search(params);
}
HTTP Methods — Semantics, Status Codes, and Common Mistakes
| Method | Purpose | Request body | Idempotent | Safe | Correct success code |
|---|---|---|---|---|---|
GET | Retrieve resource or collection | No | Yes | Yes | 200 OK |
POST | Create resource (or trigger an action) | Yes | No | No | 201 Created + Location header |
PUT | Replace resource completely | Yes | Yes | No | 200 OK (or 204 if no body returned) |
PATCH | Partial update | Yes | No (usually) | No | 200 OK |
DELETE | Remove resource | No | Yes | No | 204 No Content |
HEAD | Like GET but response body stripped — used to check existence/headers | No | Yes | Yes | 200 OK |
OPTIONS | Returns supported methods — CORS preflight | No | Yes | Yes | 200 OK |
DELETE /users/42 is idempotent: the first call removes the
user, the second call finds nothing and should return 204 or 404 — the
resource ends up absent either way. Returning a 500 on the second call
because the row no longer exists in the database is incorrect. The
convention is 204 for both cases (or 404 if your API contract says
"absence is an error"). The distinction matters because proxies,
load balancers, and clients retry idempotent requests on timeout —
if your handler isn't truly idempotent, a retry causes a duplicate
operation.
Response builder — the production shape
// POST: 201 Created with Location header pointing to the new resource
@POST
public Response createOrder(@Valid CreateOrderRequest request,
@Context UriInfo uriInfo) {
OrderDto created = orderService.create(request);
URI location = uriInfo.getAbsolutePathBuilder()
.path(String.valueOf(created.id()))
.build();
return Response.created(location) // sets status 201 + Location header
.entity(created)
.build();
}
// DELETE: 204 No Content — success with no response body
@DELETE
@Path("/{id}")
public Response deleteOrder(@PathParam("id") Long id) {
orderService.delete(id);
return Response.noContent().build();
}
// GET that returns null without a mapper → serializes as HTTP 200 with JSON null body.
// Always handle the not-found case explicitly:
@GET
@Path("/{id}")
public OrderDto getOrder(@PathParam("id") Long id) {
return orderService.findById(id)
.orElseThrow(() -> new NotFoundException("Order not found: " + id));
// NotFoundException is a WebApplicationException subtype that
// JAX-RS converts to 404 automatically — no custom mapper needed.
}
Exception Handling — Built-in Exceptions and Custom Mappers
Built-in WebApplicationException hierarchy
JAX-RS ships ready-made exception types for the most common HTTP error statuses. Throwing any of these inside a resource method causes the runtime to respond with the corresponding status code — no mapper needed:
// These are WebApplicationException subtypes — throw directly from resource methods
throw new NotFoundException("Order " + id + " not found"); // 404
throw new BadRequestException("Invalid payload: " + reason); // 400
throw new NotAuthorizedException("Bearer"); // 401 (requires challenge)
throw new ForbiddenException("Insufficient permissions"); // 403
throw new NotAllowedException("GET", "POST"); // 405 — allowed methods required
throw new NotAcceptableException("Only application/json supported"); // 406
throw new ServiceUnavailableException(300L); // 503 with Retry-After seconds
throw new InternalServerErrorException("Unexpected error"); // 500 (but prefer ExceptionMapper)
Custom ExceptionMapper — the right pattern for domain exceptions
Service-layer exceptions should not be WebApplicationException
subclasses — the service layer should not know about HTTP. An
ExceptionMapper bridges between domain exceptions and HTTP
responses without coupling those layers:
// Domain exception — knows nothing about HTTP
public class OrderNotFoundException extends RuntimeException {
private final Long orderId;
public OrderNotFoundException(Long orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
public Long getOrderId() { return orderId; }
}
// Error response body — a record is perfect here
public record ErrorResponse(String error, String message, String path) {}
// The mapper: discovered automatically thanks to @Provider
@Provider
public class OrderNotFoundMapper
implements ExceptionMapper<OrderNotFoundException> {
@Context
private UriInfo uriInfo;
@Override
public Response toResponse(OrderNotFoundException ex) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse(
"NOT_FOUND",
ex.getMessage(),
uriInfo.getPath()))
.build();
}
}
// A single catch-all mapper for unhandled RuntimeExceptions
@Provider
public class GlobalExceptionMapper
implements ExceptionMapper<Exception> {
@Override
public Response toResponse(Exception ex) {
// Never leak internal exception details to clients in production
return Response.serverError()
.entity(new ErrorResponse(
"INTERNAL_ERROR",
"An unexpected error occurred",
null))
.build();
}
}
ExceptionMapper<Exception> intercepts JAX-RS's own WebApplicationException — this is usually wrongIf you register a mapper for the root Exception type, it
also catches every WebApplicationException (including
NotFoundException, ForbiddenException, etc.)
and overrides their status codes with whatever your mapper returns.
The fix is to check whether the caught exception is a
WebApplicationException and pass it through:
@Override
public Response toResponse(Exception ex) {
if (ex instanceof WebApplicationException wae) {
return wae.getResponse(); // let JAX-RS handle its own exceptions
}
return Response.serverError()
.entity(new ErrorResponse("INTERNAL_ERROR", "...", null))
.build();
}
Filters and Interceptors — Cross-Cutting Concerns
JAX-RS has two distinct extension points for cross-cutting concerns:
filters operate on request/response metadata (headers,
status, URI); interceptors operate on the message body
(wrapping MessageBodyReader/MessageBodyWriter
during (de)serialization). Most real-world needs are met by filters.
Request filter — authentication
@Provider
@Priority(Priorities.AUTHENTICATION) // runs before AUTHORIZATION filters
public class JwtAuthFilter implements ContainerRequestFilter {
@Inject
private JwtValidator jwtValidator;
@Override
public void filter(ContainerRequestContext ctx) {
String header = ctx.getHeaderString(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED)
.header("WWW-Authenticate", "Bearer")
.build());
return;
}
String token = header.substring(7);
try {
UserPrincipal principal = jwtValidator.validate(token);
// Install a SecurityContext so @Context SecurityContext works downstream
ctx.setSecurityContext(new JwtSecurityContext(principal));
} catch (InvalidTokenException e) {
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
}
}
}
Response filter — CORS
@Provider
public class CorsFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext req,
ContainerResponseContext res) {
var headers = res.getHeaders();
headers.add("Access-Control-Allow-Origin", "https://myapp.example.com");
headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
headers.add("Access-Control-Max-Age", "86400");
}
}
Access-Control-Allow-Origin: * and credentials don't mixIf the browser sends a request with cookies or an
Authorization header (a "credentialed request"), the
wildcard * origin is rejected by every modern browser.
You must echo back the specific Origin header value from
the request, add
Access-Control-Allow-Credentials: true, and whitelist only
your known origins server-side. The wildcard is only safe for public,
anonymous read APIs.
@NameBinding — applying filters to specific endpoints only
// 1. Declare a name-binding annotation
@NameBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authenticated {}
// 2. Apply the annotation to the filter
@Provider
@Authenticated
public class JwtAuthFilter implements ContainerRequestFilter { ... }
// 3. Tag only the endpoints (or whole resource class) that need it
@Path("/orders")
@Authenticated // every method in this class now goes through JwtAuthFilter
public class OrderResource { ... }
@Path("/health")
public class HealthResource { ... } // not tagged → filter never runs here
Content Negotiation and JSON Serialization
JAX-RS uses the Accept and Content-Type headers
to select the right MessageBodyWriter/MessageBodyReader
for serialization. @Produces declares what the endpoint can
return; @Consumes declares what it can accept. If no matching
provider is found, JAX-RS returns 406 Not Acceptable or
415 Unsupported Media Type automatically.
@GET
@Path("/{id}")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public ProductDto getProduct(@PathParam("id") Long id) {
// Sends JSON if client sends: Accept: application/json
// Sends XML if client sends: Accept: application/xml
// Returns 406 if client sends: Accept: text/csv
return productService.findById(id);
}
JSON-B configuration — controlling serialization
// JSON-B (Jakarta JSON Binding) is the spec-standard JSON provider
// Customize it by providing a Jsonb instance or using annotations on the DTO
public record ProductDto(
Long id,
@JsonbProperty("product_name") // serializes as "product_name" not "name"
String name,
@JsonbNumberFormat("#0.00")
BigDecimal price,
@JsonbDateFormat("yyyy-MM-dd")
LocalDate createdOn,
@JsonbTransient // excluded from serialization entirely
String internalCode
) {}
Bean Validation Integration
JAX-RS integrates with Jakarta Bean Validation out of the box on any
certified Jakarta EE container. Adding @Valid to a parameter
or return type triggers constraint validation automatically — no manual
validation code needed. Validation failures are caught by a built-in
ExceptionMapper for
ConstraintViolationException (the container usually provides
one; register a custom one to control the response shape).
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(max = 100)
String name,
@NotNull
@DecimalMin("0.01")
BigDecimal price,
@NotBlank
@Pattern(regexp = "[A-Z]{2,5}")
String sku
) {}
@POST
public Response create(@Valid CreateProductRequest request) {
// If any constraint fails, JAX-RS intercepts and returns 400
// before this line is ever reached
return Response.status(201).entity(productService.create(request)).build();
}
// Register a custom mapper to control the error response shape:
@Provider
public class ValidationExceptionMapper
implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException ex) {
Map<String, String> errors = ex.getConstraintViolations().stream()
.collect(Collectors.toMap(
cv -> cv.getPropertyPath().toString(),
cv -> cv.getMessage()
));
return Response.status(Response.Status.BAD_REQUEST)
.entity(errors)
.build();
}
}
Async Processing — @Suspended and CompletionStage
JAX-RS offers two async models. The @Suspended model
(JAX-RS 2.0) suspends the request-handling thread and resumes the
response from any other thread. The CompletionStage return
type (JAX-RS 2.1) is the cleaner modern approach — no thread management,
integrates with virtual threads in Java 21.
// JAX-RS 2.1+: return CompletionStage — clean, no manual thread management
@GET
@Path("/reports/{id}")
public CompletionStage<Response> getReport(@PathParam("id") Long id) {
return reportService.buildAsync(id)
.thenApply(Response::ok)
.thenApply(Response.ResponseBuilder::build);
}
// JAX-RS 2.0: @Suspended — more control, more verbosity
@GET
@Path("/legacy-reports/{id}")
public void getLegacyReport(@PathParam("id") Long id,
@Suspended AsyncResponse ar) {
ar.setTimeout(10, TimeUnit.SECONDS);
ar.setTimeoutHandler(resp -> resp.resume(
Response.status(Response.Status.SERVICE_UNAVAILABLE).build()));
reportService.buildAsync(id)
.thenAccept(report -> ar.resume(Response.ok(report).build()))
.exceptionally(ex -> { ar.resume(ex); return null; });
}
// Important: new Thread(() -> ...).start() in a resource method is wrong.
// Use @Asynchronous on the EJB service, or an injected ManagedExecutorService,
// so the container manages the thread lifecycle (transactions, security context).
The original file in this project showed
new Thread(() -> ...).start() inside an async
resource — this is wrong in a managed environment. Raw threads bypass
the container's thread pool management, don't inherit the current
transaction context or security context, can't use
@PersistenceContext injection reliably, and may exhaust
file descriptors under load. Use a
ManagedExecutorService (injected via
@Resource), @Asynchronous EJB methods, or
return a CompletionStage from a properly managed async
service.
Best Practices and Common Pitfalls
✅ Do
- Always return
Responsefrom@POSTwith aLocationheader pointing to the new resource — this is the correct REST contract, and clients depend on it to retrieve the created entity - Use
@NameBindingto scope filters to specific endpoints instead of running them globally — authentication filters on a health check endpoint are unnecessary overhead - Use DTOs (records) as request and response types — never expose
@Entityclasses at the boundary. Bidirectional JPA relationships cause infinite serialization loops - Register a
ExceptionMapper<ConstraintViolationException>to control the validation error response shape — the default varies by provider and is usually not API-quality - Set explicit CORS origins, never
*when credentials are involved - Use
CompletionStagereturn types for async endpoints instead of@Suspended— cleaner code, better integration with modern Java concurrency
❌ Don't
- Don't return
nullfrom resource methods — it serializes as HTTP 204 or an empty body, not a 404. ThrowNotFoundExceptionor use anOptional-aware pattern - Don't put business logic in resource classes — they are HTTP adapters only. All validation beyond format checking, all transactions, and all domain rules belong in a service layer
- Don't register an
ExceptionMapper<Exception>without first checking for and passing throughWebApplicationException— otherwise your 404s become 500s - Don't spawn raw
Threadobjects from resource methods — use container-managed threading - Don't rely on resource-class field state between requests — resource instances are per-request, not singletons
- Don't manually inline HTTP status codes as integers (
Response.status(404)) — useResponse.Statusconstants for readability and to avoid typos
Interview Questions
Q: What is the difference between @PathParam and @QueryParam?
@PathParam binds a variable segment from the URL path declared
in @Path (e.g. /{id} → @PathParam("id")).
@QueryParam binds a key-value pair from the URL query string
(e.g. ?page=2 → @QueryParam("page")). Path params
identify a specific resource; query params typically filter or paginate a
collection.
Q: What HTTP status code should a successful @POST that creates a resource return?
201 Created, along with a Location header containing the URI
of the newly created resource. Returning 200 OK is technically wrong
because 200 means "here is the state of an existing resource" while
201 means "I created something new and here is where to find it."
Q: What does @Produces do?
It declares the media types the endpoint can return (e.g.
application/json, application/xml). The JAX-RS
runtime matches the client's Accept header against the
declared types to pick the right MessageBodyWriter for
serialization. If no matching type can be found, the runtime returns
406 Not Acceptable automatically.
Q: A @GET endpoint returns null when the entity is not found. What actually happens, and what should happen instead?
When a JAX-RS method with a non-Response return type returns
null, the runtime maps it to a 204 No Content response with an empty body
— not a 404. This is wrong for a GET on a single resource where absence
means "not found". The correct approach is to throw
NotFoundException (a WebApplicationException
subtype that produces a 404) or to use a service return type of
Optional<T> and call
.orElseThrow(() -> new NotFoundException(...)). Using a
custom ExceptionMapper keeps the 404 response shape
consistent with all other error responses in the API.
Q: Why is registering a global ExceptionMapper<Exception> dangerous, and how do you fix it?
WebApplicationException is a subclass of
RuntimeException which is a subclass of
Exception. A mapper for Exception therefore
catches every NotFoundException, ForbiddenException,
and BadRequestException that resource methods and the
framework itself throw, replacing their carefully assigned status codes
with whatever your mapper returns — usually 500. The fix is to check
if (ex instanceof WebApplicationException wae) return
wae.getResponse(); at the top of toResponse(),
delegating all JAX-RS exceptions back to the framework's default
handling.
Q: What is the difference between a JAX-RS filter and an interceptor, and when do you use each?
Filters implement ContainerRequestFilter or
ContainerResponseFilter and operate on request/response
metadata: headers, URI, HTTP method, status code, and whether to
abort a request before the resource method runs. Interceptors implement
ReaderInterceptor or WriterInterceptor and
wrap the reading or writing of the message body — they are invoked during
deserialization (request body → Java object) and serialization (Java
object → response body). Typical filter use cases: authentication, CORS,
logging, rate limiting. Typical interceptor use cases: request body
decryption, response compression, audit logging of full request/response
payloads.
Q: JAX-RS resource classes are per-request by default. What does this mean for concurrent access, and when would you change it?
Each incoming HTTP request gets its own instance of the resource class,
so fields on the resource class are never shared between concurrent
requests — there is no concurrency problem on the resource class itself.
The trade-off is that instantiation and injection happen on every request.
Annotating the resource class with @Singleton (Jersey) or
registering it as a singleton creates one shared instance for all
requests, eliminating per-request instantiation overhead at the cost of
requiring thread-safe handling for any mutable state. In practice,
resource classes should hold no mutable state at all — only injected
services — making the per-request default safe and the singleton
alternative irrelevant.