Java backend patterns for Opik. Use when working in apps/opik-backend, designing APIs, database operations, or services.
@InjectTracesResource, SpansResource, DatasetsResource (not TraceResource)TracesResourceTest, SpansResourceTest, DatasetsResourceTest (not TraceResourceTest)/v1/private/traces, /v1/private/spans (not /v1/private/trace)traces, spans, feedback_scores (not trace, span, feedback_score)TraceDAO, SpanDAO, DatasetDAO (not TracesDAO)TraceService, SpanService, DatasetService (not TracesService)// β
GOOD
@Path("/v1/private/traces")
public class TracesResource { }
// β
GOOD - DAO and Service use singular
public class TraceDAO { }
public class TraceService { }
// β
GOOD - test classes match plural resource name
public class TracesResourceTest { }
// β BAD - singular test class
public class TraceResourceTest { }
// β BAD - singular resource/URL
@Path("/v1/private/trace")
public class TraceResource { }
// β BAD - plural DAO/Service
public class TracesDAO { }
public class TracesService { }
@Builder(toBuilder = true)@NonNull on required fields β it generates a runtime null check at construction@Valid cascade (Jakarta validators like @NotNull/@NotBlank/@Size), use Jakarta annotations only β do not stack @NonNull on top. Bean Validation already enforces the contract at the API boundary; doubling up is redundant noise// β
GOOD - internal record, Lombok @NonNull
@Builder(toBuilder = true)
record MyData(@NonNull UUID id, @NonNull String name, String description) {}
MyData data = MyData.builder()
.id(id)
.name(name)
.build();
// β
GOOD - request-body DTO, Jakarta validators only
@Builder(toBuilder = true)
public record MyRequest(
@NotNull UUID id,
@NotBlank String name,
@NotNull @Size(min = 1, max = 1000) @Valid List<MyItem> items) {}
// β BAD - plain constructor (positional mistakes, less readable)
new MyData(id, name, null);
// β BAD - @Builder without toBuilder
@Builder
record MyData(UUID id, String name) {}
// β BAD - stacking @NonNull and @NotNull on the same field
public record MyRequest(@NonNull @NotNull UUID id) {}
@RequiredArgsConstructor(onConstructor_ = @Inject) instead of manual constructors// β
GOOD
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class MyService {
private final @NonNull DependencyA depA;
private final @NonNull DependencyB depB;
}
// β BAD - boilerplate constructor
public class MyService {
private final DependencyA depA;
@Inject
public MyService(DependencyA depA) {
this.depA = depA;
}
}
@NonNull) on interface method parameters// β
GOOD
interface MyService {
void process(String workspaceId, UUID promptId);
}
// β BAD - validation on interface
interface MyService {
void process(@NonNull String workspaceId, @NonNull UUID promptId);
}
// β
GOOD
var template = TemplateUtils.newST(QUERY);
// β BAD - causes memory leak via STGroup singleton
var template = new ST(QUERY);
// β
GOOD
users.getFirst()
users.getLast()
// β BAD
users.get(0)
users.get(users.size() - 1)
Never build a query out of Java string operations. No +, no String.format /
.formatted(...), no StringBuilder, no MessageFormat, no String.join over clauses. A
query is declared once as a text block, and everything that varies goes through exactly one of
two mechanisms:
| What varies | Mechanism |
|---|---|
| A value β id, name, timestamp, list of ids | :placeholder + .bind("placeholder", value) |
| A fragment β predicate, sort clause, projected column, CTE | StringTemplate <if(x)>β¦<endif>, <else>, <x> + template.add("x", β¦) |
Why: interpolating values is the SQL-injection surface, and interpolating fragments hides which query a DAO actually runs β the declaration site stops being readable, and callers drift apart over time.
// β
GOOD - text block, values bound, structure via StringTemplate
@SqlQuery("""
SELECT * FROM datasets
WHERE workspace_id = :workspace_id
<if(name)> AND name like concat('%', :name, '%') <endif>
""")
// β BAD - string concatenation
@SqlQuery("SELECT * FROM datasets " +
"WHERE workspace_id = :workspace_id " +
"<if(name)> AND name like concat('%', :name, '%') <endif> ")
A predicate that differs between callers is a fragment, so it belongs in the template β not
in a %s slot the caller fills in:
// β BAD - caller splices the predicate in
private static final String TOKEN_USAGE_NAMES_TEMPLATE = """
SELECT DISTINCT name FROM (
SELECT usage FROM spans FINAL
WHERE workspace_id = :workspace_id
AND %s
) ...
""";
static String tokenUsageNames(String projectPredicate) {
return TOKEN_USAGE_NAMES_TEMPLATE.formatted(projectPredicate);
}
// caller: tokenUsageNames("project_id IN :project_ids")
// β
GOOD - both shapes live in the template, the caller picks one
private static final String TOKEN_USAGE_NAMES = """
SELECT DISTINCT name FROM (
SELECT usage FROM spans FINAL
WHERE workspace_id = :workspace_id
<if(project_ids)> AND project_id IN :project_ids <endif>
<if(project_id)> AND project_id = :project_id <endif>
) ...
""";
var template = TemplateUtils.newST(TOKEN_USAGE_NAMES);
template.add("project_ids", true);
...
statement.bind("project_ids", projectIds.toArray(new UUID[0]));
A fragment that genuinely can't be enumerated in the template β a user-chosen sort field or
filter clause β must be produced by the allow-listed builders (SortingQueryBuilder,
FilterQueryBuilder), never assembled from raw request strings.
.formatted(...) stays correct for log and exception messages. The rule is about SQL text only.
Some %s query templates predate this rule. Don't copy them and don't add new ones.
// β
GOOD
Set.of("A", "B", "C")
List.of(1, 2, 3)
Map.of("key", "value")
// β BAD
Arrays.asList("A", "B", "C")
exclude_category_names not exclude_category_name). Starting with a singular name and later adding a plural variant results in two redundant query params on the same endpoint. Plural names are backward-compatible since they work for both single and multiple values.throw new BadRequestException("Invalid input");
throw new NotFoundException("User not found: '%s'".formatted(id));
throw new ConflictException("Already exists");
throw new InternalServerErrorException("System error", cause);
io.dropwizard.jersey.errors.ErrorMessagecom.comet.opik.api.error.ErrorMessage// β
GOOD - values in single quotes
log.info("Created user: '{}'", userId);
log.error("Failed for workspace: '{}'", workspaceId, exception);
// β BAD - no quotes
log.info("Created user: {}", userId);
Put the message first and the interpolated values at the end of the sentence, as a trailing
name '{}' list. This keeps a stable literal prefix that stays greppable during a production
debugging session β a message whose values are interleaved has no fixed substring to search for.
Applies to log.* format strings and to exception messages built with .formatted(...).
// β
GOOD - literal prefix first, values trailing
log.warn("Alert name is required, workspaceId '{}'", workspaceId);
log.debug("Webhook delivery failed, id '{}', status '{}'", eventId, status);
throw new DestinationGuardException("destination has no valid host, url '%s'".formatted(url));
// β BAD - values interleaved, no greppable prefix
log.debug("Webhook '{}' failed with status '{}'", eventId, status);
throw new DestinationGuardException("destination '%s' has no valid host".formatted(url));
@RequiredPermissions annotation guidance for endpoints