
Jersey Java Tutorial – Building RESTful Web Services
Jersey is Oracle’s production-ready reference implementation of the JAX-RS specification for building RESTful web services in Java. If you’ve been wrestling with creating APIs that actually scale and perform well, Jersey offers a battle-tested framework that handles the heavy lifting while giving you fine-grained control over your service architecture. This tutorial will walk you through building robust REST APIs with Jersey, covering everything from basic resource creation to advanced features like custom providers, client configuration, and production deployment strategies that actually work in the real world.
How Jersey Works Under the Hood
Jersey operates as a servlet-based framework that implements the JAX-RS specification, providing a comprehensive runtime for RESTful web services. At its core, Jersey uses annotation-driven programming where resources are defined as plain Java classes annotated with JAX-RS annotations like @Path
, @GET
, @POST
, and others.
The framework employs a sophisticated request processing pipeline that includes resource matching, method selection, parameter injection, and response generation. Jersey’s container abstraction allows it to run in various environments including Grizzly, Jetty, Tomcat, and Java EE application servers.
Key architectural components include:
- Resource classes that handle HTTP requests
- Providers for serialization, deserialization, and cross-cutting concerns
- Filters and interceptors for request/response processing
- Client API for consuming RESTful services
- Dependency injection integration with HK2 and other frameworks
Step-by-Step Implementation Guide
Let’s build a complete RESTful service from scratch. First, set up your Maven project with the necessary dependencies:
<dependencies>
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-grizzly2-http</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>3.1.3</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>3.1.3</version>
</dependency>
</dependencies>
Create a basic resource class for managing users:
@Path("/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {
private static final Map<Integer, User> users = new ConcurrentHashMap<>();
private static final AtomicInteger idCounter = new AtomicInteger(1);
@GET
public Response getAllUsers() {
return Response.ok(new ArrayList<>(users.values())).build();
}
@GET
@Path("/{id}")
public Response getUser(@PathParam("id") int id) {
User user = users.get(id);
if (user == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("User not found").build();
}
return Response.ok(user).build();
}
@POST
public Response createUser(User user) {
if (user.getName() == null || user.getName().trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("Name is required").build();
}
int id = idCounter.getAndIncrement();
user.setId(id);
users.put(id, user);
return Response.status(Response.Status.CREATED)
.entity(user)
.location(URI.create("/users/" + id))
.build();
}
@PUT
@Path("/{id}")
public Response updateUser(@PathParam("id") int id, User updatedUser) {
if (!users.containsKey(id)) {
return Response.status(Response.Status.NOT_FOUND).build();
}
updatedUser.setId(id);
users.put(id, updatedUser);
return Response.ok(updatedUser).build();
}
@DELETE
@Path("/{id}")
public Response deleteUser(@PathParam("id") int id) {
User removed = users.remove(id);
if (removed == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.noContent().build();
}
}
Create the User model class:
public class User {
private int id;
private String name;
private String email;
private LocalDateTime createdAt;
public User() {
this.createdAt = LocalDateTime.now();
}
public User(String name, String email) {
this();
this.name = name;
this.email = email;
}
// Getters and setters
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Now create the main application class to bootstrap the server:
public class RestServiceApplication {
public static void main(String[] args) {
final ResourceConfig config = new ResourceConfig();
config.packages("com.example.resources");
config.register(JacksonFeature.class);
// Configure Jackson for LocalDateTime serialization
config.register(new AbstractBinder() {
@Override
protected void configure() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
bind(mapper).to(ObjectMapper.class);
}
});
final HttpServer server = GrizzlyHttpServerFactory
.createHttpServer(URI.create("http://localhost:8080/api/"), config);
try {
server.start();
System.out.println("Jersey app started at http://localhost:8080/api/");
System.out.println("Press CTRL+C to stop the server...");
Thread.currentThread().join();
} catch (Exception e) {
System.err.println("Error starting server: " + e.getMessage());
} finally {
server.shutdownNow();
}
}
}
Real-World Examples and Use Cases
Jersey excels in several production scenarios. Here’s an advanced example implementing a notification service with custom filters and exception handling:
@Path("/notifications")
@Produces(MediaType.APPLICATION_JSON)
public class NotificationResource {
@Inject
private NotificationService notificationService;
@POST
@Path("/send")
@Authenticated // Custom security annotation
@RateLimited(requests = 100, window = "1m") // Custom rate limiting
public Response sendNotification(
@Valid NotificationRequest request,
@Context SecurityContext securityContext) {
try {
String userId = securityContext.getUserPrincipal().getName();
NotificationResult result = notificationService
.sendNotification(userId, request);
return Response.ok(result).build();
} catch (QuotaExceededException e) {
return Response.status(429)
.entity(Map.of("error", "Rate limit exceeded"))
.build();
} catch (ValidationException e) {
return Response.status(400)
.entity(Map.of("error", e.getMessage()))
.build();
}
}
@GET
@Path("/history")
public Response getNotificationHistory(
@QueryParam("limit") @DefaultValue("50") int limit,
@QueryParam("offset") @DefaultValue("0") int offset,
@QueryParam("status") String status) {
NotificationQuery query = NotificationQuery.builder()
.limit(Math.min(limit, 100)) // Prevent excessive queries
.offset(offset)
.status(status)
.build();
Page<Notification> notifications = notificationService
.getNotifications(query);
return Response.ok(notifications)
.header("X-Total-Count", notifications.getTotalElements())
.build();
}
}
Implement custom exception mappers for better error handling:
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ValidationException> {
@Override
public Response toResponse(ValidationException exception) {
ErrorResponse error = new ErrorResponse(
"VALIDATION_ERROR",
exception.getMessage(),
exception.getViolations()
);
return Response.status(Response.Status.BAD_REQUEST)
.entity(error)
.build();
}
}
@Provider
public class GenericExceptionMapper implements ExceptionMapper<Exception> {
private static final Logger logger = LoggerFactory.getLogger(GenericExceptionMapper.class);
@Override
public Response toResponse(Exception exception) {
logger.error("Unhandled exception", exception);
ErrorResponse error = new ErrorResponse(
"INTERNAL_ERROR",
"An internal error occurred",
null
);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(error)
.build();
}
}
Framework Comparison and Performance Analysis
Feature | Jersey | Spring Boot | Quarkus | Micronaut |
---|---|---|---|---|
Startup Time | ~2-3 seconds | ~4-6 seconds | ~0.3-0.8 seconds | ~1-2 seconds |
Memory Usage (Basic API) | ~80-120 MB | ~150-200 MB | ~30-50 MB | ~60-90 MB |
Throughput (req/sec) | ~25,000-35,000 | ~20,000-30,000 | ~40,000-50,000 | ~35,000-45,000 |
JAX-RS Compliance | Full (Reference Implementation) | Partial via Jersey | Full via RESTEasy | Custom Implementation |
Learning Curve | Moderate | Easy | Moderate | Moderate |
Container Support | Excellent | Excellent | Excellent (Native) | Good |
Performance benchmarks show Jersey consistently delivering solid throughput with reasonable resource consumption. In production environments handling 10,000+ concurrent users, Jersey typically maintains sub-100ms response times for simple CRUD operations when properly tuned.
Best Practices and Common Pitfalls
Here are production-tested practices that will save you debugging time:
- Always use
Response
objects instead of direct entity returns for better control over HTTP status codes and headers - Implement proper exception mappers to avoid exposing stack traces to clients
- Use
@Valid
annotations with Bean Validation for automatic input validation - Configure connection pooling and timeouts for database connections
- Implement proper logging with structured formats for production monitoring
Common mistakes to avoid:
- Forgetting to register providers in your ResourceConfig
- Not handling JSON serialization exceptions properly
- Using blocking I/O operations in resource methods without proper thread pool configuration
- Ignoring proper HTTP status codes (returning 200 for everything)
- Not implementing proper CORS headers for web applications
Configure a production-ready filter for CORS and security headers:
@Provider
@PreMatching
public class CorsAndSecurityFilter implements ContainerRequestFilter, ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
// Handle preflight requests
if ("OPTIONS".equals(requestContext.getMethod())) {
requestContext.abortWith(Response.ok().build());
}
}
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) throws IOException {
// CORS headers
responseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
responseContext.getHeaders().add("Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS");
responseContext.getHeaders().add("Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With");
// Security headers
responseContext.getHeaders().add("X-Content-Type-Options", "nosniff");
responseContext.getHeaders().add("X-Frame-Options", "DENY");
responseContext.getHeaders().add("X-XSS-Protection", "1; mode=block");
}
}
For production deployments, consider using Jersey with async processing for I/O intensive operations:
@GET
@Path("/heavy-operation")
public void heavyOperation(@Suspended AsyncResponse asyncResponse) {
CompletableFuture.supplyAsync(() -> {
// Simulate heavy processing
try {
Thread.sleep(2000);
return performDatabaseQuery();
} catch (Exception e) {
throw new RuntimeException(e);
}
}).whenComplete((result, throwable) -> {
if (throwable != null) {
asyncResponse.resume(Response.serverError().build());
} else {
asyncResponse.resume(Response.ok(result).build());
}
});
}
Jersey’s client API is equally powerful for consuming external services. Configure it with proper timeouts and connection pooling:
public class ExternalServiceClient {
private final Client client;
public ExternalServiceClient() {
ClientConfig config = new ClientConfig();
config.property(ClientProperties.CONNECT_TIMEOUT, 5000);
config.property(ClientProperties.READ_TIMEOUT, 10000);
config.connectorProvider(new ApacheConnectorProvider());
this.client = ClientBuilder.newClient(config);
}
public Optional<ExternalData> fetchData(String id) {
try {
Response response = client
.target("https://api.external-service.com")
.path("/data/{id}")
.resolveTemplate("id", id)
.request(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + getAccessToken())
.get();
if (response.getStatus() == 200) {
return Optional.of(response.readEntity(ExternalData.class));
}
return Optional.empty();
} catch (Exception e) {
logger.error("Failed to fetch external data", e);
return Optional.empty();
}
}
}
The Jersey ecosystem integrates well with monitoring tools like Micrometer for metrics collection and distributed tracing with OpenTelemetry. For comprehensive documentation and advanced features, check the official Jersey documentation and the JAX-RS specification. These resources provide detailed coverage of advanced topics like server-sent events, WebSocket integration, and custom injection providers that can significantly enhance your REST service capabilities.

This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.
This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.