| name | java-helidon |
|---|---|
| description | 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. |
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.
- Programming Model: Determine whether the project uses Helidon SE or Helidon MP before generating code. Do not mix the two programming models unless explicitly required.
- Java Version: Use Java 21 or later for Helidon 4 applications.
- Build Tool: Use Maven (
pom.xml) or Gradle (build.gradle) for dependency management. - Dependency Management: Use the Helidon BOM or platform to keep Helidon module versions aligned.
- Package Structure: Organize code by feature or domain, such as
com.example.app.orderandcom.example.app.customer, rather than only by technical layer.
- Explicit Composition: Construct services and dependencies explicitly in the application bootstrap layer.
- Constructor Injection: Pass required dependencies through constructors and declare dependency fields as
private final. - HTTP Services: Group related routes in focused
HttpServiceimplementations. - Business Logic: Keep business logic outside route handlers.
- Virtual Threads: Prefer straightforward blocking code with Helidon 4 virtual-thread-based request handling. Do not introduce reactive complexity without a clear reason. Helidon 4 is not reactive; do not generate
Single,Multi, orCompletionStagechains.
- Jakarta and MicroProfile: Prefer standard Jakarta EE and Eclipse MicroProfile APIs when available.
- Dependency Injection: Use CDI with constructor injection for required dependencies.
- Bean Scopes: Use CDI scopes such as
@ApplicationScopedand@RequestScopedintentionally. - Normal-Scoped Beans: Add a non-private no-argument constructor to normal-scoped beans that use constructor injection, so the CDI client proxy can be created portably.
- Business Logic: Keep Jakarta REST resource classes thin and delegate business operations to service classes.
- Portability: Prefer portable Jakarta and MicroProfile APIs over Helidon-specific APIs when portability is important.
- Externalized Configuration: Store non-secret configuration in
application.yamlorapplication.properties. - Helidon SE Configuration: Use Helidon Config and pass configuration values or typed configuration objects to components.
- Helidon MP Configuration: Use MicroProfile Config for injected application settings.
- Environment Overrides: Use environment variables or deployment-specific configuration sources for environment-dependent values.
- Secrets Management: Never hardcode credentials, API keys, tokens, or private certificates.
- DTOs: Use dedicated request and response models. Do not expose persistence entities directly through APIs.
- Validation: Validate path parameters, query parameters, headers, and request bodies before invoking business logic.
- Status Codes: Return appropriate HTTP status codes for successful, invalid, unauthorized, forbidden, missing, and failed requests. On
PUTandDELETE, return 404 when the target does not exist rather than succeeding unconditionally. - Error Handling: Use centralized error handling in Helidon SE and Jakarta REST
ExceptionMapperimplementations in Helidon MP. - Sensitive Information: Do not expose stack traces, database details, filesystem paths, or internal exception messages to clients.
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());
}
}- Transactions: Define transaction boundaries around complete business operations.
- Entity Mapping: Map persistence entities to API models at the service boundary so that service method signatures expose only API models. A service returning
Optional<CustomerEntity>where the caller expectsOptional<Customer>is a common generated-code compile error. - Concurrency: Avoid mutable shared state in application-scoped components unless access is properly coordinated.
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);
}
}- Database Access: Use Helidon DB Client, Jakarta Persistence, or another persistence mechanism already established by the project.
- Parameterized Queries: Always use parameter binding or prepared statements. Never concatenate untrusted input into SQL.
- Column Accessors: Read a typed column value with
column("name").getString()(orgetInt(),getLong(), and so on) or withcolumn("name").get(String.class).DbColumn.as(String.class)returns anOptionalValue<String>in Helidon 4, not aString. - Nullable Columns: Read nullable columns through
asOptional()or another optional-aware accessor. DirectgetString()and similar accessors throw when the column value is null. - Row Mapping: For whole-row mapping,
DbRow.as(Customer.class)returns the mapped instance directly, but requires aDbMapperregistered through aDbMapperProviderservice-loader entry. Prefer explicit column reads for a small number of simple repositories, and introduce aDbMapperwhen the same row shape is mapped in several places. - Migrations: Use a database migration tool for schema changes rather than automatic destructive schema updates.
- Entity Separation: Do not expose database entities directly as API contracts.
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 = :idReference 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());
}
}- Health: Use Helidon Health in SE or MicroProfile Health in MP for liveness and readiness checks.
- Metrics: Use Helidon Metrics or MicroProfile Metrics for operational and business measurements.
- Tracing: Propagate tracing context across inbound and outbound service calls.
- Cardinality: Avoid user IDs, request IDs, email addresses, and raw URLs as metric tags.
- Logging API: Use the logging API and implementation configured by the project.
- Sensitive Information: Never log passwords, access tokens, authorization headers, cookies, or complete sensitive request bodies. Do not place secrets or personal information in metrics or trace attributes either.
- Unit Tests: Write unit tests for business services using JUnit 5.
- Helidon SE Tests: Use
helidon-webserver-testing-junit5with@ServerTestfor full server tests and@RoutingTestfor routing-only tests. These start the server on a dynamically selected port and inject aHttp1Clientbound to it. Never hardcode a port. - Helidon MP Tests: Use
helidon-microprofile-testing-junit5with@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. - Testcontainers: Consider Testcontainers for integration tests using real databases, message brokers, or other infrastructure.
- Failure Paths: Test validation failures, missing resources, external-service failures, and authorization failures.
- Helidon Security: Use Helidon Security or supported Jakarta and MicroProfile security APIs for authentication and authorization.
- Authorization: Enforce permissions at a clear application boundary and deny protected operations by default.
- JWT and OIDC: Validate token signatures, issuers, audiences, and expiration times.
- TLS: Use TLS for production traffic and verify certificates for outbound connections.
- CORS: Configure allowed origins explicitly. Do not combine wildcard origins with credentials.
- Secrets: Store secrets in protected environment configuration or a dedicated secret-management system.
- Outbound Requests: Validate outbound destinations to reduce server-side request forgery risks.