| name | opik-backend |
| description | Java backend patterns for Opik. Use when working in apps/opik-backend, designing APIs, database operations, or services. |
Opik Backend
Architecture
- Layered: Resource → Service → DAO (never skip layers)
- DI: Guice modules, constructor injection with
@Inject
- Databases: MySQL (metadata, transactional) + ClickHouse (analytics, append-only)
Naming Conventions
Plural Names (Resources, Tests, URLs, DB Tables)
- Resource classes:
TracesResource, SpansResource, DatasetsResource (not TraceResource)
- Resource test classes:
TracesResourceTest, SpansResourceTest, DatasetsResourceTest (not TraceResourceTest)
- URL paths:
/v1/private/traces, /v1/private/spans (not /v1/private/trace)
- DB table names:
traces, spans, feedback_scores (not trace, span, feedback_score)
Singular Names (DAO, Service)
- DAO classes:
TraceDAO, SpanDAO, DatasetDAO (not TracesDAO)
- Service classes:
TraceService, SpanService, DatasetService (not TracesService)
@Path("/v1/private/traces")
public class TracesResource { }
public class TraceDAO { }
public class TraceService { }
public class TracesResourceTest { }
public class TraceResourceTest { }
@Path("/v1/private/trace")
public class TraceResource { }
public class TracesDAO { }
public class TracesService { }
Lombok Conventions
Records and DTOs
- Always annotate records/DTOs with
@Builder(toBuilder = true)
- Use builders (not constructors) when instantiating records
- For internal records (built programmatically, never validated by Bean Validation), use Lombok
@NonNull on required fields — it generates a runtime null check at construction
- For request-body DTOs validated via
@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
@Builder(toBuilder = true)
record MyData(@NonNull UUID id, @NonNull String name, String description) {}
MyData data = MyData.builder()
.id(id)
.name(name)
.build();
@Builder(toBuilder = true)
public record MyRequest(
@NotNull UUID id,
@NotBlank String name,
@NotNull @Size(min = 1, max = 1000) @Valid List<MyItem> items) {}
new MyData(id, name, null);
@Builder
record MyData(UUID id, String name) {}
public record MyRequest(@NonNull @NotNull UUID id) {}
Dependency Injection
- Use
@RequiredArgsConstructor(onConstructor_ = @Inject) instead of manual constructors
@RequiredArgsConstructor(onConstructor_ = @Inject)
public class MyService {
private final @NonNull DependencyA depA;
private final @NonNull DependencyB depB;
}
public class MyService {
private final DependencyA depA;
@Inject
public MyService(DependencyA depA) {
this.depA = depA;
}
}
Interfaces
- Don't put validation annotations (
@NonNull) on interface method parameters
- Keep interfaces free of implementation details
interface MyService {
void process(String workspaceId, UUID promptId);
}
interface MyService {
void process(@NonNull String workspaceId, @NonNull UUID promptId);
}
Critical Gotchas
StringTemplate Memory Leak
var template = TemplateUtils.newST(QUERY);
var template = new ST(QUERY);
List Access
users.getFirst()
users.getLast()
users.get(0)
users.get(users.size() - 1)
SQL Text Blocks
@SqlQuery("""
SELECT * FROM datasets
WHERE workspace_id = :workspace_id
<if(name)> AND name like concat('%', :name, '%') <endif>
""")
@SqlQuery("SELECT * FROM datasets " +
"WHERE workspace_id = :workspace_id " +
"<if(name)> AND name like concat('%', :name, '%') <endif> ")
Immutable Collections
Set.of("A", "B", "C")
List.of(1, 2, 3)
Map.of("key", "value")
Arrays.asList("A", "B", "C")
API Design
- Query parameters that accept lists: Use plural names from the start (e.g.,
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.
Error Handling
Use Jakarta Exceptions
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);
Error Response Classes
- Simple:
io.dropwizard.jersey.errors.ErrorMessage
- Complex:
com.comet.opik.api.error.ErrorMessage
- Never create new error message classes
Logging
Format Convention
log.info("Created user: '{}'", userId);
log.error("Failed for workspace: '{}'", workspaceId, exception);
log.info("Created user: {}", userId);
Never Log
- Emails, passwords, tokens, API keys
- PII, personal identifiers
- Database credentials
Reference Files