github/java-helidon
Get best practices for developing applications with Helidon 4 (SE and MP). Use when working with Helidon SE or Helidon MP, HttpService routing, Helidon DB Client, MicroProfile Config, Helidon Security, or Helidon testing in Java 21+ projects.
npx skills add https://github.com/github/awesome-copilot --skill java-helidon
Your goal is to help me write high-quality Helidon applications by following established best practices.
Helidon 4 renamed or resignatured APIs that appear widely in their Helidon 3 form.
The left column does not compile on Helidon 4. Check generated code against this table
before returning it.
| Do not use (Helidon 3) | Use (Helidon 4) |
| ---------------------------------------------------------- | ------------------------------------------------------------------ |
| io.helidon.common.http.Http.Status | io.helidon.http.Status |
| io.helidon.webserver.Service | io.helidon.webserver.http.HttpService |
| Routing.Rules, update(Routing.Rules) | HttpRules, routing(HttpRules) |
| request.path().param("id") | request.path().pathParameters().get("id") |
| String s = column.as(String.class) | column.getString() or column.get(String.class) |
| dbClient.execute(exec -> ...) returning Single/Multi | dbClient.execute() returning Optional<DbRow> / Stream<DbRow> |
| javax.* | jakarta.* |
| helidon-microprofile-tests-junit5 | helidon-microprofile-testing-junit5 |
Value.as(Class) in Helidon 4 returns OptionalValue<T>, not T. This is the single
most common Helidon 4 compile error in generated code.
pom.xml) or Gradle (build.gradle) for dependency management.com.example.app.order and com.example.app.customer, rather than only by technical layer.private final.HttpService implementations.Single, Multi, or CompletionStage chains.@ApplicationScoped and @RequestScoped intentionally.application.yaml or application.properties.PUT and DELETE, return 404 when the target does not exist rather than succeeding unconditionally.ExceptionMapper implementations in Helidon MP.Use an HttpService to register routes programmatically. Keep request handlers small and delegate business logic to a service.
import io.helidon.http.Status;
import io.helidon.webserver.http.HttpRules;
import io.helidon.webserver.http.HttpService;
import io.helidon.webserver.http.ServerRequest;
import io.helidon.webserver.http.ServerResponse;
public final class CustomerHttpService implements HttpService {
private final CustomerService customerService;
public CustomerHttpService(CustomerService customerService) {
this.customerService = customerService;
}
@Override
public void routing(HttpRules rules) {
rules.get("/{id}", this::findById);
}
private void findById(ServerRequest request, ServerResponse response) {
var id = request.path().pathParameters().get("id");
customerService.findById(id)
.ifPresentOrElse(
response::send,
() -> response.status(Status.NOT_FOUND_404).send()
);
}
}
Register the HTTP service when constructing the server:
import io.helidon.webserver.WebServer;
WebServer server = WebServer.builder()
.routing(routing -> routing.register("/customers", customerHttpService))
.build()
.start();
Use Jakarta REST annotations for endpoints and CDI for dependency injection.
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
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;
@Path("/customers")
@RequestScoped
@Produces(MediaType.APPLICATION_JSON)
public class CustomerResource {
private final CustomerService customerService;
protected CustomerResource() {
this.customerService = null;
}
@Inject
public CustomerResource(CustomerService customerService) {
this.customerService = customerService;
}
@GET
@Path("/{id}")
public Response findById(@PathParam("id") String id) {
return customerService.findById(id)
.map(customer -> Response.ok(customer).build())
.orElseGet(() -> Response.status(Response.Status.NOT_FOUND).build());
}
}
Optional<CustomerEntity> where the caller expects Optional<Customer> is a common generated-code compile error.Helidon SE services are normally plain Java classes with explicitly supplied dependencies.
public final class CustomerService {
private final CustomerRepository customerRepository;
public CustomerService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public Optional<Customer> findById(String id) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Customer ID is required");
}
return customerRepository.findById(id);
}
public Customer create(CreateCustomerRequest request) {
if (request.name() == null || request.name().isBlank()) {
throw new IllegalArgumentException("Customer name is required");
}
var customer = new Customer(request.id(), request.name().trim());
customerRepository.save(customer);
return customer;
}
}
Construct the dependency graph explicitly:
var repository = new DbCustomerRepository(dbClient);
var service = new CustomerService(repository);
var httpService = new CustomerHttpService(service);
Use CDI scopes and constructor injection. Apply transactions at the service layer when a business operation changes persistent state. Map the persistence entity to the API model here, so the service never leaks CustomerEntity to callers.
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class CustomerService {
private final JpaCustomerRepository customerRepository;
protected CustomerService() {
this.customerRepository = null;
}
@Inject
public CustomerService(JpaCustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public Optional<Customer> findById(String id) {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Customer ID is required");
}
return customerRepository.findById(id)
.map(Customer::fromEntity);
}
@Transactional
public Customer create(CreateCustomerRequest request) {
if (request.name() == null || request.name().isBlank()) {
throw new IllegalArgumentException("Customer name is required");
}
var entity = new CustomerEntity(request.id(), request.name().trim());
customerRepository.save(entity);
return Customer.fromEntity(entity);
}
}
column("name").getString() (or getInt(), getLong(), and so on) or with column("name").get(String.class). DbColumn.as(String.class) returns an OptionalValue<String> in Helidon 4, not a String.asOptional() or another optional-aware accessor. Direct getString() and similar accessors throw when the column value is null.DbRow.as(Customer.class) returns the mapped instance directly, but requires a DbMapper registered through a DbMapperProvider service-loader entry. Prefer explicit column reads for a small number of simple repositories, and introduce a DbMapper when the same row shape is mapped in several places.Use Helidon DB Client with parameterized statements. Map database rows into application models inside the repository.
import io.helidon.dbclient.DbClient;
public final class DbCustomerRepository implements CustomerRepository {
private static final String FIND_BY_ID =
"SELECT id, name FROM customers WHERE id = :id";
private static final String INSERT =
"INSERT INTO customers (id, name) VALUES (:id, :name)";
private final DbClient dbClient;
public DbCustomerRepository(DbClient dbClient) {
this.dbClient = dbClient;
}
@Override
public Optional<Customer> findById(String id) {
return dbClient.execute()
.createGet(FIND_BY_ID)
.addParam("id", id)
.execute()
.map(row -> new Customer(
row.column("id").getString(),
row.column("name").getString()
));
}
@Override
public void save(Customer customer) {
dbClient.execute()
.createInsert(INSERT)
.addParam("id", customer.id())
.addParam("name", customer.name())
.execute();
}
}
Named statements can also be stored in configuration instead of embedding SQL in Java:
db:
source: "jdbc"
connection:
url: "jdbc:postgresql://localhost:5432/customers"
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
statements:
find-customer-by-id: >
SELECT id, name
FROM customers
WHERE id = :id
Reference the named statement by name instead of passing SQL text:
return dbClient.execute()
.createNamedGet("find-customer-by-id")
.addParam("id", id)
.execute()
.map(row -> new Customer(
row.column("id").getString(),
row.column("name").getString()
));
Use Jakarta Persistence in a CDI-managed repository. Keep transaction boundaries in the service layer. The repository works in entities; the service maps them to API models.
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@ApplicationScoped
public class JpaCustomerRepository {
@PersistenceContext
private EntityManager entityManager;
public Optional<CustomerEntity> findById(String id) {
return Optional.ofNullable(entityManager.find(CustomerEntity.class, id));
}
public void save(CustomerEntity customer) {
entityManager.persist(customer);
}
}
Define the persistence entity separately from the public API model:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "customers")
public class CustomerEntity {
@Id
private String id;
@Column(nullable = false)
private String name;
protected CustomerEntity() {
}
public CustomerEntity(String id, String name) {
this.id = id;
this.name = name;
}
public String id() {
return id;
}
public String name() {
return name;
}
}
The API model stays free of persistence annotations and owns the mapping:
public record Customer(String id, String name) {
public static Customer fromEntity(CustomerEntity entity) {
return new Customer(entity.id(), entity.name());
}
}
helidon-webserver-testing-junit5 with @ServerTest for full server tests and @RoutingTest for routing-only tests. These start the server on a dynamically selected port and inject a Http1Client bound to it. Never hardcode a port.helidon-microprofile-testing-junit5 with @HelidonTest, which starts the CDI container and server for the test class. Confirm the artifact coordinates against the Helidon version in use, since this module was renamed across 4.x releases.Take github/java-helidon from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.