skills/java-helidon/SKILL.md
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 skillsauth add williamlimasilva/.copilot java-helidonInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.tools
Create a new workshop or use an existing directory as one. Handles two paths: (A) use an existing local directory the operator points at, or (B) create a new private GitHub repo in the signed-in account. Never creates a repo inside another repo.
development
Guide for setting up vcpkg in C++ projects, managing dependency versions, and cross-compiling. Covers manifest initialization, CMake and Visual Studio integration, classic-to-manifest migration, version pinning, baselines, overrides, triplets, and cross-compilation. Use when a user is working with vcpkg project setup, installation, version management, or cross-platform builds. For specialized tasks, additional references cover custom registries and overlay ports (references/registries.md), CI/CD and binary caching (references/ci.md), and troubleshooting and dependency lifecycle (references/troubleshooting.md).
testing
Emit structured agent signals — hands-up, blocked, done, checkpoint, partnership. Signals are written as JSON to .signals/ for dashboard consumption and noted in the journal for persistence.
development
Install and configure Markstream streaming Markdown renderers for Vue, React, Svelte, Angular, Nuxt, and Vue 2 applications. Use for package selection, minimal peer dependencies, CSS order, SSR boundaries, streaming mode, and renderer setup.