En esta guía (22 elementos)
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
Orderdomain andcom.example.apppackage — 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.4Two backbones:
- 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). - 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/managersmodules/<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 callDbUtils.buildDbClientinside 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 hood4.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 withvertx.executeBlocking(...)or a worker verticle. - Compose, don’t nest.
flatMapfor dependent async steps,mapfor pure transforms,CompositeFuture.all/anyfor fan-out,recoverfor fallback,eventuallyfor cleanup. - Propagate failure, don’t swallow. Return the
Future; let it fail up to the handler’sonFailure. A bareonFailure(log)that doesn’t rethrow hides errors — only the handler terminates the chain into an HTTP response. onSuccess/onFailureare side-effect observers (log/metric), not control flow. Transform withmap/flatMap.
// ✅ dependent steps compose linearlyreturn orderRepository.findById(cid, conn, id) .flatMap(order -> paymentService.authorize(order)) .map(OrderResponse::new);
// ❌ callback pyramid / blockingvar order = orderRepository.findById(...).result(); // blocks event loop — forbidden6. 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; passconndown to every repository call so they share it. Usepool.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+srvconnection strings useMongoClient.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;ClientExceptionfor downstream failures. - Never
catch (Exception)and continue silently; let theFuturefail to the handler, which callsResponseUtils.buildErrorResponse. - Map DB constraint codes centrally (e.g.
23505 → 409), don’t leakPgException. - Responses are localized (
Localefrom the i18n handler).
9. Validation & security
- Validation handlers run before the business handler (per route), one per
operation (
validation.readOne(), versioned undervalidation/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-rollif (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.c → A_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 viaDatabindCodec). Configure the sharedDatabindCodec.mapper()once at startup (JSR-310, no-timestamps, ignore-unknown). Do not allocate anew 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(...)inAbstractHandler). - Health:
vertx-health-checkendpoints 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
traceparentwhen 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 outsidetestContext.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
@Disabledtests in main — fix or delete.
13. Build & deploy
- Gradle Kotlin DSL,
shadowJarproduces a singleapp-all.jar(mergeServiceFiles(), exclude key material*.jks/*.pem). - Main class: the custom
Launcher; run asjava -jar app.jar run com....MainVerticle. - Spotless + SpotBugs must pass before commit.
- Container:
eclipse-temurin:17-jrebase; copy the fat jar; set the SLF4J log delegate. Run as non-root and mount secrets read-only (k8s) — add a non-rootUSERto 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; onewithConnection/withTransactionper use case. - Repository uses parameterized SQL +
Tuple; SQL asstatic finalconstants. - EventBus topics are enum constants; consumers reply/fail on
request. - Errors are typed exceptions mapped centrally; no silent catches; no
PgExceptionleaks. - Validation handler covers the new route; authZ policy set (not
publicby accident). - Config via
ConfigUtils; secrets from env/volumes; nothing committed. - vertx-junit5 test with Testcontainers; assertions in
verify; no@Disabledleft behind. - Spotless + SpotBugs clean; fat jar builds; container runs non-root.
- No tokens/PII in logs;
correlationIdpropagated.
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.