Skip to content
My Guidelines
Guides
In this guide (22 items)

Eclipse Vert.x

Eclipse Vert.x — Reactive Layered + Event-Driven Guideline

House standard for JVM services built on Eclipse Vert.x 4.x. This guideline codifies the layered N-tier reactive + event-driven style for production Vert.x services. It is descriptive of that architecture, not a hexagonal port — Vert.x services here are organized by technical layer and glued by the EventBus, not by ports-and-adapters.

Examples use a neutral Order domain and com.example.app package — they are illustrative, not lifted from any specific service.

Where this conflicts with the hexagonal guidelines (no ports, framework types in the service layer, anemic models), that is intentional: Vert.x services optimize for the reactor model and throughput. Apply hexagonal-architecture.md only when a bounded context grows a rich domain worth isolating.

1. Stack (locked baseline)

Concern Choice Notes
Language Java 17 records for DTOs allowed; no Kotlin in this stack
Runtime Vert.x 4.5.x modern Future API (no callbacks), optional vertx-rx-java3
HTTP vertx-web, vertx-web-client, vertx-web-validation Router DSL + WebClient
Persistence vertx-pg-client (primary) + vertx-mongo-client + vertx-redis-client dual store; raw SQL, no ORM
Migrations Flyway (separate Gradle module) forward-only, V<timestamp>__desc.sql
Auth vertx-auth-jwt JWKS + role-based authorization
Resilience vertx-circuit-breaker wrap flaky outbound calls
Metrics vertx-micrometer-metrics + vertx-health-check Prometheus registry
JSON Vert.x Json/JsonObject (Jackson under the hood) see §10 — prefer DatabindCodec, never new ObjectMapper() per call
Build Gradle Kotlin DSL + Shadow fat jar com.github.johnrengelman.shadow
Quality Spotless + SpotBugs run before commit
Test vertx-junit5 + Testcontainers VertxTestContext, real Pg/Mongo
Container eclipse-temurin:17-jre fat jar, run com....MainVerticle

2. Architecture style

┌──────────── EventBus (in-process) ────────────┐
│ audit · metrics · async offload to workers │
└───────▲───────────────────────────▲───────────┘
│ send/publish │ consumer
HTTP ──► Router ──► Handler ─┼─► Service ──► Repository ──┼─► Pg / Mongo / Redis
(DSL) (RoutingCtx) (Future chain) (raw SQL/Tuple)
§4.1 §4.2 §4.3 §4.4

Two backbones:

  1. Synchronous request path (N-tier): Router → Handler → Service → Repository → Model. Strict top-down dependency; a layer only calls the one below. No layer-skipping (handler never touches a repository).
  2. Asynchronous event path (event-driven): the EventBus decouples cross-cutting concerns (audit, metrics) and offloads heavy work to worker Verticles via named topics. In-process only — not an external broker.

Deployment is a modular monolith: one fat jar, multiple Verticles. Feature modules under modules/ mirror the core layering and carry their own MainVerticle so they can be extracted to a standalone service later.

Package layout (by technical layer, then feature)

com.example.app.core
├── verticle/ # bootstrap + HTTP server + workers/
├── api/
│ ├── router/ # route definitions (extend AbstractApiRouter)
│ ├── handler/ # HTTP entry (extend AbstractHandler)
│ ├── validation/ # request validation handlers (v2/ for versioned)
│ ├── service/ # business logic (extend AbstractService)
│ ├── repository/ # data access (concrete classes) + mapper/
│ └── model/ # DTOs: <feature>/{request,response,enums}
├── client/ # outbound integrations (WebClient wrappers)
├── exceptions/ # exception hierarchy + HandlerError mapper
├── enums/ # EventBusTopic, error codes
├── utils/ # ConfigUtils, DbUtils, ResponseUtils, RequestUtils
└── configuration/ # shared client/managers
modules/<feature>/ # self-contained feature (handler→service→repository→router + MainVerticle)

3. Bootstrap & Verticles

A custom Launcher sets the main verticle; MainVerticle builds shared clients once and deploys the rest. Clients (Pool, MongoClient, RedisAPI) are built once and passed by constructor into every Verticle — never rebuilt per request.

public class MainVerticle extends AbstractVerticle {
@Override
public void start() {
final Pool dbClient = DbUtils.buildDbClient(vertx);
final MongoClient mongoClient = DbUtils.buildMongoClient(vertx);
final RedisAPI redisClient = RedisAPI.api(DbUtils.buildRedisClient(vertx));
deployApiVerticle(vertx, dbClient, mongoClient, redisClient, /* ... */)
.onSuccess(id -> logger.info("API verticle started in {} ms", elapsed()))
.onFailure(Throwable::printStackTrace);
deployWorkerVerticle(vertx, dbClient, mongoClient, redisClient, /* ... */);
}
private Future<String> deployApiVerticle(Vertx vertx, Pool db, /* ... */) {
return vertx.deployVerticle(new ApiVerticle(db, /* ... */));
}
}

ApiVerticle owns the HTTP server and the root router. Global handlers run before routes; error handlers map status codes; feature routers mount via mount(router).

final Router router = Router.router(vertx);
router.route()
.handler(BodyHandler.create())
.handler(loggingHandler::handler) // correlationId + request log
.handler(i18nHandler) // resolve Locale
.failureHandler(errorHandler::handleFailure);
router.errorHandler(500, errorHandler::handleError500);
router.errorHandler(400, errorHandler::handleError400);
orderRouter.mount(router);
userRouter.mount(router); // one feature router per resource
vertx.createHttpServer(options).requestHandler(router).listen(port, host);

Rules

  • One HTTP ApiVerticle; deploy event-loop count = CPU cores (Vert.x default).
  • Blocking / CPU-heavy work goes to a worker Verticle (§6), never the API verticle.
  • Build shared clients in MainVerticle.start(); inject downward. Never call DbUtils.buildDbClient inside a handler/service.

4. Layer responsibilities

4.1 Router — extend AbstractApiRouter

Declarative routes only. Each route = (path, policy, validationHandler, handlerMethod). mount() calls init() then mountSubRouter(basePath, router). Authorization policy is a role string; "public"/"*" skips role checks.

public class OrderRouter extends AbstractApiRouter {
public OrderRouter(Vertx vertx, OrderHandler handler, OrderValidationHandler validation,
JwkHandler jwtAuth, AuthorizeResourceHandler authz) {
super(vertx, jwtAuth, authz, "/api/v1/orders");
this.orderHandler = handler;
this.validation = validation;
}
@Override
protected void init() {
get("/", "order.read", validation.readAll(), orderHandler::readAll);
post("/", "order.write", validation.create(), orderHandler::create);
get("/:id", "order.read", validation.readOne(), orderHandler::readOne);
put("/:id", "order.write", validation.update(), orderHandler::update);
}
}

Authorization is built once per route from the policy string:

public AuthorizationHandler authorize(String policy) {
var roleAuthorization = OrAuthorization.create();
if (!policy.equals("*")) {
roleAuthorization.addAuthorization(RoleBasedAuthorization.create(policy));
}
return AuthorizationHandler.create(roleAuthorization)
.addAuthorizationProvider(JWTAuthorization.create("realm_access/roles"));
}

4.2 Handler — extend AbstractHandler

Inbound adapter. Reads RoutingContext, extracts/parses params, calls one service method, writes the response via ResponseUtils, and routes failure to ResponseUtils.buildErrorResponse. No business logic, no SQL, no EventBus business calls here — only request/response translation, audit, and metrics.

public Future<OrderListResponse> readAll(RoutingContext rc) {
final UUID correlationId = RequestUtils.getCorrelationId(rc);
final String status = rc.queryParams().get(Constants.STATUS);
final int offset = RequestUtils.intParam(rc, Constants.OFFSET_PARAMETER, 0);
final int limit = RequestUtils.intParam(rc, Constants.LIMIT_PARAMETER, 100);
return orderService
.readAll(correlationId, status, offset, limit)
.onSuccess(result -> ResponseUtils.buildOkResponse(rc, result))
.onFailure(t -> ResponseUtils.buildErrorResponse(rc, t));
}

Cross-cutting via EventBus lives in the base class (fire-and-forget):

public void auditMessage(JsonObject message) {
eventBus.send(Constants.INPUT_AUDIT_EVENT, message);
}

4.3 Service — extend AbstractService

Owns business logic and the unit of work. Composes Futures; opens a connection via pool.withConnection(...) so all repository calls in one use case share a transaction-scoped connection. Returns DTOs, never RoutingContext.

public Future<OrderListResponse> readAll(UUID cid, String status, int offset, int limit) {
final List<String> statuses = (status == null)
? List.of("NEW", "PAID", "CANCELLED") : List.of(status);
return dbClient.withConnection(conn -> orderRepository
.count(cid, conn, statuses)
.flatMap(total -> orderRepository
.selectAll(cid, conn, statuses, limit, offset)
.map(rows -> {
var orders = rows.stream().map(OrderResponse::new).toList();
return new OrderListResponse(total, limit, offset, orders);
})))
.onFailure(t -> LOGGER.error("readAll failed cid={} {}", cid, t.getMessage()));
}

Large reads chunk to bound memory and pool pressure:

return AbstractService.fetchAll(totalLimit, (off, lim) ->
orderRepository.selectPage(cid, conn, off, lim)); // CompositeFuture.all under the hood

4.4 Repository — concrete class

Raw parameterized SQL with Tuple; map rows with a dedicated mapper function. Always parameterized ($1,$2) — never string concatenation. Column-level crypto handled in SQL (pgp_sym_decrypt).

private static final String SQL_SELECT_ALL_ORDERS =
"SELECT id, reference, status, total FROM orders WHERE status = ANY($1) LIMIT $2 OFFSET $3";
public Future<List<Order>> selectAll(UUID cid, SqlConnection conn,
List<String> status, int limit, int offset) {
return conn.preparedQuery(SQL_SELECT_ALL_ORDERS)
.execute(Tuple.of(status.toArray(), limit, offset))
.map(orderMapper::apply)
.onFailure(t -> LOGGER.error("selectAll failed cid={}", cid, t));
}

Layer rules (enforced in review)

Rule Why
Handler ↔ Service ↔ Repository only; no skipping keeps the N-tier invariant
Service returns DTOs, never RoutingContext/HttpServerResponse testable without HTTP
All repo calls in one use case share withConnection single transaction scope
Repositories are stateless; SQL constants private static final reuse, no per-call allocation
Models are DTOs (request/response); validation lives in validation handlers clear request contract
One service method per handler method thin handlers, traceable flow

5. Reactive patterns (Future API)

  • Never block the event loop. No JDBC, Thread.sleep, .result(), or busy CPU on an event-loop verticle. Use the async Vert.x clients; offload blocking libraries with vertx.executeBlocking(...) or a worker verticle.
  • Compose, don’t nest. flatMap for dependent async steps, map for pure transforms, CompositeFuture.all/any for fan-out, recover for fallback, eventually for cleanup.
  • Propagate failure, don’t swallow. Return the Future; let it fail up to the handler’s onFailure. A bare onFailure(log) that doesn’t rethrow hides errors — only the handler terminates the chain into an HTTP response.
  • onSuccess/onFailure are side-effect observers (log/metric), not control flow. Transform with map/flatMap.
// ✅ dependent steps compose linearly
return orderRepository.findById(cid, conn, id)
.flatMap(order -> paymentService.authorize(order))
.map(OrderResponse::new);
// ❌ callback pyramid / blocking
var order = orderRepository.findById(...).result(); // blocks event loop — forbidden

6. Event-driven (EventBus + workers)

Use the EventBus to (a) decouple cross-cutting concerns and (b) offload long-running work off the request path. Topics are typed constants, never inline strings.

public final class EventBusTopic {
interface Topic { String getTopic(); }
public enum ORDERS implements Topic {
BULK_IMPORT("orders.bulk.import");
private final String topic;
ORDERS(String topic) { this.topic = topic; }
@Override public String getTopic() { return topic; }
}
}

A worker Verticle owns its repositories/services and registers consumers in start(). Deploy with setWorker(true) and a bounded pool.

DeploymentOptions options = new DeploymentOptions()
.setWorker(true)
.setWorkerPoolSize(10)
.setMaxWorkerExecuteTime(5).setMaxWorkerExecuteTimeUnit(TimeUnit.SECONDS);
vertx.deployVerticle(new BulkImportWorkerVerticle(dbClient, mongoClient, /* ... */), options);
// inside BulkImportWorkerVerticle.start():
eventBus.consumer(ORDERS.BULK_IMPORT.getTopic(), importHandler::handleBulkImport);
Pattern API Use when
Fire-and-forget eventBus.send(topic, msg) audit, metrics, no reply needed
Broadcast eventBus.publish(topic, msg) all consumers react
Request/reply eventBus.request(topic, msg)Future<Message> need a result back
Offload heavy work worker verticle + consumer bulk imports, report generation, batch processing, external sync

Rules

  • Messages cross the bus as JsonObject/String (codec-friendly). Register a codec for custom types; don’t ship framework objects.
  • In-process bus = at-most-once, no durability. For exactly-once / cross-service delivery use a real broker + the outbox pattern — the EventBus is not a substitute.
  • A consumer must reply or fail explicitly on request/reply, else the sender times out.

7. Persistence

PostgreSQL is primary (relational, Flyway-migrated); MongoDB holds document-shaped collections (e.g. notifications); Redis for cache/ephemeral. Build pools once in DbUtils:

final PgConnectOptions connectOptions = new PgConnectOptions()
.setHost(host).setPort(port).setDatabase(db).setUser(user).setPassword(password);
final PoolOptions poolOptions = new PoolOptions()
.setMaxSize(maxPoolSize).setIdleTimeout(maxIdle).setIdleTimeoutUnit(TimeUnit.SECONDS);
return PgBuilder.pool().with(poolOptions).connectingTo(connectOptions).using(vertx).build();
  • Unit of work: pool.withConnection(conn -> ...) per use case; pass conn down to every repository call so they share it. Use pool.withTransaction(...) when multiple writes must commit atomically.
  • Migrations: Flyway in a dedicated module; forward-only; V<timestamp>__<desc>.sql; never edit an applied migration (see liquibase.md for the same immutability rule applied to Flyway).
  • Secrets in columns: encrypt at the SQL boundary (pgp_sym_encrypt/ pgp_sym_decrypt), key injected as a bind param — never logged.
  • Mongo: MongoClient.createShared(vertx, config) for pool reuse; mongodb+srv connection strings use MongoClient.create.

8. Error handling

Typed exception hierarchy → single mapper → HTTP. Services throw domain exceptions; the router’s failure handler converts them.

public static ErrorResponse getErrorResponse(Throwable t, Locale locale) {
if (t instanceof BusinessException be) return new ErrorResponse(be.gHttpResponseError(), be.getCode(), ...);
if (t instanceof ClientException ce) return new ErrorResponse(ce.gHttpResponseError(), ...);
if (t instanceof PgException pg) return handlePgException(pg); // 23505 → UNIQUE_VIOLATION
if (t instanceof EntityNotFoundException) return new ErrorResponse(HttpResponseError.ENTITY_NOT_EXIST, ...);
if (t instanceof IllegalArgumentException) return new ErrorResponse(HttpResponseError.ILLEGAL_ARGUMENT_PARAMETERS, ...);
return new ErrorResponse(HttpResponseError.INTERNAL_SERVER_ERROR, ...);
}
  • Throw BusinessException (with an HTTP-mapped error enum) for rule violations; ClientException for downstream failures.
  • Never catch (Exception) and continue silently; let the Future fail to the handler, which calls ResponseUtils.buildErrorResponse.
  • Map DB constraint codes centrally (e.g. 23505 → 409), don’t leak PgException.
  • Responses are localized (Locale from the i18n handler).

9. Validation & security

  • Validation handlers run before the business handler (per route), one per operation (validation.readOne(), versioned under validation/v2/). Reject malformed input at the edge; the handler assumes valid input.
  • AuthN: JWT via JWKS (JwkHandler); reject unauthenticated on protected routes.
  • AuthZ: role-based from the route policy string (RoleBasedAuthorization + JWTAuthorization.create("realm_access/roles")), plus resource-level checks (AuthorizeResourceHandler). Never hand-roll if (role.equals(...)) in handlers.
  • Transport: TLS by default; mTLS optional via server key/cert config.
  • Never log tokens, PII, decrypted secrets, or full request bodies. Log with correlationId.

10. Configuration & JSON

ConfigUtils loads application.properties then overrides with env vars (a.b.cA_B_C) in non-dev, and transparently decrypts ENC(...) values (AES/CBC) using BASE64_KEY/BASE64_IV.

String host = ConfigUtils.getStringProperty(Constants.HTTP_HOST);
boolean mtls = ConfigUtils.getBooleanProperty(Constants.MTLS_SERVER_ENABLED);
  • Read config once at bootstrap where practical; avoid per-request lookups in hot paths.
  • Secrets come from env/k8s secrets/volume-mounted keystores — never committed.
  • JSON: prefer Vert.x-native Json.encode/JsonObject.mapFrom (Jackson via DatabindCodec). Configure the shared DatabindCodec.mapper() once at startup (JSR-310, no-timestamps, ignore-unknown). Do not allocate a new ObjectMapper() per call. (This stack pins Jackson 2.x; the JsonMapper-only rule from java-code-style.md applies to Jackson 3.x stacks.)

11. Observability

  • Metrics: vertx-micrometer-metrics → Prometheus. Domain metrics emitted off the request path via EventBus (sendMetricIncrement(...) in AbstractHandler).
  • Health: vertx-health-check endpoints for liveness/readiness (DB ping).
  • Logging: SLF4J + Logback, structured, always carry correlationId (extracted in the logging handler, propagated through service/repository logs).
  • Tracing: propagate W3C traceparent when calling downstream via WebClient.

12. Testing

vertx-junit5 + VertxTestContext; real dependencies via Testcontainers (PostgreSQL, MongoDB). Async assertions complete the context explicitly.

@ExtendWith(VertxExtension.class)
class OrderApiTest extends AbstractApiTest {
@Test
void readAll_returns200(Vertx vertx, VertxTestContext testContext) {
WebClient.create(vertx)
.get(PORT, "localhost", "/api/v1/orders")
.bearerTokenAuthentication(token)
.as(BodyCodec.jsonObject())
.send(testContext.succeeding(res -> testContext.verify(() -> {
assertEquals(200, res.statusCode());
testContext.completeNow();
})));
}
}
  • Deploy the verticle once in @BeforeAll(Vertx, VertxTestContext); share clients.
  • Use testContext.succeeding(...)/failing(...) + completeNow() / failNow(); never assert outside testContext.verify(...) (assertion errors inside raw callbacks are swallowed).
  • Testcontainers for DB; no mocks for repositories (test real SQL). Mock only outbound HTTP clients (mockito-inline) when the remote is out of scope.
  • Set a per-test timeout; don’t leave @Disabled tests in main — fix or delete.

13. Build & deploy

  • Gradle Kotlin DSL, shadowJar produces a single app-all.jar (mergeServiceFiles(), exclude key material *.jks/*.pem).
  • Main class: the custom Launcher; run as java -jar app.jar run com....MainVerticle.
  • Spotless + SpotBugs must pass before commit.
  • Container: eclipse-temurin:17-jre base; copy the fat jar; set the SLF4J log delegate. Run as non-root and mount secrets read-only (k8s) — add a non-root USER to the Dockerfile if missing.
  • Config via env in k8s (ConfigMap + Secrets); keystores/certs as read-only volume mounts.

14. Naming conventions

Element Convention Example
Router <Feature>Router extends AbstractApiRouter OrderRouter
Handler <Feature>Handler extends AbstractHandler OrderHandler
Service <Feature>Service extends AbstractService OrderService
Repository <Feature>Repository (concrete) OrderRepository
Mapper <Feature>Mapper (a Function<Row, T>), in mapper/ orderMapper
Request DTO <Feature><Action>Request OrderCreateRequest
Response DTO <Feature><Action>Response OrderListResponse
EventBus topic EventBusTopic.<GROUP>.<ACTION> ORDERS.BULK_IMPORT
SQL constant private static final String SQL_<VERB>_<NOUN> SQL_SELECT_ALL_ORDERS
Base path /api/v1/<plural> /api/v1/orders

15. Anti-patterns

❌ Anti-pattern ✅ Instead
Blocking call on event loop (.result(), JDBC, Thread.sleep) async client, executeBlocking, or worker verticle
Business logic / SQL in a handler handler only translates; logic in service
Handler calls a repository directly go through the service layer
Inline EventBus topic strings EventBusTopic enum constant
new ObjectMapper() per request shared DatabindCodec.mapper() configured once
Swallowing a Future failure (onFailure(log) then continue) return the Future; fail to the handler
Rebuilding Pool/MongoClient/WebClient per call build once at bootstrap, inject down
String-concatenated SQL parameterized preparedQuery + Tuple
Assertions outside testContext.verify wrap in verify, then completeNow
EventBus used as a durable/cross-service queue external broker + outbox
Hand-rolled role checks in handlers route policy + RoleBasedAuthorization
Logging tokens/PII/decrypted secrets log correlationId only

16. Pre-merge checklist

  • No blocking on the event loop; heavy work on a worker verticle.
  • Handler thin: parse → one service call → ResponseUtils (success + failure).
  • Service composes Futures; one withConnection/withTransaction per use case.
  • Repository uses parameterized SQL + Tuple; SQL as static final constants.
  • EventBus topics are enum constants; consumers reply/fail on request.
  • Errors are typed exceptions mapped centrally; no silent catches; no PgException leaks.
  • Validation handler covers the new route; authZ policy set (not public by accident).
  • Config via ConfigUtils; secrets from env/volumes; nothing committed.
  • vertx-junit5 test with Testcontainers; assertions in verify; no @Disabled left behind.
  • Spotless + SpotBugs clean; fat jar builds; container runs non-root.
  • No tokens/PII in logs; correlationId propagated.

Style: layered N-tier reactive + event-driven (Eclipse Vert.x 4.x). For rich-domain contexts that warrant ports-and-adapters, see hexagonal-architecture.md; for reliable cross-service eventing see messaging.md.