Skip to content
Deze documentatie wordt actief uitgebreid — kom regelmatig terug.

CTN ASR Coding Standards

This document defines the coding standards for the CTN Association Register project. All code should follow these conventions for consistency and maintainability.

Stack reminder. The preferred stack is decided in ADR-00002: Quarkus + PostgreSQL + Keycloak, Java-based, open-source, cloud-agnostic. The conventions below assume that stack. The frontend stack for the ASR portals is not yet decided (see Open Questions at the bottom); the front-end section gives only the language-agnostic rules until a framework is chosen.


Column Names — snake_case

✓ GOOD:
legal_entity_id
dt_created
dt_modified
kvk_verification_status
document_uploaded_at
✗ BAD:
legalEntityId
dtCreated
documentUploadedAt -- missing prefix when part of kvk_* group

Table Names — snake_case, singular

✓ GOOD:
legal_entity
legal_entity_contact
endpoint_authorization
✗ BAD:
LegalEntity
legalEntityContact
legal_entities -- plural

Consistency Rule: related columns share a prefix.

✓ GOOD:
kvk_document_url
kvk_verification_status
kvk_verified_at
kvk_verified_by
kvk_extracted_company_name
✗ BAD:
kvk_document_url
verification_status -- missing kvk_ prefix
document_uploaded_at -- missing kvk_ prefix

Path Segments — kebab-case

✓ GOOD:
/v1/legal-entities/{legalEntityId}/identifiers
/v1/kvk-verification/flagged-entities
/v1/participant-contacts
✗ BAD:
/v1/legalentities/... -- no separation
/v1/kvk_verification/... -- snake_case in URL
/v1/participant/contacts -- inconsistent (missing hyphen)

Path Parameters — camelCase

✓ GOOD:
/v1/legal-entities/{legalEntityId}
/v1/participants/{participantId}/members/{userId}
✗ BAD:
/v1/legal-entities/{legal_entity_id} -- snake_case in URL params
/v1/legal-entities/{LegalEntityId} -- PascalCase

Query Parameters — snake_case

✓ GOOD:
?task_type=kvk_verification
?assigned_to=admin@ctn.nl
?include_overdue=true
✗ BAD:
?taskType=...
?assignedTo=...

Packages — lowercase, dotted

✓ GOOD:
package nl.ctn.asr.participant;
package nl.ctn.asr.identifier;
package nl.ctn.asr.token;
✗ BAD:
package nl.ctn.asr.Participant;
package nl.CTN.asr.participant;

Classes / Records / Sealed interfaces — PascalCase

✓ GOOD:
public record LegalEntity(UUID id, String name, ...) { }
public sealed interface VerificationOutcome permits Approved, Pending, Rejected { }
public class ParticipantResource { }
✗ BAD:
public class participantResource { }
public record legal_entity(...) { }

Transport DTOs — Req / Resp suffixes allowed. Request/response records in dto packages may abbreviate the suffix words RequestReq and ResponseResp (CreateOnboardingReq, OnboardingReqResp). This is the only sanctioned abbreviation, and only for the transport-role suffix — glossary terms inside the name stay full words (ADR-00004): the entity stays OnboardingRequest. Rationale: avoids stutter like OnboardingRequestResponse where the glossary term Request collides with the transport suffix.

✓ GOOD:
public record CreateOnboardingReq(...) { } (dto package, transport suffix)
public class OnboardingRequest { } (domain entity, full glossary term)
✗ BAD:
public class OnboardingReq { } (domain term abbreviated)
public record CreateOnboardingRqst(...) { } (unsanctioned abbreviation)

Methods / Variables — camelCase

✓ GOOD:
public Uni<Participant> findByEuid(String euid) { }
private final ParticipantRepository participantRepository;
var legalEntityId = UUID.randomUUID();
✗ BAD:
public Uni<Participant> Find_By_EUID(String EUID) { }
private final ParticipantRepository ParticipantRepository;

Constants — UPPER_SNAKE_CASE

✓ GOOD:
public static final String VAD_TOKEN_AUDIENCE = "https://ctn.network";
public static final Duration KVK_CACHE_TTL = Duration.ofHours(24);
✗ BAD:
public static final String vadTokenAudience = "...";
public static final Duration KvkCacheTtl = Duration.ofHours(24);

File Names — match the public class / record / interface inside.

✓ GOOD:
ParticipantResource.java (public class ParticipantResource)
LegalEntity.java (public record LegalEntity)
VerificationOutcome.java (public sealed interface VerificationOutcome)
✗ BAD:
participantResource.java
legal-entity.java

1.4 Records, Sealed Interfaces, Pattern Matching

Section titled “1.4 Records, Sealed Interfaces, Pattern Matching”

Java 25+ features that the codebase actively uses:

  • record for DTOs, value objects, and immutable data carriers (legal entities, identifiers, claim payloads).
  • sealed interface + permits for closed enumerations of states (e.g. VerificationOutcome, OnboardingState).
  • Pattern matching for switch on those sealed hierarchies — exhaustiveness checked by the compiler.
✓ GOOD:
public sealed interface VerificationOutcome
permits Approved, Pending, Rejected { }
public record Approved(Instant at, String verifiedBy) implements VerificationOutcome { }
public record Pending(String waitingFor) implements VerificationOutcome { }
public record Rejected(String reason) implements VerificationOutcome { }
return switch (outcome) {
case Approved a -> Response.ok(a).build();
case Pending p -> Response.status(202).entity(p).build();
case Rejected r -> Response.status(409).entity(r).build();
};
✗ BAD:
// Don't expose mutable POJOs in REST layers
public class VerificationResultDTO {
public String status;
public Date when;
}

ALWAYS use TIMESTAMP WITH TIME ZONE

✓ GOOD:
dt_created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
kvk_verified_at TIMESTAMP WITH TIME ZONE
expires_at TIMESTAMP WITH TIME ZONE
✗ BAD:
created_at TIMESTAMP WITHOUT TIME ZONE -- no timezone info
updated_at TIMESTAMP -- ambiguous

Rationale: TIMESTAMP WITH TIME ZONE stores timestamps in UTC internally and converts to/from any timezone automatically. This prevents timezone bugs.

Unix Timestamp Conversion:

✓ GOOD:
to_timestamp($1) AT TIME ZONE 'UTC'
✗ BAD:
to_timestamp($1) -- assumes server timezone

Use java.time.* only. Never java.util.Date, java.util.Calendar or Joda-Time.

✓ GOOD:
Instant createdAt = Instant.now();
OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(24);
LocalDate dueDate = LocalDate.of(2026, 6, 1);
✗ BAD:
Date created = new Date(); // legacy, mutable, timezone-naive
Calendar.getInstance(); // legacy

Persistence: store Instant (mapped to TIMESTAMP WITH TIME ZONE) for events, LocalDate for calendar dates without time.

Always return dates in ISO 8601 with UTC offset (Z). Jackson is configured to serialise Instant and OffsetDateTime exactly this way.

✓ GOOD:
{
"created_at": "2026-05-29T14:30:00.000Z",
"expires_at": "2026-05-30T14:30:00.000Z"
}
✗ BAD:
{
"created_at": "2026-05-29T14:30:00", // no timezone
"expires_at": "05/30/2026" // ambiguous format
}

The frontend MUST format dates using the user’s locale, not a hard-coded one. Use the framework’s built-in localisation utilities (when the frontend stack is chosen; see Open Questions).

✓ GOOD:
Format with the user's locale (resolved from i18n or browser).
✗ BAD:
Hard-coded "nl-NL" or "en-GB" in display code.

For relative times (e.g. “2 hours ago”), use the platform-standard relative-time formatter.

Show timezone information for absolute timestamps:

✓ GOOD:
"Created: 2026-05-29 14:30 UTC"
"Expires: 2026-05-30 14:30 (in 23 hours)"
✗ BAD:
"Created: 2026-05-29 14:30" -- no timezone info

For relative times, timezone is not needed:

✓ GOOD:
"Created 2 hours ago"
"Expires in 23 hours"

200 OK - Successful GET / PUT
201 Created - Successful POST
204 No Content - Successful DELETE
400 Bad Request - Validation error
401 Unauthorized - Missing / invalid auth token
403 Forbidden - Valid auth but no permission
404 Not Found - Resource doesn't exist
409 Conflict - Duplicate resource
422 Unprocessable - Business-logic error
429 Too Many Req. - Rate limit exceeded
500 Server Error - Unexpected server error

3.2 Error Response Format (RFC 9457 — Problem Details for HTTP APIs)

Section titled “3.2 Error Response Format (RFC 9457 — Problem Details for HTTP APIs)”
✓ GOOD:
{
"type": "https://docs.ctn.network/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "kvk_number must be 8 digits",
"instance": "/v1/legal-entities/abc-123/identifiers",
"errors": [
{ "field": "kvk_number", "message": "must be 8 digits" }
]
}
✗ BAD:
{ "message": "Error" } -- not helpful

Quarkus exposes RestResponse<ProblemDetails> builders; use those rather than rolling a custom body shape.


Use Panache or parameter-bound PreparedStatement. Never concatenate user input into SQL.

✓ GOOD:
participantRepository.find("email = ?1", email).firstResult();
em.createQuery("SELECT p FROM Participant p WHERE p.email = :email", Participant.class)
.setParameter("email", email)
.getSingleResult();
✗ BAD:
em.createQuery("SELECT p FROM Participant p WHERE p.email = '" + email + "'", ...);

Never commit secrets to git. Use the configured secret store (e.g. Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) and inject through Quarkus configuration:

✓ GOOD:
@ConfigProperty(name = "kvk.api-key")
String kvkApiKey;
✗ BAD:
private static final String KVK_API_KEY = "abc123xyz";

application.properties references the secret indirectly (e.g. ${KVK_API_KEY}); the secret-store driver resolves it at runtime.

Use quarkus-oidc as the resource-server, configured against Keycloak (see ADR-00002, ADR-00008). Endpoints carry standard @Authenticated, @RolesAllowed, and @PermissionsAllowed annotations.

✓ GOOD:
@Path("/v1/participants/{participantId}")
@Authenticated
public class ParticipantResource {
@GET
@RolesAllowed("participant-member")
public Participant get(@PathParam("participantId") UUID id) { ... }
}

4.4 Object-level authorization (ownership)

Section titled “4.4 Object-level authorization (ownership)”

@RolesAllowed is coarse role gating only. It does not answer “may this caller touch this record?” When an endpoint exposes user-owned data, the role check must be backed by a data-dependent ownership check that compares the caller’s identity (Keycloak sub) to the record’s owner. Two callers with the same role must not be able to read or mutate each other’s data.

Enforce ownership below the resource layer (in the service/transition shell), so no endpoint — current or future — can forget the check.

Check ownership before state/existence, so a non-owner cannot probe a record’s lifecycle or existence through differing error codes (a non-owner always gets 403, never a 404/409 that leaks whether the record exists or what state it is in).

✓ GOOD:
// OnboardingTransitions — ownership before state
public OnboardingRequest findOwned(UUID requestId, Actor actor) {
var request = load(requestId);
if (!actor.sub().equals(request.applicantUserId)) { // owner == Keycloak sub
throw new SecurityException("Actor may only read an onboarding request they own");
}
return request; // 403 on mismatch, before any state read
}
✗ BAD:
@GET @Path("{requestId}")
@RolesAllowed(Roles.REQUESTOR)
public OnboardingRequest get(@PathParam("requestId") UUID id) {
return repository.findById(id); // any requestor reads any requestor's record
}

Worked example — OnboardingRequest. After (anonymous) creation, only the creator (applicantUserId == sub) and the association admin (ASSOCIATION_ADMIN) may read or modify it. Admin endpoints carry @RolesAllowed(ASSOCIATION_ADMIN); requestor endpoints carry @RolesAllowed(REQUESTOR) and run the applicantUserId == actor.sub() ownership check in the transition shell (findOwned / enrich / resubmit).


5.1 Project Layout (Quarkus, single module)

Section titled “5.1 Project Layout (Quarkus, single module)”
asr/
├── src/
│ ├── main/
│ │ ├── java/nl/ctn/asr/
│ │ │ ├── participant/ ← Domain package (resource + service + repo)
│ │ │ │ ├── ParticipantResource.java
│ │ │ │ ├── ParticipantService.java
│ │ │ │ ├── Participant.java (record)
│ │ │ │ └── ParticipantRepository.java
│ │ │ ├── identifier/
│ │ │ ├── token/
│ │ │ └── endpoint/
│ │ └── resources/
│ │ ├── application.properties
│ │ └── db/changelog/ ← Liquibase changelogs (YAML)
│ └── test/
│ └── java/nl/ctn/asr/
│ ├── participant/ParticipantResourceTest.java
│ └── ...
└── pom.xml

The package layout is domain-first (participant, identifier, token, endpoint), not layer-first (no controllers/, services/, repositories/ top-level packages).

Each top-level business package under org.bdinetwork.asr (audit, document, verification, onboarding) is a module that exposes a small, explicit seam and hides everything else. Because the code is a Quarkus modulith (no Spring Modulith) and Java access control cannot express “module-internal across sub-packages” — CDI proxying and JAX-RS resources force types to be public — the boundary is defined by package placement and enforced by an ArchUnit allow-list (ModuleBoundaryTest), not the compiler. See ADR-00022 for the full decision; this section is the working digest. Audit is the pilot; document/verification/onboarding migrate opportunistically as they are touched.

A module has three package zones:

audit/ ← SEAM: root package
AuditRecorder (service interface — callers inject this, never the impl)
Action (seam enum — root, NOT dto/)
dto/ ← SEAM (lazy): the interface's own record types
exceptions/ ← SEAM (lazy): seam exception types
rest/ ← HTTP boundary — sibling of internal, NOT seam, NOT injectable
AuditRecordResource
dto/AuditRecordResp (+ from(entity) mapper — HTTP-only wire types)
internal/ ← hidden implementation (subtree; add packages freely)
AuditRecorderImpl (public, but ArchUnit-hidden)
AuditRecordQueryService
persistence/ ← internal.persistence: JPA entities + repositories
AuditRecord
AuditRecordRepository

Rules:

  • Seam = module root + dto + exceptions. Nothing else is reachable from other modules. Cross-module communication goes through the root service interface (+ any published events); callers inject the interface, never the impl. Create dto/exceptions lazily, only when a seam type needs them.
  • dto/ holds the interface’s own types, each a record (per ArchitectureTest.dtos_should_be_records). Enums and other non-record seam value types live at the root, not dto/.
  • rest/ is a top-level sibling of internal, not part of the seam (a deliberate deviation from the source spec’s internal.rest): a module’s HTTP surface is externally significant, so it is visible at a glance — but the allow-list still forbids sibling modules from injecting it. HTTP-only wire types live under rest/ (e.g. rest/dto), never the seam dto/. A resource may freely consume its own module’s internal (same-module access is unrestricted).
  • internal/ is a subtree. Add hidden packages under internal.* freely (fail-safe default). Persistence — JPA entities + repositories — lives in internal.persistence. Never create hidden packages as seam siblings other than rest/.
  • model/repository parking packages dissolve into each module’s internal.persistence as modules migrate.
  • Tests are exempt (ImportOption.DoNotIncludeTests): same-module tests may touch internal; cross-module/e2e tests go through HTTP (RestAssured), not Java imports.

Watch the Javadoc {@link} trap. ArchUnit reads bytecode, so a {@link some.other.module.internal.persistence.Entity} — and the import it needs — passes the boundary test while still coupling your source to another module’s internals. Reference the seam type (or plain prose) in cross-module Javadoc, and keep no import of another module’s internal.. in production code.

Java import grouping (IntelliJ / google-java-format default):

// 1. Standard library
import java.time.Instant;
import java.util.UUID;
// 2. Third-party / Quarkus / Jakarta
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import io.quarkus.security.Authenticated;
import org.eclipse.microprofile.config.inject.ConfigProperty;
// 3. Project
import nl.ctn.asr.participant.Participant;

No wildcard imports. No unused imports. Static imports only for test assertions and obvious helpers (e.g. Assertions.assertThat).


Every public REST method has a Javadoc comment that explains what and why (not just the signature):

/**
* Creates a new legal-entity identifier (KVK, LEI, EORI, …) for the
* given participant. Idempotent on (participant, type, value).
*
* @param participantId participant the identifier belongs to
* @param request identifier details (type, value, country)
* @return 201 Created with the persisted identifier
* @throws ValidationException if the value does not match the type's format
* @throws ConflictException if the identifier already exists for this participant
*/
@POST
@Path("/{participantId}/identifiers")
public RestResponse<Identifier> create(
@PathParam("participantId") UUID participantId,
@Valid CreateIdentifierRequest request) { ... }

OpenAPI is generated from the resource classes (quarkus-smallrye-openapi) and is the contract for consumers. Because the generated spec is the consumer-facing documentation, every endpoint carries OpenAPI annotations:

  • @Operation(summary = …, description = …) on every endpoint method. The description explains the endpoint’s behaviour and intent in current, accurate terms — not a restated method name. Update it whenever behaviour changes; a stale description is a review finding, same as a wrong comment.
  • @APIResponse for each status code the endpoint can produce (success plus the error statuses from §3), with typed @Schema content.
  • @Parameter / @RequestBody descriptions on inputs whose meaning or format is not obvious from the name and type.
  • Examples@ExampleObject on request/response content, or example on @Schema fields — unless an example is unreasonable in the given context (trivial primitives, binary payloads, pure pass-through wrappers).
  • @Tag on each resource class so endpoints group sensibly in the spec.
@POST
@Path("/{participantId}/identifiers")
@Operation(summary = "Create a legal-entity identifier",
description = "Registers a KVK, LEI or EORI identifier for the participant. "
+ "Idempotent on (participant, type, value).")
@APIResponse(responseCode = "201", description = "Identifier created",
content = @Content(schema = @Schema(implementation = Identifier.class),
examples = @ExampleObject(name = "kvk", value = """
{"type": "KVK", "value": "12345678", "country": "NL"}""")))
@APIResponse(responseCode = "409", description = "Identifier already exists for this participant")
public RestResponse<Identifier> create(...) { ... }
// Calculate expiry date: 24 hours from issuance for VAD tokens
Instant expiresAt = Instant.now().plus(Duration.ofHours(24));
// KvK numbers must be exactly 8 digits (Dutch Chamber of Commerce standard)
private static final Pattern KVK_REGEX = Pattern.compile("^\\d{8}$");

7.1 Unit + Integration Tests (JUnit 5 + RestAssured)

Section titled “7.1 Unit + Integration Tests (JUnit 5 + RestAssured)”
  • Unit tests: pure Java with JUnit 5; mock external collaborators via Mockito where unavoidable.
  • Integration tests: @QuarkusTest with the in-memory database (Dev Services for PostgreSQL via Testcontainers).
  • Aim for >70% line coverage on business logic packages; coverage gates enforced in CI.
@QuarkusTest
class ParticipantResourceTest {
@Test
void create_returns_201() {
given()
.contentType(JSON)
.body(new CreateParticipantRequest("Acme BV", "12345678"))
.when()
.post("/v1/participants")
.then()
.statusCode(201)
.body("name", equalTo("Acme BV"));
}
}

7.2 Authorization tests (mandatory for owner-scoped endpoints)

Section titled “7.2 Authorization tests (mandatory for owner-scoped endpoints)”

Every endpoint that exposes user-owned data MUST have a test proving an unauthorized user cannot reach it. A happy-path test (owner gets 200) is not enough — it never exercises the deny path, so a missing or broken ownership check passes CI silently. The ownership rule of §4.4 is only real if a test defends it.

For each owner-scoped read/modify endpoint, assert at minimum:

  • Owner allowed — the owning caller succeeds (200/204).
  • Same-role non-owner denied — a different caller with the same role is rejected with 403 (this is the test that catches missing object-level checks).
  • Deny before leak — where ordering matters (§4.4), the non-owner gets 403 even when the record is in a state that would otherwise yield 404/409, so existence/state is not leaked.
✓ GOOD:
@Test
@TestSecurity(user = "stranger-read", roles = Roles.REQUESTOR)
void get_asNonOwningRequestor_returns403() {
var requestId = seedOwned("33545678", UUID.randomUUID()); // owned by someone else
given().when().get(PATH + "/" + requestId).then().statusCode(403);
}
@Test
void resubmit_byNonOwnerOfNonChangesRequested_throwsSecurityExceptionNotIllegalTransition() {
// ownership check fires before the state check → 403, not 409 (no state leak)
}
✗ BAD:
@Test
void get_returns200() { // only the owner path — deny path never tested
given().when().get(PATH + "/" + ownRequestId).then().statusCode(200);
}

These tests live next to the resource/shell they protect (e.g. OnboardingRequestResourceTest, OnboardingTransitionsTest) and carry @Tag("requires-database") when they touch the DB.

OpenAPI is the contract. Generate clients from the spec; the API project’s CI compares the generated OpenAPI against the committed openapi.yaml and fails on incompatible changes.

The chosen browser E2E framework is Playwright for Java, driving Chromium, decided in ADR-00015. It is a test-scoped, pinned Maven dependency bound to Maven Failsafe: E2E *IT classes carry @Tag("requires-database") and run under ./mvnw verify on a Docker host alongside the DB integration tests; a Docker-less run (-DskipITs=true) activates the no-docker profile and reports them SKIPPED, never green (mirroring the DB/IT gating). The E2E suite runs after API contract tests pass.

This names the E2E test tooling only; it does not decide the ASR portal frontend stack (still open — see Open Questions). The current E2E coverage drives the throwaway asr-demo-frontend static demo, not a portal SPA.


Follow Conventional Commits:

feat: add KvK verification review workflow
fix: correct timezone handling in date display
docs: update API documentation for identifiers
refactor: standardise naming across participant package
test: add E2E tests for participant creation
chore: bump Quarkus to 3.35.0

Format:

<type>(<scope>): <short description>
<optional longer description>
<optional footer>

Scope is optional but useful (feat(participant): …, fix(token): …).


When writing code, verify:

  • Database columns use snake_case with consistent prefixes.
  • Database timestamps use TIMESTAMP WITH TIME ZONE.
  • API routes use kebab-case for paths; camelCase for path params; snake_case for query params.
  • Java classes/records/interfaces use PascalCase; methods/variables camelCase; constants UPPER_SNAKE_CASE.
  • DTOs are immutable (record); state machines are sealed interfaces.
  • Dates use java.time.*; API serialises as ISO 8601 with Z.
  • SQL is parameter-bound (Panache or @Parameter); no string concatenation of user input.
  • No secrets committed; secrets resolved via Quarkus configuration from the secret store.
  • Endpoints carry @Authenticated / @RolesAllowed per ADR-00008.
  • Owner-scoped endpoints back the role check with a data-dependent ownership check (§4.4), enforced below the resource layer and evaluated before state/existence.
  • Every owner-scoped endpoint has a test proving a same-role non-owner is rejected with 403 (§7.2), not just an owner happy-path test.
  • Cross-module access touches only the seam (module root + dto + exceptions); no production import of another module’s internal.., not even in a {@link} (§5.2, ADR-00022).
  • Error responses follow RFC 9457 (Problem Details).
  • Public REST methods have Javadoc explaining behaviour and contracts.
  • OpenAPI passes the CI compatibility check.
  • Test coverage on the touched package is at or above the 70% threshold.

  • Frontend stack: the ASR portals (admin, participant, data-consumer) need a chosen frontend stack. Candidates discussed include the existing React + TypeScript prototype, a server-rendered Quarkus + Qute approach, or a fully framework-agnostic web-component approach. A separate ADR is expected.
  • OpenAPI versioning: how do we evolve /v1/v2? Deprecation policy and concurrent-version support remain TBD.

Questions or clarifications? See AGENTS.md or ask in project discussions.

In samenwerking met

Connected Trade NetworkConclusionData in LogisticsContargoInland Terminals GroupVan Berkel