Core
View MarkdownStatus: Stable Package:
dev.vertique.coreArtifact:vertique-coreDepends on: nothing — this is the framework root API reference: javadoc.io
vertique-core is the vocabulary every other Vertique module is written against. It supplies the
Vert.x dependency-injection seam, the framework exception hierarchy, the typed-configuration
contract, the extension-ordering and lifecycle contracts, the event-bus dispatch types, and the
shared annotation vocabularies for validation, sanitization, and JSON profiles.
Core is almost entirely declaration. Most of what it defines is implemented elsewhere: the
context holder ships in dev.vertique:vertique-context, the config parser in
dev.vertique:vertique-config-core, bean validation in dev.vertique:vertique-validation, health
aggregation in dev.vertique:vertique-management. Depending on core gives an application the
contracts and the annotations, not the runtime behind them.
When To Use It
Section titled “When To Use It”Every Vertique application already depends on vertique-core transitively — it arrives with any
starter or feature module. Depend on it directly when:
- you are writing a Dagger module that binds
Vertx,@VertxConfig JsonObject, orEventBus; - you throw or catch framework exceptions and want the semantic roots that drive HTTP mapping;
- you parse a configuration section into a typed record through
ConfigParser; - you implement a framework extension point declared here — a health check, a lifecycle step, a
JsonMapperProfile, or a canonicalizer; - you annotate an operation with
@ValidateWith,@Canonicalize,@Sanitize, or@JsonProfile.
Pair core with the module that implements the contract you are using: vertique-config-core for
ConfigParser, vertique-validation for BeanValidator, vertique-json for the JSON profile
registry, vertique-context and vertique-correlation for context propagation,
vertique-management for health-check aggregation, vertique-services for typed event-bus service
calls.
Core Concepts
Section titled “Core Concepts”Asynchronous timeouts
Section titled “Asynchronous timeouts”dev.vertique.core.async.Timeouts provides shared timeout boundaries for asynchronous work:
awaitCondition(vertx, condition, pollIntervalMs, timeoutMs, operation)polls a condition on a Vert.x timer, completing when it becomes true or failing withTimeoutExceptionat the deadline.withTimeout(future, timeoutMs, description)returns the future’s result or failure, bounded by the timeout.
Both timeout values must be positive. A timeout does not cancel the underlying asynchronous operation, so the caller still owns cleanup and failure policy. For example, a consumer can report the timeout while its close future continues to settle in the background.
The graph-input seam
Section titled “The graph-input seam”Every bootstrap path funnels two values into the application’s Dagger graph: the Vertx instance
and the root configuration JsonObject. VertiqueRuntime carries exactly those two, and
VertiqueComponentFactory<C> turns them into the built component. Nothing host-specific crosses
this seam — no ApplicationContext, no service locator. A host that embeds Vertique adapts its own
beans through ordinary typed Dagger modules, so a missing adapter is a compile-time missing-binding
error rather than a runtime lookup failure.
VertxModule is the Dagger module on the other side of the seam: constructed with the runtime’s two
values, it publishes them plus the event bus and the low-level event-bus client.
Configuration is parsed at the boundary
Section titled “Configuration is parsed at the boundary”Configuration is read only in @Provides methods, which parse a section into a typed record whose
compact constructor validates it — so a bad value fails Dagger component construction rather than
first use. Module internals depend on the typed record, never on the raw JsonObject. See
Configuration.
Two ordering contracts, deliberately unrelated
Section titled “Two ordering contracts, deliberately unrelated”OrderedExtension orders behavioral extensions (interceptors, customizers, observers).
LifecycleOrdered orders lifecycle participants (startup and shutdown steps, verticle
deployments). Both sort phase ascending → priority ascending → orderKey ascending, and both
expose a static comparator(). They are separate interfaces because their phase() methods return
different enums — ExtensionPhase and LifecyclePhase.
The rules an extension author must know:
- Phase dominates priority absolutely. An
APPLICATIONextension withpriority = Integer.MIN_VALUEstill runs after everySYSTEM_FIRSTextension. Do not try to emulate cross-phase ordering with priority. - Lower
priority()runs first; the default is0. orderKey()defaults togetClass().getName(). It is the final tie-break, so ordering is deterministic across JVM restarts only when the key is unique per registered instance. Two instances of the same class tie; a lambda or method-reference registration gets a synthetic class name that is not stable across compilations. Give such participants distinctpriority()values or overrideorderKey().ExtensionPhaseis not a security boundary. Any module on the classpath can declareSYSTEM_FIRST. The phase confers ordering, not privilege.
Lifecycle phases
Section titled “Lifecycle phases”LifecyclePhase is the single phase vocabulary, in declaration (ordinal) order:
| Phase | Kind | Purpose |
|---|---|---|
CONFIGURE |
Non-verticle | Build runtime configuration (e.g. install Jackson modules) before any deployment |
VALIDATE |
Non-verticle | Validate assembled configuration and wiring before any deployment |
MIGRATE |
Non-verticle | Run data/schema migrations before any deployment |
BOOTSTRAP |
Verticle | Framework bootstrapping (codec registration, config watchers) |
INFRA |
Verticle | Infrastructure verticles (management, health endpoints) |
SERVICES |
Verticle | Service-layer verticles (event-bus dispatch) |
EDGE |
Verticle | Edge verticles (HTTP, WebSocket) |
AFTER_START |
Non-verticle | Post-start work, after all verticles are deployed |
isVerticlePhase() is true only for BOOTSTRAP, INFRA, SERVICES, and EDGE.
Startup interleaves per phase: for each phase in order, that phase’s startup steps run
sequentially in comparator order and fail-fast, and only then are that phase’s verticles deployed.
A step declared in SERVICES therefore runs before the SERVICES verticles, not after them.
Shutdown runs the shutdown steps of the phases whose startup completed, in reversed comparator
order, best-effort.
How a swallowed observer failure is logged
Section titled “How a swallowed observer failure is logged”Observer-style callbacks — observers, listeners, hooks and completion scopes whose contract says
their exceptions are “caught, logged, and swallowed” — are all reported through one
framework-internal class, dev.vertique.core.extension.ObserverFailureReporter. It is public only
so that the framework’s other modules can share it; applications do not use it. What it means for
an implementer of such a callback:
- An
Exception,AssertionErrororStackOverflowErroris logged at WARN every time, naming the observer class, the callback and the failure’s class only. The failure itself, with its message and stack, is logged at DEBUG, because an exception message can contain payload text. - A
LinkageErrormeans the callback cannot run at all. It is logged at ERROR with the failure the first time it is seen for an observer class and callback, then at most once every five minutes (ObserverFailureReporter.REPORT_INTERVAL) with the number of failures in between. It is never silenced for good. - What happens to any other
Erroris stated by each callback’s own contract; most sites let it propagate.
Exception semantics
Section titled “Exception semantics”Framework and application exceptions extend a semantic root from dev.vertique.core.exception
rather than raw RuntimeException. The root chosen is what determines the HTTP status a REST
boundary produces, so picking the most specific applicable root is the whole mechanism — see
Failures, Constraints, and Common Mistakes.
Context values
Section titled “Context values”A value that must ride along with a request or a durable dispatch implements the marker interface
ContextValue. That marker gates the write path: ContextHolder.bind is
<T extends ContextValue>, while current(Class<T>) is deliberately unbounded so anything stored
can be read back. The five context-producing SPIs carry the same bound on their context-type
parameter. The holder implementation and the encoder/decoder registries ship in
dev.vertique:vertique-context.
Scope vocabulary
Section titled “Scope vocabulary”ScopeKey and ScopeContext live in dev.vertique.core.scope. Applications use a key to name a
data or execution partition — a tenant, project, workspace, or organization supplies the meaning.
The key constructor rejects null with NullPointerException and other invalid text with
IllegalArgumentException. A valid key has 1–255 Unicode code points, no ISO controls, no unpaired
surrogate, no format character, at least one non-whitespace code point, and no leading or trailing
whitespace (Character.isWhitespace or Character.isSpaceChar). Internal whitespace other than
controls (including U+00A0, U+2028, and U+2029) is accepted. Construction preserves the exact
string; equality uses that exact text. ScopeKey.DEFAULT equals new ScopeKey("default") and
names an ordinary partition — it confers no privilege, fallback, or automatic binding.
ScopeContext wraps one non-null ScopeKey and implements ContextValue, so callers may bind it
explicitly through the existing holder API. Construction itself installs no ambient state. When the
application’s existing context runtime includes ContextRuntimeModule from vertique-context, its
default durable codec pair carries an explicitly bound scope unless an explicit durable contribution
claims the scope type or namespace. When active, the built-in pair captures at WORKFLOW, OUTBOX,
and DELAYED_JOB, and decodes at WORKFLOW, OUTBOX_SERVICE, and DELAYED_JOB; it does neither
at KAFKA or any other identifier. The pair does not affect generic snapshots or service-dispatch
propagation. When the built-in pair is active, a raw scope body already in a Kafka carrier remains
untyped and is not decoded; the existing authoritative ingress still clears a prior ambient scope for
its lifetime, and closing its holder scope restores that binding. Applications that need scope at
Kafka bind it after ingress.
On a later workflow drive, the caller’s ambient scope or valid explicit scope metadata can override the saved scope for that drive without changing the saved carrier or workflow scope fields. The saved value is not an immutable partition pin; applications that require one partition for an entire instance must enforce that policy before supplying or accepting context. Neither type asserts trust or access, and scope never selects a workflow. Key text is not neutralized for logging, so consumers encode values for their sink.
Key Classes
Section titled “Key Classes”VertxModule
Section titled “VertxModule”Dagger @Module constructed with the Vert.x instance and the root configuration; list it on the
application @Component and supply it through the generated builder. Bindings provided:
| Type | Qualifier | Description |
|---|---|---|
Vertx |
— | The Vert.x instance passed at construction |
JsonObject |
@VertxConfig |
The root application configuration |
EventBus |
— | vertx.eventBus() |
EventBusExceptionMapper |
— | Translates Vert.x ReplyExceptions into typed exceptions |
EventBusClient |
— | Low-level dispatch transport |
VertiqueRuntime and VertiqueComponentFactory<C>
Section titled “VertiqueRuntime and VertiqueComponentFactory<C>”public record VertiqueRuntime(Vertx vertx, JsonObject config) { public static VertiqueRuntime of(Vertx vertx, JsonObject config);}
@FunctionalInterfacepublic interface VertiqueComponentFactory<C> { C build(VertiqueRuntime runtime);}
VertiqueComponentFactory<AppComponent> factory = rt -> DaggerAppComponent.builder() .vertxModule(new VertxModule(rt.vertx(), rt.config())) .build();
AppComponent component = factory.build(VertiqueRuntime.of(vertx, config));Both record components are required: the compact constructor calls Objects.requireNonNull on
each, so a null throws at construction rather than surfacing later as a missing binding. Prefer
the static of factory, which delegates to the canonical constructor.
FailureMapper
Section titled “FailureMapper”Concrete, hierarchy-aware, context-aware Throwable → Throwable translator registry. Usable
directly for ad-hoc translation, and the base class the layer mappers in dev.vertique:vertique-rest-jaxrs,
dev.vertique:vertique-services, and dev.vertique:vertique-db-core extend.
public class FailureMapper { public FailureMapper();
public <T extends Throwable> FailureMapper on(Class<T> type, FailureTranslator<T> translator); public <T extends Throwable> FailureMapper on(Class<T> type, ContextAwareFailureTranslator<T> translator);
public Throwable translate(Throwable throwable, String context); public Throwable translate(Throwable throwable); // context defaults to throwable.getMessage()
public FailureTranslator<?> findTranslator(Class<? extends Throwable> exceptionClass);
protected Throwable fallback(Throwable throwable, String context);}var mapper = new FailureMapper();mapper.on(DatabaseException.class, (e, ctx) -> new DataAccessException(ctx, e));
Throwable translated = mapper.translate(sqlFailure, "save user");Invariants & Gotchas
Section titled “Invariants & Gotchas”- Lookup walks the superclass chain and stops below
Object, so the most specific registered translator wins. Interfaces are not consulted. - Registration is last-wins and clears the whole lookup cache, which is what lets a later, more specific registration override an already-resolved broader one.
findTranslatorreturnsnull— not a no-op translator — when nothing in the hierarchy matches.fallbackis whattranslatereturns instead; the default returns the throwable unchanged.translatedispatches on the runtime type, so behavior is identical regardless of whichon(...)overload registered the translator. A two-argument lambda binds to the context-aware overload; a one-argument lambda binds to the plain one.
Result<T>
Section titled “Result<T>”Sealed success/failure monad used for event-bus replies. Implementations are the records
Result.Success<T> and Result.Failure<T>.
public sealed interface Result<T> { static <T> Result<T> success(T value); static <T> Result<T> failure(Throwable cause);
boolean isSuccess(); boolean isFailure(); T get(); // throws NoSuchElementException on a failure Optional<T> toOptional(); Throwable cause(); // null on a success <U> Result<U> map(Function<T, U> mapper); <U> Result<U> flatMap(Function<T, Result<U>> mapper); Result<T> recover(Function<Throwable, T> recoveryFn); <U> U fold(Function<T, U> onSuccess, Function<Throwable, U> onFailure);}map, flatMap, and recover catch an Exception thrown by the supplied function and return it
as a Failure. Result.failure(null) is rejected.
DispatchEnvelope<T> and DispatchMetadata
Section titled “DispatchEnvelope<T> and DispatchMetadata”The event-bus carrier for framework dispatch and its typed context map.
public final class DispatchEnvelope<T> { public static <T> DispatchEnvelope<T> of(T payload, DispatchMetadata metadata); public static <T> DispatchEnvelope<T> of(T payload, DispatchMetadata metadata, String replyAddress); public static <T> DispatchEnvelope<T> of(T payload); // empty metadata public static DispatchEnvelope<Void> empty();
public T payload(); public DispatchMetadata metadata(); public Optional<String> replyAddress();}
public final class DispatchMetadata { public static DispatchMetadata of(Map<String, Object> dispatchContext); public static DispatchMetadata empty();
public Map<String, Object> dispatchContext(); public <C> Optional<C> context(Class<C> type);}The dispatch context is keyed by fully-qualified type name; context(Class) is the typed read.
DispatchMetadata.of copies the caller map, so later caller mutations cannot change what the
receiver observes across the local hop. Annotating a type @DispatchContextValue (in
dev.vertique.core.eventbus) makes it injectable as a service-handler method parameter.
Application code should not build envelopes by hand — use the typed client in
dev.vertique:vertique-services, which captures currently-bound context values automatically.
EventBusClient
Section titled “EventBusClient”@Singleton low-level transport over the framework’s dispatch codecs, exposing
Future<Result<?>> request(String address, DispatchEnvelope<?> envelope, long sendTimeoutMs) and
void send(String address, DispatchEnvelope<?> envelope). Failed requests are translated through
EventBusExceptionMapper before the future fails.
This is a transport primitive, not an application API. Prefer the typed service client in
dev.vertique:vertique-services, which adds supervisor checks, effective-timeout computation, and
service-specific failure enrichment.
BeanValidator and validation result types
Section titled “BeanValidator and validation result types”HTTP-agnostic validation contract; the implementation ships in dev.vertique:vertique-validation.
public interface BeanValidator { <T> void validate(T object); <T> void validate(T object, Class<?>... groups);
<T> List<ViolationDetail> check(T object); <T> List<ViolationDetail> check(T object, Class<?>... groups);
List<ParameterViolation> checkParameters(Object instance, Method method, Object[] args, Class<?>... groups); void validateParameters(Object instance, Method method, Object[] args, Class<?>... groups);}validate and validateParameters throw BeanValidationException on violation; check and
checkParameters return the violations instead, empty meaning valid.
| Type | Shape |
|---|---|
ViolationDetail |
record (@Nullable String path, String message, @Nullable String type, @Nullable Map<String, Object> args, ViolationPathForm form); the four-argument constructor and of(path, message) state JAVA_PROPERTY_PATH, a null form becomes JAVA_PROPERTY_PATH, atPointer(pointer, message, type, args) states JSON_POINTER and unlocated(message, type) has a null path. path is the producer’s own path in the stated form, or null when the violation locates nothing in any validated value (a whole-operation rule, a truncation marker); an application’s own rule identifier can be carried in type. form is not part of the JSON form of a detail |
ParameterViolation |
record (int parameterIndex, ViolationDetail detail, @Nullable PropertyPath propertyPath); the two-argument constructor remains and leaves propertyPath null. propertyPath is an INTERNAL seam outside the Stable promise (see PropertyPath) |
PropertyPath |
INTERNAL, outside the Stable promise; may change without a deprecation cycle. record (List<Segment> segments): a Java-side path from a validated value to a violated element, free of Jakarta Validation types and never holding a map key, set member or validated value. Segments: Property(String name, @Nullable Class<?> ownerType), Index(int index) (non-negative) and Opaque() (a map entry or set member); Segment is an open interface, so a consumer reads a kind it does not recognize as Opaque. root() is the empty path and isRoot() tests for it. toJavaPath() renders a property as its name (preceded by . unless it is the first rendered token), an index as [n], and an opaque or unrecognized segment as []; the root is "": items[0].zipCode, byKey[].name, lists[0][1], [2].name, tags[]. Not an application extension point |
ViolationCarrier |
interface { List<ViolationDetail> violations() }, implemented by validation exceptions whose violations a transport may render; the list is unmodifiable and never null |
ViolationPathForm |
enum { JAVA_PROPERTY_PATH, JSON_POINTER }: how a ViolationDetail writes its path. JAVA_PROPERTY_PATH is a Java property path of the validated object and is not a request location. JSON_POINTER is an RFC 6901 pointer into the document the producer validated, named by the producer’s own mapper; a producer that states it guarantees every path is "" or begins with / and contains no key the client chose. boolean accepts(String path) tells whether a path is well formed for the form: always true for JAVA_PROPERTY_PATH; for JSON_POINTER true only for "" or a /-prefixed path whose every ~ is followed by 0 or 1, with no ISO control character, no U+2028 or U+2029 and at most 2,000 characters (null is not accepted). New values and methods may be added, so a consumer that switches on it keeps a default branch |
BeanValidationException |
extends ValidationException, implements ViolationCarrier; violations() is defensively copied and unmodifiable |
JakartaPathPointers |
INTERNAL, may change without a deprecation cycle; not an application extension point. static Resolver resolver(ObjectMapper mapper, @Nullable InputFieldNameResolver names) opens a resolution session for one batch of violations (not thread-safe; its per-class lookups live only as long as the session); Resolver.toPointer(PropertyPath path, @Nullable Type rootType, @Nullable Object document) returns the RFC 6901 pointer of the deepest verified position, "" for the root. A segment is emitted only when it is verified, otherwise the pointer stops at the deepest verified ancestor (shorter, never different): a property is named by its primary wire name from the mapper’s deserialization introspection, and an unknown member or a member below a scalar, Object or unknown type ends the pointer; list and array positions are emitted per level (/lists/0/1, /2/name for a root list); a map entry, set member, or a position on anything but a list or array ends the pointer at that property, so no key text is ever emitted; Optional and other reference types are transparent; a recorded owner class is used only under flat type information on the declared type or on the holding property (@JsonTypeInfo with PROPERTY, EXISTING_PROPERTY or EXTERNAL_PROPERTY inclusion, or Id.DEDUCTION), when it is assignable to the declared base and the projection lists it among the base’s subtypes or cannot enumerate them — an assignable runtime class the projection does not list (or a failed subtype lookup) stops the pointer at the property holding the value, because such a subtype may rename even inherited members; flat type information with no recorded runtime class (always the case for a path string, and so for the path-string entry point below) stops the pointer at the holding property too, since the mapper may have bound any subtype; a recorded class outside the declared hierarchy, or a type without type information, keeps the declared type’s names; a value under WRAPPER_OBJECT or WRAPPER_ARRAY type information, @JsonIdentityInfo, a mapper-wide default typer, or (when the mapper enables UNWRAP_SINGLE_VALUE_ARRAYS) any bean below the root — and the root bean itself unless a document is checked to hold an object there — stops the pointer at the property holding it ("" at the root), and a list of such values at the list (/pets); names are escaped ~ → ~0 then / → ~1 with no percent-encoding, so an empty wire name gives /. A property is emitted only when the mapper writes it through a setter or a creator parameter (record components included), or through a field when the document holds a declared spelling of it; a read-only, getter-only or ignored property, a name the class (@JsonIgnoreProperties, detected through the annotation introspector and config overrides) or the holding property ignores, a member whose type the mapper ignores (@JsonIgnoreType or an ignored-type config override), a @JsonBackReference member, the @JsonAnySetter member and a @JsonUnwrapped member stop the pointer at their bean. A bean bound outside its reported properties — a class-level custom deserializer or converter, a builder (Lombok @Jacksonized included), a delegating creator, @JsonFormat(shape = ARRAY) on the class or in a config override — stops the pointer at the property holding it ("" at the root), as does a bean with a single-argument constructor or factory that is not its properties creator unless the document holds an object there (so single-component records resolve). A property bound through @JsonDeserialize(using / contentUsing / converter / contentConverter / as / contentAs) or @JsonFormat(shape = ARRAY) is emitted and ends the pointer; JsonNode, Map and Object properties are never descended. A list or array that skips null elements (contentNulls = SKIP from the default setter info, a config override or @JsonSetter) or accepts a single value as an array (ACCEPT_SINGLE_VALUE_AS_ARRAY on the mapper, in a config override of the sequence type or of Collection, or in the property’s @JsonFormat) gets no index, and neither does a list or array whose own type carries a class-level custom deserializer or converter, which may rebuild the elements in another order: {"items":[null,{"name":""}]} gives /items. The checks use public Jackson introspection only and are memoized per session; a deserializer installed by a registered Jackson module is not detected. The class-level check is the one JacksonFieldNameResolver uses for boundJavaNames (JacksonBindings, below). Without names (mapper-only) the document is ignored, so field-only properties and beans with a scalar creator stop the pointer; with the route’s projection a property must also be among its bound Java names and must not be one the projection promotes members out of; a failing projection stops the pointer and is remembered for the session. With the projection and a document (the bound body as JsonObject, JsonArray, Map, List or scalar) every step is checked against it: a member is looked up only inside an object the document holds there (a position holding null, a scalar or an array stops the pointer at that position: {"owner":null} → /owner); exactly one document key that is a declared spelling of the property (primary name or alias, case-folded when the mapper folds case) and maps back to it is emitted as sent (/zip, /ZipCode), two or more stop at the parent, and no other key — a Java name on a renaming route, a map key, an undeclared member — is ever emitted; when the bean matches member names with a case rule other than the mapper’s (@JsonFormat(with/without = ACCEPT_CASE_INSENSITIVE_PROPERTIES) on its class, in its config override or on the holding property), or the mapper matches case-insensitively and its locale lower-cases a key or declared spelling differently from Locale.ROOT, membership cannot be verified and the pointer stops at the parent whether the member is present or omitted; a list position past the end of the document’s array stops at the list. “Verified” means the mapper reads the member: through a setter or a creator parameter, or through a field when its declared spelling is present. An omitted member is named, by its primary wire name, only as the last segment of the path, inside an object the document holds, and only when its own binding member is a setter, creator parameter or record component ({"items":[{},{}]} → /items/1/name; on a SNAKE_CASE route {"zipCode":""} → /zip_code for a setter-bound zipCode), so hidden bound required members are named on omission; an omitted member that only a field writes (a public field, a field behind a renamed setter or creator parameter) and an omitted intermediate member or position stop at the parent. At most MAX_SEGMENTS (256) segments; per session, each owner class is introspected and asked of the projection once and each visited document object’s keys are indexed once, and a property’s matching keys in one object are computed once, stopping at the second match; list positions are read directly, never by walking the array; iterative; never throws (a failure yields the pointer verified so far). static String toPointer(String jakartaPath, @Nullable Type rootType, ObjectMapper mapper) parses the property-path string (a numeric bracket is a position, any other bracket an opaque marker, a negative or overflowing position or an unclosable bracket ends the parse, a container-element node contributes only its position) and resolves it in mapper-only mode without a document. static String loggablePath(@Nullable String) renders a path string for logs through the same parser, stopping at the first bracket of any kind, which renders [] (byKey[ZZKEY].name and byKey[0].ZZKEY].name → byKey[]; items[0].name → items[]: without the validated type a position cannot be told from a key shaped like one, so this fallback never renders positions); loggablePath(@Nullable PropertyPath) renders positions and continues past opaque segments (byKey[].name, lists[0][1]); both remove ISO control characters, U+2028/U+2029 and Unicode format characters (bidirectional overrides, zero-width characters) and cut to 512 characters |
JacksonBindings |
INTERNAL, in dev.vertique.core.json, may change without a deprecation cycle; not an application extension point. static boolean bindsThroughItsProperties(DeserializationConfig config, BeanDescription description) reports whether the mapper binds a type through the properties its deserialization introspection reports: false for a class-level @JsonDeserialize(using = …), a builder, a @JsonCreator(mode = DELEGATING) creator or a single-parameter @JsonCreator whose parameter has no name. Shared by JacksonFieldNameResolver (a null boundJavaNames) and JakartaPathPointers, so one rule decides both; stateless |
ViolationDetail deliberately excludes the invalid value so a violation can be serialized without
leaking input. type and args are populated by the validation module and are null when it is
not configured to resolve them.
ViolationCarrier is the seam a transport uses to render structured violations: the default REST
mapping renders any ValidationException that implements it as a 400 with one error per
violation. BeanValidationException implements it. Path, message, type and args reach clients
verbatim, so an implementor builds every ViolationDetail it creates without a submitted value. A
constraint message produced by an application’s own constraints passes through unchanged, so
applications must not interpolate the validated value into a message. Each detail states the form of its
own path in form(): JAVA_PROPERTY_PATH by default (as for BeanValidationException), or JSON_POINTER
for a detail built with ViolationDetail.atPointer. A pointer is relative to the document its producer
validated and is built with the producer’s own mapper, so it is not a pointer into the request body unless the
code that knows where that document sits says so. A transport reports a location only where it knows that,
and only for a pointer that ViolationPathForm.accepts admits. A producer emits only paths that
ViolationPathForm.accepts admits.
@ValidateWith(Class<?>[] value) selects Bean Validation groups for a single method; the default
group applies when it is absent.
MethodMetadata and ParameterMetadata
Section titled “MethodMetadata and ParameterMetadata”Reflection-free metadata contracts shared by codegen consumers and runtime scanners. An aspect or interceptor author consumes them; application code does not implement them — generated code and the framework’s own scanners provide the implementations.
MethodMetadata:
| Method | Returns | Group |
|---|---|---|
name() |
String |
reflection-free |
declaringType() |
Class<?> |
reflection-free |
returnType() |
Class<?> |
reflection-free |
parameterTypes() |
Class<?>[] |
reflection-free |
parameters() |
List<ParameterMetadata> |
reflection-free |
findAnnotation(Class<A>) |
Optional<A> |
reflection-free |
hasAnnotation(Class<? extends Annotation>) |
boolean |
reflection-free |
genericReturnType() |
Type |
reflection-free on generated metadata; reflective on scan metadata |
asMethod() |
Method |
reflective |
ParameterMetadata:
| Method | Returns | Group |
|---|---|---|
index() |
int |
reflection-free |
name() |
String |
reflection-free |
type() |
Class<?> |
reflection-free |
findAnnotation(Class<A>) |
Optional<A> |
reflection-free |
hasAnnotation(Class<? extends Annotation>) |
boolean |
reflection-free |
genericType() |
Type |
reflective |
annotationsLazy() |
Supplier<Annotation[]> |
reflective; default returns an empty array |
Invariants & Gotchas
Section titled “Invariants & Gotchas”- The reflection-free group — including both annotation lookups and generated
genericReturnType()— is safe on the critical path and needs no GraalVMreflect-config.jsonentry.asMethod(),ParameterMetadata.genericType(), andannotationsLazy()remain reflective; the scan-path implementation ofgenericReturnType()is reflective too. - On the codegen path, annotation lookups query compile-time-captured annotation literals, not live
runtime annotations. An annotation added after compilation — by a dynamic proxy or a bytecode
agent — is invisible. Only
@Retention(RUNTIME)annotations are ever visible. findAnnotationreturnsOptional, nevernull. BothfindAnnotationandhasAnnotationtake an annotation-typedClass(Class<A extends Annotation>andClass<? extends Annotation>).parameterTypes()gives the erased signature without materializingParameterMetadataobjects;parameters()gives the full per-parameter view.
Resilience vocabulary
Section titled “Resilience vocabulary”The resilience annotation vocabulary and its declaration metadata are provided by
dev.vertique:vertique-resilience. Core deliberately remains independent of that artifact;
applications using @Retry, @Timeout, or @CircuitBreaker should depend on the resilience module
directly or through the module that enforces those declarations.
ScopeKey and ScopeContext
Section titled “ScopeKey and ScopeContext”public record ScopeKey(String value) { public static final ScopeKey DEFAULT; // equal to new ScopeKey("default")}
public record ScopeContext(ScopeKey scopeKey) implements ContextValue {}Validated exact-identity partition name and its holder-eligible composition. See
Scope vocabulary. system is an ordinary accepted key, not a special variant.
ScopeKey itself does not implement ContextValue.
CorrelationContext
Section titled “CorrelationContext”Read-only view of the correlation identifiers bound to the current execution. It is a
ContextValue and is marked @DispatchContextValue, so it propagates across service dispatch.
public interface CorrelationContext extends ContextValue { CorrelationIdentifier requestId(); CorrelationIdentifier correlationId(); @Nullable CorrelationIdentifier causationId(); @Nullable TraceReference trace(); List<ProtocolCorrelationRef> protocolCorrelations(); @Nullable CorrelationSessionRef session(); Map<String, String> attributes(); CorrelationContextSnapshot snapshot();
static CorrelationContext unbound(); static CorrelationContext generated(String source);}CorrelationIdentifier is a record (String value, String source); both components must be
non-blank. CorrelationContext.unbound() is the non-joinable sentinel (value "unavailable",
source "unbound") for surfaces that are deliberately not request-tied.
CorrelationContext.generated(String source) mints fresh UUID request/correlation ids for
fail-closed emission sites that still need an audit join key when no ambient correlation is bound.
The runtime that binds this context, and the CorrelationIdGenerator SPI it uses, ship in
dev.vertique:vertique-correlation. An application overrides ID generation by binding its own
CorrelationIdGenerator — that module declares the binding as optional.
UUIDV7
Section titled “UUIDV7”Time-ordered identifiers (RFC 9562 UUIDv7) as plain java.util.UUID values, for keys that should
sort by creation time — for example database primary keys that append to the index instead of
scattering across it.
UUID id = UUIDV7.randomUUID(); // drop-in for UUID.randomUUID()long createdAt = UUIDV7.timestampMillis(id); // embedded Unix epoch millisecondsInvariants & Gotchas
Section titled “Invariants & Gotchas”- Same layout as PostgreSQL
uuidv7()and Hibernate’s version 7 generator. The 12rand_abits hold the fraction of the millisecond in 1/4096 steps (about 244 ns) and the 62rand_bbits are random, so database-generated and application-generated keys sort together. - Monotonic per JVM. Every call returns a UUID strictly greater than all earlier results from
the same JVM, including within one millisecond and across clock regressions.
UUID.compareToand the string form sort identically. - No cross-process guarantee. Different processes or hosts order by their clocks, then by random bits where the clocks give the same instant.
- Not a secret. The UUID discloses its creation time and carries fewer random bits than a
version 4 UUID. Do not use it as a token or session secret; keep
UUID.randomUUID()for those. - The embedded timestamp can run ahead of the clock after a backwards clock step, by as much as the step, or during a sustained burst above about four million identifiers per second, until the clock catches up. It never runs behind the last issued value.
- Thread-safe, short lock. Callers contend only for a brief critical section, so it is safe to call from an event-loop thread.
timestampMillisthrowsIllegalArgumentExceptionfor any UUID that is not version 7, variant 2, andrandomUUIDthrowsIllegalStateExceptionif the system clock is outside the 48-bit range.
PayloadSource and PayloadSources
Section titled “PayloadSource and PayloadSources”Transport-agnostic description of a request or response body, used where a module must inspect a payload without depending on REST or audit types.
public interface PayloadSource { PayloadKind kind(); // ABSENT | BUFFERED | STREAMING Optional<String> contentType(); OptionalLong declaredLength(); Optional<Buffer> bufferedView(); Optional<InputStream> bufferedStream(); byte[] copyPrefix(int maxBytes);}
PayloadSource absent = PayloadSources.absent();PayloadSource buffered = PayloadSources.buffered(bytes, "application/json");PayloadSource streaming = PayloadSources.streaming("application/octet-stream", declaredLength);Invariants & Gotchas
Section titled “Invariants & Gotchas”- No defensive copy anywhere.
buffered(byte[], …)captures the array reference; a caller that needs immutability must copy before calling the factory. Mutating the array afterwards changes what readers observe. bufferedView()is zero-copy only for aBuffer-backed source. Forbyte[]andByteBufferbackings it lazily materializes and copies, so a hot path that only reads bytes should preferbufferedStream().copyPrefixis the one accessor that always copies; it exists for diagnostics, not for the data path.
JSON profiles
Section titled “JSON profiles”A named ObjectMapper selected at a framework boundary, so one application can serialize different
surfaces under different Jackson policies.
public record JsonProfileId(String value) { public static final JsonProfileId SYSTEM; // "system" public static JsonProfileId of(String value);}
public interface JsonMapperProfileRegistry { ObjectMapper mapper(JsonProfileId id); JsonMapperProfile profile(JsonProfileId id); Set<JsonProfileId> profileIds(); default void validateConfigured(@Nullable String profileId);}@JsonProfile("name") is @Target({TYPE, METHOD}) and selects a profile at a boundary that
supports it. Which placements a boundary accepts is the boundary’s own contract: REST resources and
MCP tools accept both TYPE and METHOD with method-level overriding type-level, while rest-client
interfaces and Kafka listeners/producers accept TYPE only. JsonProfileId trims its value and
rejects null or blank. Looking up an unknown id throws JsonProfileConfigurationException, which
extends ConfigurationException. The registry implementation ships in dev.vertique:vertique-json.
JsonProfileId.SYSTEM ("system") is the reserved baseline id. The id "vertx" is retired: it
was renamed system, and the registry rejects it — as a configured id and as an
application-contributed profile id — with a message naming the rename.
JSON schema overrides on a profile
Section titled “JSON schema overrides on a profile”A profile may declare, per Java class, the JSON Schema fragment describing the wire form its mapper actually produces or accepts — so a generated schema matches the profile’s serialization instead of the Java type’s default shape.
public final class JsonSchemaFragment { public static JsonSchemaFragment parse(String schemaJson); public String canonicalJson();}
public final class JsonSchemaTypeOverride { public enum Direction { INPUT, OUTPUT, BOTH }
public static JsonSchemaTypeOverride input(Class<?> javaType, JsonSchemaFragment fragment); public static JsonSchemaTypeOverride output(Class<?> javaType, JsonSchemaFragment fragment); public static JsonSchemaTypeOverride both(Class<?> javaType, JsonSchemaFragment fragment);
public Class<?> javaType(); public Direction direction(); public JsonSchemaFragment fragment();}JsonSchemaTypeOverride.both( BigDecimal.class, JsonSchemaFragment.parse( """ {"type":"string","format":"decimal","maxLength":100} """));parse takes a non-null, non-blank JSON object holding one self-contained Draft 2020-12
fragment. It orders object keys recursively by String.compareTo (never reordering arrays), retains
only the canonical compact string, and returns an immutable value; canonicalJson() returns that
string and two fragments are equal when their canonical JSON is equal. Parsing is syntactic plus
bounded structural validation — the consuming validator still compiles the completed schema.
parse rejects — with an IllegalArgumentException whose bounded message names the violated rule
and never echoes the fragment — a null or blank input, malformed JSON, a non-object root, and any
occurrence of $schema, $id, $anchor, $dynamicAnchor, $ref, $dynamicRef, or $defs. The
schema generator owns the document dialect and definition graph, so a fragment-local reference would
change meaning once embedded. That rejection is structural and deliberately over-broad: those key
names are rejected at any depth, including where one is merely a property name inside a properties
object.
JsonSchemaTypeOverride requires non-null arguments and describes one exact raw class — no
assignability or subtype matching, and never a map key. An override for BigDecimal reaches a
BigDecimal property and the element of a resolved List<BigDecimal>, but not a BigDecimal
subclass nor the key type of Map<BigDecimal, String>. INPUT and OUTPUT select one generation
direction; BOTH selects both, and conflicts with a direction-specific declaration for the same
class. The generator that consumes these values ships in dev.vertique:vertique-json-schema.
The process JSON codec
Section titled “The process JSON codec”Vert.x routes every Json.encode, Json.decodeValue, new JsonObject(String|Buffer),
JsonObject.encode(), mapTo and mapFrom call through one process-wide codec. vertique-core
registers its own codec for that role and points it at a swappable ObjectMapper; VertiqueJson is
how framework and application code reach that mapper.
public final class VertiqueJson { public static ObjectMapper mapper(); // live read public static boolean ownsCodec(); public static Optional<JsonProfileId> profile(); public static void install(JsonProfileId id, ObjectMapper mapper); public static void resetForTests(); // gated, see below}Before installation the codec delegates to Vert.x’s own mapper, so a process that never installs
behaves exactly like stock Vert.x. dev.vertique:vertique-json contributes the CONFIGURE-phase
step that installs the profile named by json.systemProfile; from that point the whole process
encodes and decodes through that profile’s mapper. Mappers are swapped, never reconfigured — Jackson
forbids reconfiguring a mapper after first use — and Vert.x’s own mapper is never mutated.
mapper() is a live read: call it at use time, or capture it only in an object constructed
during or after CONFIGURE. A static initializer or a Dagger provider runs earlier and would
capture the pre-install mapper.
install is keyed by profile id and enforces three rules on every call, whoever the caller is — the
rules bind the act of installing, not the installed instance’s future state (a live ObjectMapper
can still be reconfigured by any in-process code; the JSON runtime re-checks default typing at
VALIDATE, and a graph without it logs a WARN there when nothing was installed):
- A different id than the one already installed is refused with an
IllegalStateExceptionnaming both ids and both installing classes — one process has one JSON codec. The same id installs the new, equivalent instance and logs the swap atINFO, so a second application booted in the same JVM under the same system profile starts normally. - The mapper must register Vert.x’s Jackson module (
VertxJsonSupport.module()indev.vertique:vertique-json), otherwiseJsonObject,JsonArrayandBuffervalues would be bean-serialized instead of round-tripping —IllegalArgumentException. - The mapper must not activate Jackson default typing, which would let any decoded payload choose
the type it instantiates —
IllegalArgumentException. Annotation-driven polymorphism with@JsonTypeInfostays available.
Registration is a packaging concern. The codec reaches Vert.x through
META-INF/services/io.vertx.core.spi.JsonFactory, loaded from the thread-context classloader when
Vert.x initializes its Json class, and the framework’s factory declares a low order() so an
application-registered factory does not displace it. A shaded or uber-jar build must merge
service files (Maven Shade’s ServicesResourceTransformer, or the equivalent for the packaging tool
in use); dropping them silently leaves Vert.x on its own codec. A container that hides the framework
from that classloader has the same effect. ownsCodec() reports the outcome, and the install
step fails the boot when it is false.
Outside the installed-mapper claim. Vert.x’s DatabindCodec.createParser/fromParser static
helpers, and the JacksonCodec.fromString(String)/fromBuffer(Buffer) streaming overloads that
return Object, are bound to Vert.x’s own raw mapper and factory. Code that must use the installed
mapper calls VertiqueJson.mapper().
Buffer decodes copy. To keep a buffer decode as strict as a string decode, the codec builds its
parser from the installed mapper’s factory over a byte copy of the buffer, so JsonObject(Buffer)
and Json.decodeValue(Buffer, …) transiently hold twice the payload. Bound the input: set
vertx.jackson.defaultReadMaxDocumentLength and a BodyHandler body limit on network edges.
resetForTests() is a gated test seam. It restores the pre-install delegate, and it throws
IllegalStateException unless the JVM was started with
-Dvertique.json.codec.allowReset=true. The flag is read once when VertiqueJson is initialized, so
setting the property at runtime cannot open the seam. Set it only in a build’s surefire/failsafe
configuration, never for a deployed application.
Extension Points
Section titled “Extension Points”Every extension below is contributed through Dagger multibinding unless stated otherwise.
HealthCheck
Section titled “HealthCheck”Reports the health of one component. Classify each check with the @Liveness or @Readiness
qualifier; HealthCheckModule declares both empty-by-default sets so a module can contribute
without depending on dev.vertique:vertique-management, which is what reads them.
public interface HealthCheck { String name(); Future<HealthCheckResult> check();}public final class DatabaseHealthCheck implements HealthCheck { @Inject public DatabaseHealthCheck() {}
@Override public String name() { return "database"; }
@Override public Future<HealthCheckResult> check() { return Future.succeededFuture(HealthCheckResult.up(Map.of("pool", "ready"))); }}
@Provides @IntoSet @Readinessstatic HealthCheck databaseHealth(DatabaseHealthCheck check) { return check;}HealthCheckResult is a record (HealthStatus status, Map<String, Object> data) with the factories
up(), up(Map), down(), down(String error), down(Throwable cause), and down(Map); status
must not be null, and data is copied and never null. The copy is unmodifiable and keeps the
supplied map’s iteration order (pass a LinkedHashMap to control the order entries appear in the
probe response); a null key or value is rejected with a NullPointerException. down(String) treats a null error as
“no message” and yields empty data rather than throwing, so down(throwable.getMessage()) is safe
for a message-less exception.
down(Throwable) puts the throwable’s message under the error key, falling back to its fully
qualified class name when the message is null — and equally when getMessage() itself throws an
exception, so a failure that cannot describe itself still yields a DOWN result instead of a
second failure. It rejects a null cause with a NullPointerException. Return a completed future
carrying a DOWN result rather than a failed future — a failed future or a thrown exception is
still reported as DOWN, but with the error text instead of your diagnostic data. name() must be
unique within its qualifier set and must not return null; the management endpoint reports a null
name under the check’s implementing class name.
ApplicationStartupStep and ApplicationShutdownStep
Section titled “ApplicationStartupStep and ApplicationShutdownStep”Non-verticle units of startup and teardown work.
public interface ApplicationStartupStep extends LifecycleOrdered { Future<Void> start();}
public interface ApplicationShutdownStep extends LifecycleOrdered { Future<Void> stop();}@Singletonpublic final class SchemaMigrationStep implements ApplicationStartupStep { private final Migrator migrator;
@Inject public SchemaMigrationStep(Migrator migrator) { this.migrator = migrator; }
@Override public LifecyclePhase phase() { return LifecyclePhase.MIGRATE; }
@Override public Future<Void> start() { return migrator.migrate(); }}
@Provides @Singleton @IntoSetstatic ApplicationStartupStep schemaMigration(SchemaMigrationStep step) { return step;}A step normally declares one of the non-verticle phases — CONFIGURE, VALIDATE, MIGRATE, or
AFTER_START. The multibinding sets are declared by dev.vertique:vertique-deploy and driven by
dev.vertique:vertique-application; core contributes into them without depending on either.
ComposeValidator
Section titled “ComposeValidator”Behavior-free marker for the constructible-as-validation pattern: a class that declares its
module’s required bindings as @Inject constructor parameters proves those bindings exist the
moment it is constructed.
public interface ComposeValidator {}@Singletonpublic final class MyModuleComposeValidator implements ComposeValidator { @Inject public MyModuleComposeValidator(MyRequiredService service) { if (service.mode() != Mode.READY) { throw new IllegalStateException("MyRequiredService must be configured in READY mode"); } }}
@Provides @Singleton @IntoSetstatic ComposeValidator myModuleValidator(MyModuleComposeValidator v) { return v;}ComposeValidationStep materializes the whole set during the VALIDATE phase, which constructs
every contributed validator. A missing binding is a Dagger compile error; a violated invariant is
whatever the constructor throws, reported as a failed VALIDATE-phase startup. For validators to
run, the application component must include CoreLifecycleStepsModule and be driven by the standard
lifecycle runner in dev.vertique:vertique-application.
Canonicalizer and Sanitizer
Section titled “Canonicalizer and Sanitizer”String normalization (semantics-preserving) and content removal (not semantics-preserving) applied
to inbound values. Implementations must be stateless and thread-safe; a Canonicalizer must also be
deterministic and idempotent.
@FunctionalInterfacepublic interface Canonicalizer { String canonicalize(String value, InputValueContext context);}
@FunctionalInterfacepublic interface Sanitizer { String sanitize(String value, InputValueContext context);}Register each implementation as a binding record keyed by its own type — the type() component is
the lookup key used to resolve the classes named in @Canonicalize / @Sanitize. Declare the chain
with those annotations, both @Target({TYPE, FIELD, RECORD_COMPONENT, PARAMETER, METHOD, ANNOTATION_TYPE}), so they also compose into custom annotations:
@Provides @IntoSetstatic CanonicalizerBinding trimCanonicalizer(TrimCanonicalizer c) { return new CanonicalizerBinding(TrimCanonicalizer.class, c);}
@Path("/users") // type-level: applies to the whole resource@Canonicalize({TrimCanonicalizer.class, NfkcCanonicalizer.class})public class UserResource { }
public record CreateUserRequest( // component-level @Canonicalize(TrimCanonicalizer.class) @Sanitize(BasicHtmlSanitizer.class) String bio) {}A Sanitizer is registered the same way, through SanitizerBinding.
@SkipCanonicalization and @SkipSanitization opt an element out of processing inherited from an
enclosing type. InputValueContext is a record (InputLocation location, String path, String logicalName, Class<?> ownerType); InputLocation is PATH, QUERY, HEADER, COOKIE, FORM,
BODY, BEAN_PARAM, PAYLOAD. PAYLOAD marks values from message or protocol payloads (e.g.
WebSocket messages); REST request bodies remain BODY. A custom Canonicalizer, Sanitizer, or
CharacterPolicy that branches on InputLocation.BODY must also handle PAYLOAD to keep
covering message-oriented inputs — WebSocket messages reported BODY before PAYLOAD existed.
path and logicalName are deliberately different views of the same value. path is the wire
path — the dot-separated keys as the caller sent them — so a diagnostic points at what actually
arrived. logicalName is the Java property name whenever the value came from a property the
processing engine matched, and the wire name otherwise. A Canonicalizer or Sanitizer that
branches on logicalName is therefore branching on the Java name, not on a @JsonProperty-renamed
key; branch on path when the wire form is what matters. Collection elements carry the element path
in both components.
InputFieldNameResolver
Section titled “InputFieldNameResolver”Codec-neutral projection from a wire property name to the Java property name that declares its input policies.
@FunctionalInterfacepublic interface InputFieldNameResolver {
/** Returns every wire name unchanged, for transports that do not rename. */ InputFieldNameResolver IDENTITY = (ownerType, wireName) -> wireName;
String logicalName(Class<?> ownerType, String wireName);
/** Composes one owner type's projection at registration; no-op unless overridden. */ default void precompute(Class<?> ownerType) {}
/** Wire keys of ownerType that bind into a field of another type; empty unless overridden. */ default Map<String, PromotedField> promotedFields(Class<?> ownerType) { return Map.of(); }
/** Where one wire key is bound, or null; override to apply a wire-side rule such as folding. */ default PromotedField promotedField(Class<?> ownerType, String wireName) { return promotedFields(ownerType).get(wireName); }
/** The Java names the codec binds wire keys of ownerType into; null when not enumerable. */ @Nullable default Set<String> boundJavaNames(Class<?> ownerType) { return null; }
/** Wire keys bound into a Java property the codec cannot name; empty unless overridden. */ default Set<String> unroutableWireNames(Class<?> ownerType) { return Set.of(); }
/** Subtypes the codec may bind in place of a polymorphic base; empty unless overridden. */ default Set<Class<?>> polymorphicSubtypes(Class<?> ownerType) { return Set.of(); }
/** A key that arrives on one type but is bound into a field declared on another. */ record PromotedField(Class<?> declaringType, String fieldName, List<String> enclosingPath) {}}Policies declared with @Canonicalize / @Sanitize are keyed by the Java property name, but an
intermediate parsed from the wire is keyed by whatever the codec published —
@JsonProperty("user_name"), a SNAKE_CASE naming strategy, or a @JsonAlias. Without a
projection between the two, a policy declared on a renamed field silently never runs. This contract
is the seam that closes that gap, and declaring it here — beside InputLocation, Canonicalizer
and Sanitizer — is what keeps both vertique-input-processing and vertique-sanitization free of
any codec dependency: each codec-backed implementation lives in the module that already owns that
codec (JacksonFieldNameResolver in dev.vertique:vertique-json).
Six properties are part of the contract:
| Property | Contract |
|---|---|
| Direction | Wire → Java, the only direction able to express an alias’s many-to-one mapping |
| Totality | An unrecognized wireName — an undeclared extra key, a Map-typed field’s key, or a name that is already the Java property name — is returned unchanged; an implementation never returns null and never throws |
| Threading and cost | Stateless (or effectively immutable), reentrant, consulted once per intermediate key on the request path — it must not block and should serve every call from a precomputed projection |
| Warm-up | precompute(Class) composes one owner type’s projection ahead of the request path; it defaults to a no-op, so a resolver that needs no per-class state is unaffected. The input-processing engine calls it at registration for every owner type it may consult, and an implementation may fail fast there — the failure is deliberately a startup failure rather than a per-request one |
| Bound names | boundJavaNames(Class) enumerates the Java property names the codec binds wire keys of an owner into, or returns null when the projection cannot enumerate them. The engine keys metadata on the Java field name and a codec on the property name it derives from accessors; the two diverge for setStreet writing streetName, and neither side can derive that mapping. The engine checks at registration that every field carrying a declared chain is among the bound names and fails startup naming the field when it is not. IDENTITY returns null and is trusted, as is a codec that binds a type outside its declaration view. unroutableWireNames(Class) names the keys a codec binds into a property it cannot name — a creator parameter carrying only a wire name — and the engine refuses at registration an owner with such a key that declares a chain on any field, because which field the parameter writes is undecidable |
| Polymorphic subtypes | polymorphicSubtypes(Class) reports the concrete subtypes the codec may bind in place of a polymorphic base, or the empty set. The engine resolves policy metadata from the declared type and never inspects the runtime subtype, so it asks at registration and refuses a base whose subtype declares a chain or skip the base does not carry. The default is the empty set, so a resolver that knows no polymorphism — including IDENTITY — is unaffected. An implementation answers from class annotations alone: the engine asks it of every reachable type, including ones whose property projection was never composed. A subtype discoverable only from a type id at runtime is not enumerable and is absent |
| Promotion | promotedFields(Class) reports the keys of an owner type that the codec promoted out of a nested member, so they arrive flat while their policies are declared on the inner type — Jackson’s @JsonUnwrapped is the case it exists for. The map is keyed by the wire name, and a promoted key is never added to the projection: logicalName returns it unchanged, as its totality contract already requires, so the owner’s metadata misses and the engine falls through. promotedField(Class, String) performs the lookup, which is what keeps case folding inside the projection. PromotedField.enclosingPath() names the owner-side members traversed, so the engine descends through them and their chains and skip flags still apply. Both default to promoting nothing |
The JSON pointer resolver (JakartaPathPointers) is a second consumer: it may read logicalName,
boundJavaNames, promotedFields and polymorphicSubtypes on the error path, when it names the
request members a constraint violation concerns, and treats a call that throws as unverifiable.
Use InputFieldNameResolver.IDENTITY for any transport whose intermediate keys are already Java
property names, including every call site that processes a bare String, where there is no object
whose fields could be renamed.
The engine consults promotedField only after the owner’s own metadata has no entry for a key, so
an ordinary field costs nothing. Keying by the promoted field’s Java name would not work: that name
is local to the inner type, so two promoted members of different types sharing a field name would
collide even when a prefix gives them distinct wire keys, and a promoted key could shadow a
same-named field of the owner.
PromotedField.fieldName() is the Java property name the codec binds the key into. Registration
verifies through boundJavaNames that it equals the field name for every governed field of the
declaring type, and verifies the enclosing path is one the engine can descend wherever routing the
key would apply a policy; a promoted key that fails either is refused at startup rather than routed
wrongly, as is one on a type served by a generated input processor, whose field-name switch is
emitted from the owner’s own declared fields.
CharacterPolicy
Section titled “CharacterPolicy”Validates that a string contains only permitted characters.
public interface CharacterPolicy { CharacterPolicyResult validate(String value, InputValueContext context);}@Provides @IntoSetstatic CharacterPolicyBinding asciiPolicy(AsciiCharacterPolicy p) { return new CharacterPolicyBinding(AsciiCharacterPolicy.class, p);}Return CharacterPolicyResult.passed() or
CharacterPolicyResult.failed(index, codePoint, reason) naming the first offending character.
@SkipAllowedCharacters opts an element out of character validation inherited from its type.
When a policy runs from Bean Validation (the @AllowedCharacters constraint), its
InputValueContext is synthetic: location() is always BODY whatever the value’s real origin,
and ownerType() is always Object.class. Validation sees the value after it has been separated
from its transport provenance, and one validation pass can cover values from several locations at
once, so no accurate location exists to report. Do not branch on location() in a policy.
Canonicalizers and sanitizers run earlier and do receive accurate provenance.
Resilience primitives
Section titled “Resilience primitives”dev.vertique:vertique-resilience also owns the canonical BackoffStrategy and RetryPolicy, the
small contracts used by consumer modules to calculate retry delays and determine retry eligibility.
FailureTranslator and ContextAwareFailureTranslator
Section titled “FailureTranslator and ContextAwareFailureTranslator”@FunctionalInterfacepublic interface FailureTranslator<T extends Throwable> { Throwable translate(T throwable);}
@FunctionalInterfacepublic interface ContextAwareFailureTranslator<T extends Throwable> extends FailureTranslator<T> { Throwable translate(T throwable, String context);
@Override default Throwable translate(T throwable) { return translate(throwable, throwable.getMessage()); }}Both register on a FailureMapper through on(...). Layer-specific customizer SPIs — which
contribute translators into the framework’s REST, service, and database mappers through Dagger — are
declared by those modules; see dev.vertique:vertique-rest-jaxrs, dev.vertique:vertique-services,
and dev.vertique:vertique-db-core.
JsonMapperProfile
Section titled “JsonMapperProfile”Contributes one named ObjectMapper profile.
public interface JsonMapperProfile { JsonProfileId id(); ObjectMapper mapper(); default List<JsonSchemaTypeOverride> jsonSchemaTypeOverrides();}The registry that collects profiles ships in dev.vertique:vertique-json; contribute a profile
through that module’s multibinding. JsonProfileId.SYSTEM ("system") is reserved for the baseline
profile; vertique and vertique-strict are reserved too. mapper() returns one stable instance
per profile — the same reference on every call — and callers never mutate it. A registry
implementation must register all three reserved ids; no application profile may activate Jackson
default typing or claim the retired vertx id.
jsonSchemaTypeOverrides() declares the schema overrides described under
JSON schema overrides on a profile. It defaults to an empty
list, so a profile that only implements id() and mapper() stays valid. An implementation that
overrides it returns the same stable, non-null, unmodifiable list on every call and defensively
copies any caller-supplied collection.
Context-propagation SPIs
Section titled “Context-propagation SPIs”Implement these to make a typed value travel across a dispatch or durable boundary. Every one is
bounded <T extends ContextValue> on its context type; the registries that collect them live in
dev.vertique:vertique-context.
| SPI | Contract |
|---|---|
ServiceDispatchContextEncoder<T> |
Class<T> type(), String key() (defaults to the FQCN), Object encode(T, ServiceDispatchEncodeContext) |
ServiceDispatchContextDecoder<T> |
Class<T> type(), String key(), ContextDecodeResult<T> decode(Object, ServiceDispatchDecodeContext) |
DurableContextMetadataEncoder<T> |
Class<T> type(), String namespace(), DurableMetadata encode(T, DurableEncodeContext) |
DurableContextMetadataDecoder<T> |
Class<T> type(), String namespace(), ContextDecodeResult<T> decode(DurableMetadata, DurableDecodeContext), default boolean acceptsExplicitCarrier() |
ContextValueAdapter<T> |
Class<T> type(), Object snapshot(T), T restoreFromSnapshot(Object), default T duplicate(T) |
InboundContextInitializer |
ContextHolder.Scope initialize(InboundContextInitializationContext) |
public record RequestLabelContext(String label) implements ContextValue {}
public final class RequestLabelDispatchEncoder implements ServiceDispatchContextEncoder<RequestLabelContext> { @Inject public RequestLabelDispatchEncoder() {}
@Override public Class<RequestLabelContext> type() { return RequestLabelContext.class; }
@Override public Object encode(RequestLabelContext value, ServiceDispatchEncodeContext context) { return value.label(); }}ScopeContext is partition vocabulary eligible for the same explicit holder write path. This core
foundation does not register a scope encoder; the conditional default durable codec pair is supplied
by vertique-context’s ContextRuntimeModule.
Decoders report problems by returning ContextDecodeResult.failure(warnings) rather than throwing;
the record is (Optional<T> value, List<ContextDecodeWarning> warnings) with the factories
empty(), of(value), and failure(warnings). ContextDecodeWarning is a
record (String key, String value, String reason).
DispatchBoundary supplies the boundary identifiers an encoder or decoder can branch on:
MCP, SERVICE_DISPATCH, KAFKA, OUTBOX, OUTBOX_SERVICE, DELAYED_JOB, WORKFLOW, CAMEL.
The same class also names the ingress kinds an entry point binds as the request’s ambient invocation
origin for authorization — REST ("rest") and WEBSOCKET ("websocket") alongside the boundary
identifiers above — so a transport, an encoder, and a policy all read one vocabulary.
Configuration
Section titled “Configuration”vertique-core reads no configuration section of its own. It defines the contract every other
module’s configuration is parsed through.
The boundary
Section titled “The boundary”The root configuration object is bound once, qualified @VertxConfig, by VertxModule. Read it
only in a @Provides method, navigate to your section, and parse it through an injected
ConfigParser:
@Provides@Singletonstatic HelloConfig helloConfig(@VertxConfig JsonObject config, ConfigParser parser) { return parser.parse(JsonConfigPaths.navigateObject(config, "hello"), HelloConfig.class);}The ConfigParser binding is provided by ConfigParsingModule in
dev.vertique:vertique-config-core; include it once in the application component. Never use
JsonObject.mapTo or a hand-rolled ObjectMapper at this boundary — the config mapper is isolated
from the Vert.x and REST mappers and is deliberately coercion-lenient, so "port": "9090" binds
whether or not another mapper in the process has been tuned strict.
ConfigParser
Section titled “ConfigParser”| Method | Behavior |
|---|---|
<T> T parse(JsonObject section, Class<T> type) |
Parses one section into a typed record. A null/empty section yields the type’s default shape. Throws ConfigurationException on failure. |
<T> List<T> parseKeyedObject(JsonObject section, String identityProp, Class<T> elementType) |
Parses a section that is a keyed object {key:{…}}, injecting each entry key into the named identity property. |
<T> List<T> parseKeyedObject(JsonObject section, String identityProp, Class<T> elementType, Map<String, Object> fixedProps) |
Same, plus constant properties injected into every element before deserialization — needed when a record’s compact constructor requires fields beyond the key. |
// Field-keyed: @KeyedBy on the List<T> component of the parsed recordKafkaConfig kafka = parser.parse(JsonConfigPaths.navigateObject(config, "kafka"), KafkaConfig.class);
// Section-root-keyed: the keys are the section itselfList<RestClientConfig> clients = parser.parseKeyedObject( JsonConfigPaths.navigateObject(config, "restClient"), "name", RestClientConfig.class);
// With fixed props for a record whose identity is a (type, name) pairList<ServiceConfig> services = parser.parseKeyedObject(typeGroup, "name", ServiceConfig.class, Map.of("type", typeKey));parse(null, T) is safe and returns the type’s Jackson default shape; parseKeyedObject(null, …)
returns List.of(). Open property bags whose schema the framework does not own must be read
directly from the source JsonObject and attached afterwards.
@KeyedBy
Section titled “@KeyedBy”Annotation (@Target({FIELD, METHOD}), in dev.vertique.core.json) on a List<T> record component
whose external JSON is a keyed object. The object key is injected into the named identity property
of each element. On a class bound through a builder (a Lombok @Builder @Jacksonized class, for
example) Jackson reads the annotation from the builder setter rather than the field, so place it on a
hand-written builder method that takes the list; a field annotation is not seen there.
// External JSON: { "consumers": { "orders": {...}, "refunds": {...} } }record KafkaConfig(@KeyedBy("name") List<KafkaConsumerConfig> consumers) {}The element type must declare a settable property matching the annotation value. A conflict between
an explicit JSON value and the injected key is a ConfigurationException; equal values are
accepted. The annotated value itself must be a JSON object: a scalar or an array, including an empty
array, is rejected, while an empty object binds to an empty list. The deserializer backing it ships
in dev.vertique:vertique-json.
JsonConfigPaths
Section titled “JsonConfigPaths”| Method | Semantics |
|---|---|
navigateObject(JsonObject root, String... segments) |
Tolerant subtree traversal: returns an empty JsonObject when a segment key is absent, and skips blank/null segments. Throws ConfigurationException when a segment key is present but bound to a non-object — a scalar, an array, or explicit JSON null. |
resolve(JsonObject root, String dottedPath) |
Strict dotted-path lookup returning a LookupResult(status, value, path, failingSegment). |
LookupStatus is PRESENT, MISSING, or INVALID_SHAPE. INVALID_SHAPE means traversal crossed
a non-object intermediate segment. PRESENT covers any leaf, including a JsonObject or
JsonArray — a caller expecting a scalar must check.
@ConfigMapper
Section titled “@ConfigMapper”Dagger @Qualifier for overriding the ObjectMapper that backs config parsing. Bind an
@ConfigMapper ObjectMapper anywhere in the application component; the config module reads it
optionally and re-layers the framework’s mandatory modules and lenient policy over it before first
use.
The seam is entirely optional — an application that binds nothing gets the framework’s lenient default. Because the framework finalizes the supplied mapper in place, do not share that instance concurrently for any other purpose.
ConfigSecretRenderer
Section titled “ConfigSecretRenderer”Shared redactor so every module’s config toString() masks the same things. MASK is "***", and
the input is never mutated — masking is applied to a copy used only for the returned string.
| Method | Behavior |
|---|---|
isSensitivePath(String dottedPath) |
true when the lower-cased path contains password, secret, token, passphrase, credential, jaas.config, user.info, private.key, keystore, or truststore; or when its separator-stripped, lower-cased form contains apikey, accesskey, or secretkey. |
redactBag(JsonObject bag) |
Renders a bag as a log-safe String, descending through nested objects and arrays and masking every leaf whose full dotted path is sensitive. |
redactUri(String uri) |
Masks authority userinfo (user:pass between // and @) and the value of any credential-bearing query parameter. Best-effort; never throws. |
Matching is substring-based in both forms, so ssl.trustStoreOptions.password and aws.access-key
are recognized at any depth.
ConfigTreeBuilder
Section titled “ConfigTreeBuilder”Builds the nested JsonObject the framework consumes from the flat dotted/bracketed keys an
embedding host exposes. build(Map<String, String> flatKeys) is purely syntactic — every leaf is
stored as a String, and type coercion is ConfigParser’s job downstream.
| Segment form | Interpretation |
|---|---|
Bare segment, including all-digit (2026) |
Object key |
[N], N a non-negative integer |
Array index |
[content], quoted or containing a dot |
Literal map key (dots preserved, surrounding quotes stripped) |
a.b=1, a.c=2 → {"a":{"b":"1","c":"2"}}years.2026.total=5 → {"years":{"2026":{"total":"5"}}}servers[0].host=h, servers[1].host=k → {"servers":[{"host":"h"},{"host":"k"}]}audit.bindings[http.server].dim[0]=x → {"audit":{"bindings":{"http.server":{"dim":["x"]}}}}Fail-fast rules, all raising ConfigurationException: array indices must be contiguous from 0
with no gap, duplicate, or mixing of [N] with object keys at the same node; a path used as both a
leaf and a parent collides; a null input map is rejected. Every message names offending
keys/paths only, never values. Input order is irrelevant — keys are processed in a stable sorted
order, so collision detection does not depend on map iteration order. A null value for a key is
stored as JSON null.
PropertyCondition
Section titled “PropertyCondition”Immutable record evaluated at runtime by code generated from @ConditionalOnProperty. It is public
and stable, but it is not for hand-written Dagger bindings — the annotation is codegen-only.
public record PropertyCondition(String name, String havingValue, boolean matchIfMissing) { public static boolean matchesAll(JsonObject config, PropertyCondition[] conditions);}| Component | Annotation default | Meaning |
|---|---|---|
name |
— (required) | Dotted config property path, e.g. feature.adminApi.enabled |
havingValue |
"true" |
Expected scalar; compared with String.valueOf(value).equals(havingValue) |
matchIfMissing |
false |
When true, a missing path counts as a match |
matchesAll ANDs the array and returns true for an empty or null array. A MISSING path
returns false unless matchIfMissing is set, in which case evaluation continues. An
INVALID_SHAPE path, or a present leaf that is a JsonObject/JsonArray, throws
ConfigurationException.
Failures, Constraints, and Common Mistakes
Section titled “Failures, Constraints, and Common Mistakes”Exception hierarchy
Section titled “Exception hierarchy”VertiqueException (RuntimeException)├── ValidationException — invalid input or data│ ├── BusinessRuleException — business/domain-rule violation│ │ └── DurableEncodeRejectedException — a durable-context encoder rejected the whole│ │ capture/merge operation│ └── MalformedDurableMetadataException — durable carrier or namespace body present but not a│ JSON object├── ConflictException — resource state conflict├── NotFoundException — resource absent├── ConfigurationException — startup/wiring/contract error├── TechnicalException — runtime/infrastructure failure│ └── UnavailableException — capability currently unavailable├── TooManyRequestsException — rate/quota limit exceeded (429 analogue of│ UnavailableException's 503 role) — extends│ VertiqueException directly, never ValidationException/│ BusinessRuleException, so an application's own│ ExceptionMapper<ValidationException> can never│ out-rank the 429 default└── VertiqueSecurityException — grouping root for the security family ├── UnauthorizedException — authentication required or credential invalid └── ForbiddenException — authenticated but not authorizedEvery root is concrete — it may be thrown directly or subclassed. The one exception is
VertiqueSecurityException: catch it to handle any security denial, but throw
UnauthorizedException or ForbiddenException instead of the grouping root. Prefer the most
specific applicable root; a new module exception should never extend raw RuntimeException.
HTTP mapping at a REST boundary
Section titled “HTTP mapping at a REST boundary”The default mapper in dev.vertique:vertique-rest-jaxrs registers these core types:
| Exception | Status |
|---|---|
ValidationException (and BusinessRuleException, BeanValidationException) |
400 |
UnauthorizedException |
401 |
ForbiddenException |
403 |
NotFoundException |
404 |
ConflictException |
409 |
TooManyRequestsException |
429 |
UnavailableException |
503 |
Throwable (fallback) |
500 |
Mapping is hierarchy-aware, so a subclass inherits its nearest registered ancestor’s status.
TechnicalException, ConfigurationException, and a bare VertiqueSecurityException have no
explicit registration and therefore reach the Throwable fallback as 500. ForbiddenException is
registered by fully-qualified name to avoid clashing with jakarta.ws.rs.ForbiddenException. The
REST defaults reference only core, JAX-RS, and REST-owned types — a database exception that escapes
a repository surfaces as 500, so translate it to a core semantic type at your service boundary.
Event-bus transport failures
Section titled “Event-bus transport failures”EventBusExceptionMapper translates Vert.x ReplyExceptions; the set of reply failures is a fixed
enum, so this mapper is not extensible. Non-ReplyException throwables pass through unchanged.
| Reply failure | Translated to | Root |
|---|---|---|
TIMEOUT |
EventBusTimeoutException |
TechnicalException |
NO_HANDLERS |
EventBusAddressUnavailableException |
UnavailableException |
RECIPIENT_FAILURE, ERROR |
EventBusDispatchException |
TechnicalException |
Each carries address(). Service-layer callers receive enriched subclasses from
dev.vertique:vertique-services that add the failing contract.
Resilience annotation semantics
Section titled “Resilience annotation semantics”The resilience annotation semantics, declaration fields, and retry bounds are documented by
dev.vertique:vertique-resilience and the consumer modules that enforce them. Core does not own the
runtime or annotation vocabulary.
Constraints and common mistakes
Section titled “Constraints and common mistakes”- Do not read the root
@VertxConfig JsonObjectoutside a boundary provider. Module internals take typed config records. Do not disambiguate same-typed bindings with@Named— declare a dedicated@Qualifier. - Do not use priority to emulate cross-phase ordering. Phase dominates priority absolutely in both ordering contracts.
- Give lambda-registered extensions a stable identity. The default
orderKey()of a lambda or method reference is a synthetic class name that is not stable across compilations, so ties break unpredictably. OverrideorderKey()or assign distinctpriority()values. - The dispatch codecs are local-only.
LocalMessageCodecthrowsUnsupportedOperationExceptionfrom both wire methods, so a clustered event bus fails loudly rather than degrading silently;transformreturns the object reference unchanged, which means no defensive copy occurs on a local hop. Registration of thedispatch.envelopeanddispatch.resultcodec names is owned by whichever deployment path an application installs —dev.vertique:vertique-services,dev.vertique:vertique-kafka-core, ordev.vertique:vertique-job-delayed— each tolerating an already-registered name. Core itself registers neither, soEventBusClientis usable only once one of those modules has deployed. PayloadSourcesnever copies. Copy the array yourself before callingbuffered(byte[], …)if you need immutability, and preferbufferedStream()overbufferedView()on hot paths for non-Bufferbackings.- A
VerticleDeploymentrequires a verticle phase. Constructing one withCONFIGURE,VALIDATE,MIGRATE, orAFTER_STARTis rejected at construction time. - Do not capture
VertiqueJson.mapper()in a Dagger provider or a static initializer. Both run before theCONFIGUREphase installs the system profile, so the captured instance is Vert.x’s raw mapper. Read it at use time, or capture it in an object built atCONFIGUREor later. - Do not configure Jackson by mutating a shared mapper. Contribute a
JsonMapperProfileand select it throughjson.systemProfileand/orjson.jsonProfile. Jackson forbids reconfiguring a mapper after first use, and the process codec has already parsed the application’s configuration by the time any startup step runs. - Do not implement
MethodMetadataorParameterMetadatain application code. They are provided by generated code and by the framework’s own scanners. - Never log a secret-bearing config record without redaction. Route the value through
ConfigSecretRendererin the record’stoString()rather than relying on@JsonIgnore, which on a record would also block deserialization and make the value unreadable from configuration.
Dependencies
Section titled “Dependencies”vertique-core depends on no other Vertique module — it is the root of the dependency graph, and
every compile-scope dependency it declares lands on every consumer’s classpath.
| Dependency | Why |
|---|---|
io.vertx:vertx-core |
Vertx, EventBus, Future, Buffer, JsonObject — the substrate every contract here is expressed in |
com.google.dagger:dagger |
@Module, @Provides, @Multibinds for VertxModule, HealthCheckModule, CoreLifecycleStepsModule |
jakarta.inject:jakarta.inject-api |
@Inject, @Singleton, @Qualifier, Provider on the injectable types and qualifiers |
jakarta.annotation:jakarta.annotation-api |
@Nullable on nullable record components, SPI parameters, and return values |
jakarta.ws.rs:jakarta.ws.rs-api |
Declared at compile scope; core’s own sources do not reference it, so every consumer receives the JAX-RS API on its classpath transitively |
com.fasterxml.jackson.core:jackson-databind |
ObjectMapper on the profile contracts and the process codec, and the serialization annotations on result records |
org.slf4j:slf4j-api |
Logging in VertiqueJson and the other core collaborators |
org.projectlombok:lombok |
provided scope, so it never reaches runtime; core’s own sources do not use it |