Stop Breaking at 2am: MicroProfile Done Right on Open Liberty
Implement Config, Health, Circuit Breakers, and JWT security the way production systems actually need them.
Most developers think MicroProfile is about annotations. You add @ConfigProperty, @Retry, @RolesAllowed, and things magically work. On your laptop, everything is green. Requests return JSON. Health endpoints say UP.
The problem starts when you deploy.
Configuration drifts between environments. A missing environment variable crashes your service at startup. A slow downstream call blocks request threads until your whole instance stops responding. A liveness probe restarts healthy pods because readiness was implemented wrong. A JWT is accepted locally but rejected in production because the issuer or key location changed.
MicroProfile is not about convenience. It is about defining strict contracts between your code and the runtime. Config defines where values come from and in which order they override each other. Health defines exactly what JSON shape your orchestrator will read. Fault Tolerance defines how retries and circuit breakers interact. JWT defines how claims map to roles.
If you treat these as optional decorations, production breaks in ways that are hard to debug at 2am.
In this article, we build a small book-catalog microservice. It reads configuration from the environment. It exposes liveness and readiness checks. It survives downstream failures. And it protects admin endpoints with JWT. We do all of this using only MicroProfile APIs. No vendor-specific classes in our business code.
We run everything on Open Liberty, one of the most complete MicroProfile implementations. The code targets the spec, not Liberty.
Prerequisites
You need a working Java and Maven setup and basic REST knowledge. We focus on MicroProfile, not on explaining JAX-RS from scratch.
JDK 17 or newer
Maven 3.9 or newer
curlandjqfor manual testingBasic understanding of JAX-RS
Project Setup
Create the project or start from the example in my Github repository. We use a standard maven webapp archetype:
mvn archetype:generate \
-DarchetypeGroupId=org.apache.maven.archetypes \
-DarchetypeArtifactId=maven-archetype-webapp \
-DgroupId=dev.example \
-DartifactId=book-catalog \
-Dversion=1.0-SNAPSHOT \
-DinteractiveMode=false
cd book-catalogOpen pom.xml and replace the <properties> section with the following:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--
Pin every version in one place so every developer and CI job
produces identical results regardless of what lands on Maven Central.
-->
<jakartaee-api.version>10.0.0</jakartaee-api.version>
<microprofile-api.version>7.1</microprofile-api.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<war-plugin.version>3.4.0</war-plugin.version>
<liberty.plugin.version>3.11.4</liberty.plugin.version>
<!-- Override on the CLI: mvn liberty:dev -Dliberty.http.port=9081 -->
<liberty.http.port>9080</liberty.http.port>
<liberty.https.port>9443</liberty.https.port>
</properties>Add the necessary dependencies:
<dependencyManagement>
<dependencies>
<!-- MicroProfile 7.1 BOM: versions for Config, Health, Fault Tolerance, JWT, Metrics, OpenAPI, Rest Client, Telemetry -->
<dependency>
<groupId>org.eclipse.microprofile</groupId>
<artifactId>microprofile</artifactId>
<version>${microprofile-api.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-core-api</artifactId>
<version>${jakartaee-api.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-core-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- MicroProfile 7.1 APIs (provided by Liberty at runtime) -->
<dependency>
<groupId>org.eclipse.microprofile.config</groupId>
<artifactId>microprofile-config-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.fault-tolerance</groupId>
<artifactId>microprofile-fault-tolerance-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.health</groupId>
<artifactId>microprofile-health-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.metrics</groupId>
<artifactId>microprofile-metrics-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.jwt</groupId>
<artifactId>microprofile-jwt-auth-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.openapi</groupId>
<artifactId>microprofile-openapi-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.rest.client</groupId>
<artifactId>microprofile-rest-client-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.microprofile.telemetry</groupId>
<artifactId>microprofile-telemetry-api</artifactId>
<type>pom</type>
<scope>provided</scope>
</dependency>
<!-- JUnit 5 for unit tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<!--
A lightweight JAX-RS client for integration tests.
We need something that runs outside Liberty to call the live server.
RESTEasy client is a good fit — no JBoss AS dependency required.
-->
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>6.2.11.Final</version>
<scope>test</scope>
</dependency>
</dependencies>Configure the build section:
<finalName>book-catalog</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>${war-plugin.version}</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
<!-- Surefire — unit tests only (excludes *IT.java) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>
<!-- 4. Failsafe — integration tests against a live Liberty server -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<!--
Pass the Liberty port into the test JVM so integration tests
know which port to connect to without hardcoding it.
-->
<systemPropertyVariables>
<liberty.http.port>${liberty.http.port}</liberty.http.port>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<!--
liberty-maven-plugin — downloads, configures, and runs Liberty.
RUNTIME RESOLUTION:
No <runtimeArtifact> is configured here. The plugin resolves the runtime
by scanning <dependencies> for io.openliberty:openliberty-runtime and
using its version (${liberty.runtime.version} = 25.0.0.11).
This approach keeps the runtime version in one place with the rest of
your dependencies, and lets CI override it with:
mvn liberty:dev -Dliberty.runtime.version=25.0.0.12
-->
<plugin>
<groupId>io.openliberty.tools</groupId>
<artifactId>liberty-maven-plugin</artifactId>
<version>${liberty.plugin.version}</version>
<configuration>
<serverName>book-catalog-server</serverName>
<!--
looseApplication=true: Liberty reads class files directly from
target/classes instead of a repackaged WAR.
Save → compile → hot-reload in ~1 second instead of ~10.
-->
<looseApplication>true</looseApplication>
<!--
New in 3.11.0: changes the on-demand test trigger in dev mode
from pressing Enter (easy to hit accidentally) to 't' then Enter.
-->
<changeOnDemandTestsAction>true</changeOnDemandTestsAction>
</configuration>
<!--
Bind Liberty goals to Maven lifecycle phases so that
"mvn verify" does the full cycle automatically:
create → install-features → deploy → start → ITs → stop
-->
<executions>
<execution>
<id>liberty-create</id>
<phase>pre-integration-test</phase>
<goals>
<goal>create</goal>
</goals>
</execution>
<execution>
<id>liberty-install-feature</id>
<phase>pre-integration-test</phase>
<goals>
<goal>install-feature</goal>
</goals>
</execution>
<execution>
<id>liberty-deploy</id>
<phase>pre-integration-test</phase>
<goals>
<goal>deploy</goal>
</goals>
</execution>
<execution>
<id>liberty-start</id>
<phase>pre-integration-test</phase>
<goals>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>liberty-stop</id>
<phase>post-integration-test</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>This is important. You depend on the MicroProfile specification. The runtime provides the implementation.
Now configure Liberty in src/main/liberty/config/server.xml:
<?xml version="1.0" encoding="UTF-8"?>
<server description="Book Catalog">
<!--
One feature tag activates the entire MicroProfile 7.1 spec:
Config, Health, Fault Tolerance, JWT, Metrics, OpenAPI, Rest Client, and more.
You do not need to list them individually unless you want fine-grained control.
-->
<featureManager>
<feature>microProfile-7.1</feature>
<feature>pages-3.1</feature>
</featureManager>
<!--
${liberty.http.port} is resolved from the <variable> element below,
which can itself be overridden by a system property or env var.
-->
<httpEndpoint id="defaultHttpEndpoint"
httpPort="${liberty.http.port}"
httpsPort="${liberty.https.port}"/>
<!--
Deploy the WAR at the root context so URLs are /api/...
The location attribute matches the <finalName> in pom.xml.
-->
<webApplication location="book-catalog.war" contextRoot="/"/>
<!-- Default port values — overridden by the Maven property ${liberty.http.port} -->
<variable name="liberty.http.port" value="9080"/>
<variable name="liberty.https.port" value="9443"/>
<!-- JWT defaults — you will uncomment mpJwt in Step 7 -->
<variable name="jwt.issuer" value="https://dev-issuer.example.com"/>
</server>One <feature> enables Config, Health, Fault Tolerance, JWT, Metrics, and more. No extra libraries. We do add the jsp feature on top to have a UI technologie.
Start dev mode:
mvn liberty:devLiberty starts in development mode and redeploys on changes. Verify it boots cleanly before adding business code. Test it out via http://localhost:9080/
Implementing MicroProfile Config
We start with configuration. Hard-coded values are fine until you deploy to staging and production. Then you discover that changing a port or feature flag requires a rebuild.
Create src/main/resources/META-INF/microprofile-config.properties:
catalog.max_results=25
catalog.storage_backend=in-memory
catalog.feature.recommendations=falseNow create a config bean. src/main/java/dev/example/catalog/CatalogConfig.java:
package dev.example.catalog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.Optional;
@ApplicationScoped
public class CatalogConfig {
@Inject
@ConfigProperty(name = "catalog.max_results", defaultValue = "10")
int maxResults;
@Inject
@ConfigProperty(name = "catalog.storage_backend")
String storageBackend;
@Inject
@ConfigProperty(name = "catalog.feature.recommendations")
Optional<Boolean> recommendationsEnabled;
public int getMaxResults() {
return maxResults;
}
public String getStorageBackend() {
return storageBackend;
}
public boolean isRecommendationsEnabled() {
return recommendationsEnabled.orElse(false);
}
}This looks simple. But there are guarantees and limits.
If catalog.storage_backend is missing, the application fails at startup. This is good. You detect misconfiguration early. But if you want optional flags, you must use Optional. Otherwise, missing config crashes your service.
MicroProfile Config defines override priority. System properties override environment variables. Environment variables override microprofile-config.properties. This means you can run the same artifact everywhere and change behavior per environment.
Create the JAX-RS Application subclass with @ApplicationPath("/api"): src/main/java/dev/example/catalog/CatalogApplication.java:
package dev.example.catalog;
import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;
@ApplicationPath("/api")
public class CatalogApplication extends Application {
}
Create a REST resource to expose config src/main/java/dev/example/catalog/CatalogResource.java:
package dev.example.catalog;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/books")
@Produces(MediaType.APPLICATION_JSON)
public class CatalogResource {
@Inject
CatalogConfig config;
@GET
@Path("/config-info")
public Response configInfo() {
String json = String.format(
"{\"maxResults\":%d,\"backend\":\"%s\",\"recommendations\":%b}",
config.getMaxResults(),
config.getStorageBackend(),
config.isRecommendationsEnabled());
return Response.ok(json).build();
}
}Test it:
curl http://localhost:9080/api/books/config-info | jqResult:
{
"maxResults": 25,
"backend": "in-memory",
"recommendations": false
}Override via environment:
CATALOG_MAX_RESULTS=99 mvn liberty:devCall again.
{
"maxResults": 99,
"backend": "in-memory",
"recommendations": false
}This means your code never changes between environments. Only config changes. The limit is clear: Config does not validate complex relationships. If two values conflict, your code must detect it.
Implementing MicroProfile Health
Health endpoints are not for humans. They are for orchestrators.
Create a liveness check src/main/java/dev/example/catalog/HealthCheckResponse.java:
package dev.example.catalog;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Liveness;
import jakarta.enterprise.context.ApplicationScoped;
@Liveness
@ApplicationScoped
public class CatalogLiveness implements HealthCheck {
@Override
public HealthCheckResponse call() {
return HealthCheckResponse.named("catalog-liveness")
.status(true)
.build();
}
}Liveness answers one question: is the JVM alive and sane? If this returns DOWN, Kubernetes restarts the pod.
Now readiness src/main/java/dev/example/catalog/StorageReadiness.java:
package dev.example.catalog;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Readiness;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@Readiness
@ApplicationScoped
public class StorageReadiness implements HealthCheck {
@Inject
CatalogConfig config;
@Override
public HealthCheckResponse call() {
boolean storageOk = "in-memory".equals(config.getStorageBackend());
return HealthCheckResponse.named("catalog-storage")
.status(storageOk)
.withData("backend", config.getStorageBackend())
.build();
}
}Readiness decides if the service should receive traffic. If you return DOWN here, the load balancer stops sending requests. The pod is not restarted.
Under stress, this matters. If your database is temporarily unreachable, readiness should go DOWN. Liveness should stay UP. Otherwise, your cluster keeps restarting healthy pods.
Test endpoints:
curl http://localhost:9080/health | jq
curl http://localhost:9080/health/live | jq
curl http://localhost:9080/health/ready | jqThe JSON shape is defined by the spec. This means your Kubernetes probes work across runtimes.
Implementing MicroProfile Fault Tolerance
Now we simulate a recommendation engine. It fails sometimes. It is slow sometimes. This is real life.
Create a client stub src/main/java/dev/example/catalog/StorageReadiness.java:
package dev.example.catalog;
import java.util.List;
import java.util.Random;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class RecommendationClient {
private final Random random = new Random();
public List<String> fetchRecommendations(String bookId) {
if (random.nextInt(3) == 0) {
throw new RuntimeException("Recommendation engine unavailable");
}
if (random.nextInt(5) == 0) {
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return List.of("Book-A", "Book-B", "Book-C");
}
}Now wrap it with Fault Tolerance. Create the recommendation service. src/main/java/dev/example/catalog/RecommendationService.java:
package dev.example.catalog;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.eclipse.microprofile.faulttolerance.CircuitBreaker;
import org.eclipse.microprofile.faulttolerance.Fallback;
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.faulttolerance.Timeout;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class RecommendationService {
@Inject
RecommendationClient client;
@Timeout(value = 2, unit = ChronoUnit.SECONDS)
@Retry(maxRetries = 3)
@CircuitBreaker(requestVolumeThreshold = 4, failureRatio = 0.5, delay = 10, delayUnit = ChronoUnit.SECONDS)
@Fallback(fallbackMethod = "defaultRecommendations")
public List<String> getRecommendations(String bookId) {
return client.fetchRecommendations(bookId);
}
public List<String> defaultRecommendations(String bookId) {
return List.of("Popular-Book-1", "Popular-Book-2");
}
}Add an endpoint to CatalogResource.java
import jakarta.ws.rs.PathParam;
@Inject
RecommendationService recommendations;
@GET
@Path("/{id}/recommendations")
public Response getRecommendations(@PathParam("id") String id) {
return Response.ok(
recommendations.getRecommendations(id)).build();
}What happens under load?
Each call has a two-second timeout. If the client sleeps four seconds, the call fails fast. Retry tries up to three times. If enough calls fail, the circuit opens. When open, calls immediately go to fallback.
for i in {1..10}; do curl -s -w "\n%{http_code}\n" http://localhost:9080/api/books/1/recommendations; doneResults:
["Book-A","Book-B","Book-C"]
["Book-A","Book-B","Book-C"]
["Book-A","Book-B","Book-C"]
["Book-A","Book-B","Book-C"]
["Popular-Book-1","Popular-Book-2"]
["Popular-Book-1","Popular-Book-2"]
...This protects your threads. Without timeout and circuit breaker, slow downstream calls block request threads. Enough blocked threads and your whole service stops responding.
The limit: Fault Tolerance works at method boundaries. It does not protect code inside a single long-running transaction. It does not magically make blocking code non-blocking. It enforces timeouts and fallbacks.
Implementing MicroProfile JWT
Now we secure admin endpoints.
Add to microprofile-config.properties:
mp.jwt.verify.publickey.location=/publicKey.pem
mp.jwt.verify.issuer=https://dev-issuer.example.comGenerate the keys in the src/main/resources directory:
openssl genrsa -out rsaPrivateKey.pem 2048
openssl rsa -pubout -in rsaPrivateKey.pem -out publicKey.pemPlace publicKey.pem in src/main/resources.
Create admin resource:
package dev.example.catalog;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
@Path("/admin/books")
@RequestScoped
@Produces(MediaType.APPLICATION_JSON)
public class AdminResource {
@Inject
JsonWebToken jwt;
@GET
@RolesAllowed("librarian")
public Response listAll() {
String json = String.format(
"{\"subject\":\"%s\"}",
jwt.getSubject()
);
return Response.ok(json).build();
}
@POST
@RolesAllowed({"librarian", "editor"})
public Response addBook(String body) {
return Response.status(201)
.entity("{\"status\":\"created\"}")
.build();
}
@DELETE
@Path("/{id}")
@RolesAllowed("librarian")
public Response deleteBook(@PathParam("id") String id) {
return Response.noContent().build();
}
}@RolesAllowed is standard Jakarta Security. MicroProfile JWT maps the groups claim in the token to roles.
Test without token:
curl -v http://localhost:9080/api/admin/booksYou get HTTP/1.1 401 Unauthorized.
To test with valid token you’d need to either use the JSON Web Token 1.0 feature or use a small utility to generate the token yourself. I’ve written about how to do this with Quarkus before. Let me know in the comments if you’d be interested in a special OpenLiberty edition.
The guarantee: signature, issuer, expiry are validated by the runtime. The limit: you still need proper key management. If your public key is wrong or rotated without update, all requests fail.
Production Hardening
What happens under load
If the recommendation engine is slow, the two-second timeout ensures fast failure. Requests do not queue forever. Some requests return fallback. This is correct behavior.
If you remove @Timeout, blocked threads accumulate. At some point, the container thread pool is exhausted. Health checks start failing. Your service restarts for the wrong reason.
Concurrency guarantees
Fault Tolerance does not make code thread-safe. If RecommendationClient uses shared mutable state, you still have race conditions.
JWT does not enforce fine-grained permissions. It checks roles. If you need attribute-based access control, you must implement it yourself.
Security abuse cases
JWT protects endpoints only if HTTPS is enforced. Over plain HTTP, tokens are exposed.
Config can expose secrets if you log values carelessly. Do not log injected config values like database passwords.
Health endpoints can leak internal state. Be careful what you put into withData.
Conclusion
We built a small service using MicroProfile Config, Health, Fault Tolerance, and JWT on Open Liberty. Config isolates environment differences. Health defines clear liveness and readiness contracts. Fault Tolerance prevents downstream failures from cascading. JWT secures endpoints with spec-defined role mapping. The code depends only on MicroProfile APIs, so you can switch runtimes without rewriting business logic.
You own the business code. The runtime handles the plumbing.


