JSON
View MarkdownStatus: Stable Package:
dev.vertique.jsonArtifact:vertique-jsonDepends on: core API reference: javadoc.io
Runtime of the Vertique JSON mapper profile system: named, code-owned ObjectMapper configurations
that a framework boundary selects per resource method, REST client, or Kafka binding. The zero-config
default at every managed edge is the reserved vertique profile — the baseline recipe plus the
framework’s opinions. The reserved system profile is a copy of Vert.x’s DatabindCodec.mapper()
carrying just the baseline recipe, so it behaves like the stock Vert.x mapper for everything that
mapper could already handle, while adding Optional and java.time support; system is the
zero-config default installed as the process JSON codec, and is reachable at any managed edge by
explicit selection.
Renamed: the reserved raw profile was called
vertx; it is nowsystem. The idvertxis retired — configuring it, or contributing an application profile under it, fails startup with a message naming the rename. ChangejsonProfile: vertxtojsonProfile: system. This is not a pure rename in effect: see thesystemrecipe for the one wire-shape delta.
The profile contracts (JsonProfileId, JsonMapperProfile, JsonMapperProfileRegistry,
JsonProfileConfigurationException, @JsonProfile, @KeyedBy) live in dev.vertique.core.json, in
dev.vertique:vertique-core, so a boundary module can reference them without depending on this
artifact. This module supplies the registry, the three built-in profiles, the opinionated-defaults
helper, the opt-in strict serdes, the keyed-collection Jackson support, the Jackson-backed
wire-name projection every Jackson-bound transport hands to input processing, and the Dagger wiring.
When To Use It
Section titled “When To Use It”Install dev.vertique:vertique-json when the application needs a JSON wire shape other than Vert.x’s
stock mapper — ISO-8601 java.time, JDK8 Optional support, or a strict decimal/string posture — or
when it needs to contribute its own named mapper profile.
Most applications never add the dependency directly. The boundary modules
(dev.vertique:vertique-rest-jaxrs, dev.vertique:vertique-rest-client,
dev.vertique:vertique-kafka-json) install JsonRuntimeModule through their own Dagger modules,
dev.vertique:vertique-config-core depends on this artifact for keyed-collection config parsing, and
dev.vertique:vertique-rest-websocket depends on it for JacksonFieldNameResolver alone (it binds
messages through Vert.x’s shared mapper and installs no profile registry).
Core Concepts
Section titled “Core Concepts”Profiles
Section titled “Profiles”A profile is an id paired with a fully configured ObjectMapper. The registry holds every
profile discovered at startup and resolves an id to its mapper in constant time. A profile mapper is
exposed exactly as supplied — never copied, wrapped, or mutated by the framework.
Three profiles are always registered:
| Id | Recipe | Wire posture |
|---|---|---|
system |
JacksonDefaults.applySystem(DatabindCodec.mapper().copy()) |
The baseline every other profile layers on. Byte-identical to the raw Vert.x mapper for every type that mapper already handled, except java.util.Date/Calendar (epoch millis → ISO-8601); Optional and java.time start working. Inherits Vert.x’s ALLOW_COMMENTS and its vertx.jackson.defaultRead* stream-read limits. No inclusion, number, or enum opinion. The shared mapper itself is never exposed or mutated |
vertique |
JacksonDefaults.applySystem(new ObjectMapper()) then JacksonDefaults.applyOpinionated(mapper) — i.e. JacksonDefaults.apply(new ObjectMapper()) |
The baseline recipe plus the opinions (see below); BigDecimal and String stay stock. Seeded from a fresh mapper, so unlike system it does not accept JSON comments |
vertique-strict |
vertique plus the strict overlay |
BigDecimal on the wire as a bounded JSON string; no scalar→String coercion |
All three ids are reserved: an application profile that claims one fails startup. So does the
retired vertx id.
Each profile’s mapper() returns one stable instance — the same reference on every call.
Selecting a profile
Section titled “Selecting a profile”A profile is selected by the @JsonProfile annotation, by a per-binding configuration value, or by a
default configuration key. Each boundary resolves the first non-blank tier and falls through to
vertique:
| Boundary | Precedence, highest first |
|---|---|
| JAX-RS request + response | method @JsonProfile → class @JsonProfile → jaxrs.jsonProfile → json.jsonProfile → vertique |
| REST client | explicit objectMapper → restClient.<name>.jsonProfile → builder jsonProfile(...) → interface @JsonProfile → restClient.defaults.jsonProfile → json.jsonProfile → vertique |
| Kafka consumer | kafka.consumers.<n>.jsonProfile → @JsonProfile on the @KafkaListener type / KafkaConsumerBinding.jsonProfile(...) → kafka.jsonProfile → json.jsonProfile → vertique |
| Kafka producer | kafka.producers.<n>.methods.<m>.jsonProfile → kafka.producers.<n>.jsonProfile → @JsonProfile on the @KafkaProducer type → kafka.jsonProfile → json.jsonProfile → vertique |
json.jsonProfile is the global default tier — the floor applied at every boundary when no
more-specific value is configured.
Configuration
Section titled “Configuration”| Key | Type | Default | Description |
|---|---|---|---|
json.jsonProfile |
string | (unset) | Global default profile id for every JSON boundary. null or blank means the vertique profile at managed edges. An id that names no registered profile — including the retired vertx — fails startup. |
json.systemProfile |
string | (unset) | Profile installed as the process JSON codec’s mapper, read through JsonConfig.effectiveSystemProfile() and consumed by the CONFIGURE-phase install step below. null or blank means the reserved system profile. An unknown id — including the retired vertx — fails the boot there. |
{ "json": { "jsonProfile": "vertique", "systemProfile": "system" }}JsonConfig carries both keys verbatim; the floors live in its accessors, which never return null
and never validate: effectiveProfile() floors at vertique and effectiveSystemProfile() floors at
system. The one-argument constructor is a convenience for (jsonProfile, null).
Setting json.jsonProfile activates that profile as the global default everywhere. Per-boundary and
per-binding overrides remain fully operational. Any registered id is valid here — the three built-ins
and every application-contributed id alike.
The value is validated unconditionally during the VALIDATE startup phase, so a typo fails fast even
when every active binding overrides the global default, and even when no boundary has any active
bindings at all. Each boundary validates its own default key (jaxrs.jsonProfile,
restClient.defaults.jsonProfile, kafka.jsonProfile) the same way.
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
owns that codec and exposes it as VertiqueJson; this module owns the startup step that decides
which profile it runs.
JsonRuntimeModule contributes a CONFIGURE-phase ApplicationStartupStep that resolves
json.systemProfile (floor: system) through the registry and installs that profile’s mapper with
VertiqueJson.install(id, mapper). CONFIGURE is the first startup phase, so every later step,
validator and verticle already sees the installed codec. An application graph that does not include
JsonRuntimeModule never installs anything and keeps Vert.x’s raw JSON behavior — where
Json.encode(LocalDate.now()) still fails.
The step fails the boot with JsonProfileConfigurationException, naming the profile and the remedy,
when:
| Check | Failure |
|---|---|
| Unknown or retired id | json.systemProfile names no registered profile; the retired vertx id names the rename to system. |
| Codec ownership | Vert.x did not select the framework’s codec, so installing a mapper would change nothing. The message names the actual Json.CODEC class and points at META-INF/services visibility (a shaded jar must merge service files) and competing io.vertx.core.spi.JsonFactory registrations. |
| Vert.x Jackson module | The profile’s mapper does not register VertxJsonSupport.module(), so JsonObject, JsonArray and Buffer values would be bean-serialized instead of round-tripping. Build the mapper from the sanctioned seed. |
| Default typing | The profile’s mapper activates Jackson default typing, which would let any payload the process decodes choose the type it instantiates. |
Anything about the selected profile that accepts more than the baseline system recipe is logged
at WARN before the install — FAIL_ON_UNKNOWN_PROPERTIES off, unknown-enum and case-insensitive-enum
leniency, parser-leniency features system leaves off, and any stream-read limit weaker than Vert.x’s.
Those settings then apply to every payload the process decodes, including payloads that no
request-validation gate has seen.
Installation is keyed by profile id, so two applications booted in one JVM under the same effective
system profile both start (the second install swaps to its own equivalent instance and logs the swap at
INFO); a different id fails the second boot naming both ids.
Selecting a non-default system profile changes the whole process. With
json.systemProfile: vertique, every Json.encode in the process omits nulls, JsonObject parsing
binds JSON floats to BigDecimal, JsonObject.encode() drops explicit nulls, and the enum leniency of
that profile applies to every process-codec path. That reaches durable and cross-service formats —
correlation envelopes, workflow state, cache entries — so treat it as a wire-format decision.
The system Profile
Section titled “The system Profile”The reserved baseline, and the zero-config default for the process JSON codec (managed edges default
to vertique instead — see Selecting a profile). JacksonDefaults.applySystem(mapper)
applies its recipe; the system profile is exactly its output on DatabindCodec.mapper().copy():
| # | Baseline behavior | Mechanism |
|---|---|---|
| 1 | java.time → ISO-8601, original offset/zone preserved |
Register JavaTimeModule; disable WRITE_DATES_AS_TIMESTAMPS; disable ADJUST_DATES_TO_CONTEXT_TIME_ZONE; re-register Vert.x’s Jackson module last so Vert.x’s Instant serializer stays authoritative |
| 2 | JDK8 Optional / OptionalInt / OptionalLong / OptionalDouble → contained value, both directions |
Register Jdk8Module before the Vert.x re-registration. java.util.stream types are serialize-only |
| 3 | Operator-tuned read limits survive | Copy the raw Vert.x factory’s StreamReadConstraints (vertx.jackson.defaultRead*) onto the mapper’s factory |
That is the whole recipe: no inclusion, number, or enum opinion is applied. Everything the raw Vert.x
mapper already handled — JsonObject, JsonArray, Buffer, byte[], Instant, plain POJOs
including their null-valued properties — serializes byte-identically under system.
The one delta from the raw Vert.x mapper. java.util.Date and java.util.Calendar move from
epoch millis to ISO-8601:
| Value | raw Vert.x mapper | system (and vertique) |
|---|---|---|
new Date(0L) |
0 |
"1970-01-01T00:00:00.000+00:00" |
Disabling WRITE_DATES_AS_TIMESTAMPS is what makes java.time render ISO-8601, and it reaches the
legacy date types too. Date support is the point of the recipe, so the delta is accepted and pinned by
a compatibility-matrix test. A consumer that must keep reading epoch millis is unaffected on the read
side — Jackson still accepts a numeric timestamp.
Comment leniency is inherited. Because system is a copy() of Vert.x’s mapper, it keeps
JsonParser.Feature.ALLOW_COMMENTS, exactly as the raw mapper did. vertique is seeded from a fresh
ObjectMapper instead, so it still rejects JSON comments at the body-binding boundary.
system is a snapshot, not the live shared mapper. The copy is taken when the profile registry is
first constructed, before the install step points the process codec at it. Anything registered on DatabindCodec.mapper() before that moment is inherited;
anything after it is not — the shared mapper and system never share state. The registry refuses to
seed system when the copied mapper carries Jackson default typing, so a classpath library that
activated it on the shared mapper fails the boot instead of feeding a polymorphic mapper into every
profile role.
Where system binds. Through the registry (registry.mapper(JsonProfileId.SYSTEM), the cache
stores, @JsonProfile("system") resolved by the registry) it is the recipe above. It is also the
zero-config process JSON codec: the install step above puts this profile’s mapper behind every
Json.* and JsonObject operation, so Optional and java.time work there and java.util.Date
renders ISO-8601. Every managed edge — REST request body/response, rest-client, Kafka JSON, and MCP —
resolves its effective id, system included, through this same registry; none of them special-cases
the reserved id or reads DatabindCodec.mapper() directly. The managed-edge zero-config floor is
vertique, not system; reach system there by explicit selection at any tier. On the REST edges
the resolved mapper collapses to “no override” (the Vert.x fast path) exactly when it is the same
instance as the process codec’s mapper — which an explicit system selection is, in a booted
application under the defaults — so Optional/java.time work and java.util.Date renders ISO-8601
at those edges too.
The vertique Profile
Section titled “The vertique Profile”Opt in by selecting vertique at any tier above. JacksonDefaults.apply(mapper) is exactly
applySystem(mapper) followed by applyOpinionated(mapper), and the vertique profile is exactly its
output on a fresh ObjectMapper. Rows 1–2 below come from the baseline half, rows 3–6 from the
opinionated half:
| # | Default | Mechanism |
|---|---|---|
| 1 | java.time → ISO-8601, original offset/zone preserved |
Register JavaTimeModule; disable WRITE_DATES_AS_TIMESTAMPS; disable ADJUST_DATES_TO_CONTEXT_TIME_ZONE; re-register Vert.x’s Jackson module last so Vert.x’s Instant serializer stays authoritative |
| 2 | JDK8 Optional / OptionalInt / OptionalLong / OptionalDouble → contained value, both directions |
Register Jdk8Module before the Vert.x re-registration. java.util.stream types are serialize-only — a Stream field writes as a JSON array; deserialization is deliberately not provided, because a stream is one-shot |
| 3 | Empty optionals omitted from serialized bean/record properties | Per-type configOverride(...).setInclude(NON_ABSENT) on all four Optional* types, with the value-inclusion slot left at USE_DEFAULTS so the mapper-wide NON_NULL still governs contained values |
| 4 | Unknown enum string → @JsonEnumDefaultValue constant |
Enable READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE; an enum without that annotation still rejects unknown values |
| 5 | Decimal JSON numbers → BigDecimal |
Enable USE_BIG_DECIMAL_FOR_FLOATS |
| 6 | Null-valued fields omitted on serialization | setSerializationInclusion(NON_NULL) |
BigDecimal serialization stays at Jackson’s default (a JSON number) and String coercion is left
stock, so both round-trip through this profile with no wire-shape change.
applyOpinionated alone is rows 3–6; layer it on applySystem to get vertique’s configuration on a
mapper you seeded yourself.
Invariants & Gotchas
Section titled “Invariants & Gotchas”apply mutates and returns the mapper it is given; the six defaults above plus the
StreamReadConstraints copy are its only persistent changes. It is safe to call on a mapper that already has Vert.x’s Jackson module registered — the
Vert.x serializers still end up authoritative.
Empty-optional omission is narrowed to bean and record properties:
- an empty optional held in a bean or record property is omitted from the output;
- a root-level empty optional serializes as JSON
null— there is no property to omit; - an empty optional inside a collection or as a map value serializes as a
nullelement or value; element inclusion is not governed by the property-level override; - a missing JSON property binds to
Optional.empty()only through creator/record binding. On a mutable POJO an unset setter or field is left at its Javanulldefault. An explicit JSONnullbinds toOptional.empty()on both shapes; - an explicit
@JsonIncludeon a property wins over the override —@JsonInclude(ALWAYS)makes an empty optional serialize as JSONnull.
The vertique-strict Profile
Section titled “The vertique-strict Profile”vertique-strict is the vertique defaults plus a strict decimal and string wire posture. Select it
the same way — @JsonProfile("vertique-strict"), a per-binding value, or json.jsonProfile.
Wire contract.
| Target | Written as | Read from |
|---|---|---|
Typed BigDecimal property |
Quoted JSON string via toPlainString(), never scientific notation |
JSON string only — a bare JSON number raises MismatchedInputException |
Map<BigDecimal, ?> key |
The same bounded plain form ("0.0000001", not "1E-7") |
The same bounded grammar, so the profile always reads back its own output |
String property |
Unchanged | VALUE_STRING only — a JSON number or boolean fails instead of becoming "42" / "true" |
Untyped decimal (Object, Map<String, Object>, JsonObject) |
JSON number | JSON number, bound as double |
The accepted decimal grammar is bounded on both sides: plain decimals matching
-?[0-9]+(\.[0-9]+)?, at most 100 characters. Exponent forms ("1e5"), a leading +, ".5",
and "1." are rejected on read; a value whose plain form would exceed the bound is rejected with a
JsonMappingException before serialization, however that value was constructed. Because the paired
serializer never emits exponent notation, the bound costs no round-trip fidelity.
Numerical equality always survives a round trip. Scale is preserved exactly for a non-negative
scale ("1.50" re-reads with scale 2). A negative-scale value — including a negative-scale zero
— is written in expanded plain form (new BigDecimal("1E+2") writes "100") and re-reads with scale
0.
Clients of a vertique-strict endpoint must expect decimals as JSON strings, not numbers.
A repeated identical key is a parse error. This profile’s mapper enables
JsonParser.Feature.STRICT_DUPLICATE_DETECTION; system and vertique are unchanged and keep the
last value. Because a REST route parses raw body bytes with its effective profile’s mapper, a route
under vertique-strict answers 400 to {"quantity":1,"quantity":2} before the resource runs. Where
that mapper is also the installed process codec — an application setting
json.systemProfile: vertique-strict — the binder swallows the parse rejection and binds the raw
buffer instead, and under web-validation with a synthesized body schema the gate then refuses that
buffer, so the client still receives a 400 but its detail names an internal buffer class rather than
the repeated key. Installing this profile as the process codec also makes that codec’s delegated
decode methods — new JsonObject(String) and new JsonObject(Buffer), and Json.decodeValue in
its buffer, string, and typed forms — reject a repeated key anywhere in the process, while the
streaming Json.CODEC.fromString(String) and Json.CODEC.fromBuffer(Buffer) overloads and the
static DatabindCodec parser helpers keep accepting it, exactly the exclusion that codec’s own
contract documents. The MCP envelope codec already rejects a repeated key under every profile.
The same feature is the signal the schema generator reads to decide how several @JsonAlias
spellings of one property are described: under this profile a body carrying a property under two
spellings is rejected by the gate, while under system and vertique it is accepted. No separate
flag or configuration key selects that; a mapper is strict or it is not. The binder itself accepts
several spellings under every profile, because two different key names are not a repeated key.
Declared JSON Schema override. vertique-strict declares exactly one entry from
JsonMapperProfile#jsonSchemaTypeOverrides(): a BigDecimal override applying to both the input and
output construction directions, whose fragment is
{"format":"decimal","maxLength":100,"pattern":"^-?[0-9]+(\\.[0-9]+)?$","type":"string"}The fragment is built from the same BigDecimalStrictStringDeserializer grammar constants the
deserializer itself enforces — the bound and the pattern are never duplicated as a second literal —
so a schema generator consuming this profile’s overrides cannot drift from the wire grammar above
(FR-JSON-089). The system and vertique built-in profiles declare no overrides.
Invariants & Gotchas
Section titled “Invariants & Gotchas”Untyped decimals deliberately stay JSON numbers. USE_BIG_DECIMAL_FOR_FLOATS is disabled on this
profile so documents the application merely relays keep their wire shape — number in, number out. Were
it enabled, an untyped decimal would bind to BigDecimal and be re-serialized as a string, silently
restringing pass-through payloads. The cost is a precision boundary: untyped decimals bind as
double, so a value beyond IEEE-754 double precision loses digits when relayed. Declare the property
as BigDecimal whenever arbitrary precision matters.
Rejection messages never echo the submitted value. Every bound violation this profile raises —
value or key, read or write — names only the bound and the offending length, scale, or precision. The
one boundary outside the profile’s control is Jackson’s own WRAP_EXCEPTIONS reference-chain
wrapping, which appends a rejected map key’s toString() form to the mapping exception’s message. In
this framework that message reaches logs only; the REST response body is replaced with a fixed
fallback.
Request-body validation follows this profile’s wire shape. The default web-validation
strategy synthesizes every route’s request-body schema through that route’s effective JSON profile,
so this profile’s input type overrides are in the schema the gate enforces. On a route whose
effective profile is vertique-strict, a BigDecimal property is typed as a string carrying
this profile’s decimal grammar and length bound: the strict string form is accepted, while a JSON
number, an exponent form, an over-length value, and a grammar-invalid value are all rejected with a
400 by the gate, before the resource runs and before this profile’s deserializer sees them. Routes
on a profile that declares no BigDecimal override keep the JSON-number shape. Selecting the
profile is the whole configuration — no annotation, schema registry, extra binding, or change of
validation strategy is involved. This applies to request bodies only: response serialization is
unaffected, and a BigDecimal bound as a query, path, header, or cookie parameter is typed as a
string already.
Remove @Schema(implementation = String.class) when you adopt this profile. That per-property
redirect was the way to make the older, profile-agnostic gate accept a decimal string. It is now
rejected: on a route whose effective profile declares an override for the property’s declared type,
a non-default @Schema(implementation = ...) fails router construction, naming the operation and
the property, and the mount is never installed. The trigger is configuration-only —
json.jsonProfile: vertique-strict, or a @JsonProfile selection of this profile, on an
application whose DTOs still carry the redirect. Delete the annotation; the profile supplies the
string form together with the grammar and length bound the annotation never carried. On a route
whose effective profile declares no override for that type, the redirect applies exactly as before.
Because the schema source runs for every operation whatever validation strategy is selected, this
failure is not confined to web-validation.
The build-time spec generator in dev.vertique:vertique-rest-openapi-plugin is a different
generator with different annotation support; the runtime body-schema synthesizer described here is
the one the web-validation gate enforces.
Key Classes
Section titled “Key Classes”JacksonDefaults
Section titled “JacksonDefaults”Configures any ObjectMapper, in two composable halves.
| Method | What it applies |
|---|---|
applySystem(mapper) |
The baseline recipe: Jdk8Module, JavaTimeModule, ISO-8601 dates, Vert.x’s Jackson module re-registered last, and the raw Vert.x factory’s StreamReadConstraints. No opinions. |
applyOpinionated(mapper) |
The opinions: NON_NULL inclusion, NON_ABSENT for the four Optional* types, USE_BIG_DECIMAL_FOR_FLOATS, READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE. Registers no module. |
apply(mapper) |
applySystem then applyOpinionated — the definition of the vertique profile. |
Each mutates and returns the mapper it is given.
The sanctioned seed for an application profile is JacksonDefaults.applySystem(new ObjectMapper()),
then whatever the application needs. It gives the profile the baseline type support and the operator’s
stream-read limits without inheriting any framework opinion. Do not seed a profile from
DatabindCodec.mapper() directly — that bypasses the baseline.
// Baseline + the application's own posture.ObjectMapper mapper = JacksonDefaults.applySystem(new ObjectMapper());mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
// Or: the vertique posture plus customizations.ObjectMapper opinionated = JacksonDefaults.apply(new ObjectMapper());JsonMapperProfiles
Section titled “JsonMapperProfiles”Factory pairing an id with a mapper: JsonMapperProfiles.of(JsonProfileId, ObjectMapper). Both
arguments must be non-null. The mapper is exposed as-is — the factory never copies or mutates it.
An overload additionally declares JSON Schema type overrides:
JsonMapperProfiles.of(JsonProfileId, ObjectMapper, Collection<JsonSchemaTypeOverride>). All three
arguments must be non-null and the collection must not contain a null element; the collection is
defensively copied into an immutable list, so the caller’s collection may be freely mutated after the
call returns, and the returned profile’s jsonSchemaTypeOverrides() is unmodifiable and stable —
the same list instance on every call. The two-argument overload’s behavior is unchanged: it declares
no overrides.
VertxJsonSupport
Section titled “VertxJsonSupport”VertxJsonSupport.module() returns a fresh Vert.x Jackson module. Register it on a custom profile
mapper so JsonObject and JsonArray round-trip with their values intact rather than binding to an
empty object. This helper exists so an application references a stable framework symbol instead of the
Vert.x class name directly.
Opt-in serdes
Section titled “Opt-in serdes”Three public serdes are available for an application-owned profile mapper. None is registered by
JacksonDefaults or the vertique profile. All three are already active on vertique-strict — use
them standalone when only one is wanted, or when they must sit on a mapper carrying other
customizations.
| Class | Behavior |
|---|---|
BigDecimalAsStringSerializer |
Writes BigDecimal as a quoted JSON string via toPlainString(), bounded to 100 characters; rejects an over-length value with a value-free JsonMappingException |
BigDecimalStrictStringDeserializer |
Accepts only a JSON string matching -?[0-9]+(\.[0-9]+)? of at most 100 characters; every other token raises MismatchedInputException naming only the rejected length |
StrictStringDeserializer |
Rejects scalar→String coercion: only VALUE_STRING is accepted |
The two BigDecimal serdes are a matched pair — activating either alone produces an asymmetric
wire shape. The 100-character bound is deliberate: Jackson’s StreamReadConstraints number limits do
not apply to string tokens, so a short exponent literal would otherwise select an enormous scale that
toPlainString() expands into gigabytes on the way out. Registering the pair changes the wire shape,
so clients must expect a string rather than a number.
SimpleModule strict = new SimpleModule("payments-strict");strict.addSerializer(BigDecimal.class, new BigDecimalAsStringSerializer());strict.addDeserializer(BigDecimal.class, new BigDecimalStrictStringDeserializer());strict.addDeserializer(String.class, new StrictStringDeserializer());mapper.registerModule(strict);Keyed-collection support (dev.vertique.json.keyed)
Section titled “Keyed-collection support (dev.vertique.json.keyed)”KeyedCollectionModule is a Jackson SimpleModule that deserializes a @KeyedBy-annotated
List<T> field, or List<T> builder setter method, from a keyed JSON object
({"orders": {…}, "refunds": {…}}), injecting each entry key into the named identity property of the
element. The @KeyedBy annotation itself lives in
dev.vertique.core.json, in dev.vertique:vertique-core. Register the module on any mapper that must
bind keyed collections — dev.vertique:vertique-config-core does so automatically on the config
mapper, so typed config records need no application wiring:
mapper.registerModule(new KeyedCollectionModule());The annotated value itself must be a JSON object: a scalar or an array, including an empty array, is
rejected with a mapping failure that names the identity property and the JSON type, never the value; an empty
object binds to an empty list and JSON null to no value. Entry keys must be non-blank and entry values
must be JSON objects. If an element already declares the
identity property with a different value, a ConfigurationException is raised naming the entry key
and property — never the conflicting values; an equal value is accepted.
KeyedCollectionDeserializer.injectKey(String key, JsonNode value, String identityProp, Map<String, Object> fixedProps) is public so a caller can apply the same injection and conflict rules
outside Jackson binding; it returns the prepared element node.
JacksonFieldNameResolver
Section titled “JacksonFieldNameResolver”Projects the wire property names a body is keyed by onto the Java property names the
input-processing engine keys its per-field policies on. Implements
dev.vertique.core.sanitization.InputFieldNameResolver.
Input processing (@Canonicalize / @Sanitize) resolves each field’s declared chain by the Java
property name, while an intermediate parsed from the wire is keyed by whatever Jackson published —
@JsonProperty("user_name"), a SNAKE_CASE naming strategy, @JsonNaming, a mix-in, a
@JsonAlias, or a name an AnnotationIntrospector a registered module installed produced. Without
the projection, a policy declared on a renamed field silently never runs. The resolver lives here
because the projection is a pure function of the ObjectMapper that binds the body, so REST and
WebSocket share one implementation rather than each deriving their own. Because the contract is
declared in vertique-core, implementing it adds no dependency on vertique-input-processing —
this module never sees the processing engine.
// The mapper that materializes the body decides the projection.JacksonFieldNameResolver resolver = JacksonFieldNameResolver.forMapper(mapper);
// forRoute(null) means "no profile selected": the process codec's mapper, VertiqueJson.mapper().JacksonFieldNameResolver unprofiledRoute = JacksonFieldNameResolver.forRoute(null);
// Compose one owner type's projection at registration, never on the request path.resolver.precompute(RenamedDto.class);
// In practice a transport does not call precompute itself. From its own module — which does depend// on the engine — it hands the declared body or message type over, and the engine calls precompute// once per owner type it may consult:// objectProcessor.precomputeFieldNameResolution(orderBodyType, resolver);
resolver.logicalName(RenamedDto.class, "user_name"); // -> "userName"resolver.logicalName(RenamedDto.class, "unknown"); // -> "unknown" (the projection is total)| Behavior | Contract |
|---|---|
| Source of names | DeserializationConfig.introspect(JavaType) — never an inference about how the mapper is configured |
| Primary vs alias | A primary name always claims its key; an alias fills only keys no primary claims — matching what Jackson itself binds |
| Colliding primary names | ConfigurationException naming the type, the wire name, and both Java properties |
| Two properties claiming one alias | ConfigurationException naming the type, the alias, and both Java properties. Jackson resolves such a collision in hash order while a projection built from findProperties() resolves it in declaration order, so the property whose policies are applied and the property Jackson binds could differ non-deterministically. An alias colliding with another property’s primary name is not this case: the primary claims the key and the alias is silently unclaimed, exactly as Jackson binds it |
| Two Java fields Jackson merged | ConfigurationException naming the type, the published wire name, and both Java properties. For class Dto { String a; @JsonProperty("a") String b; } Jackson produces one property that publishes a’s implicit name while binding writes the field b, so only one property reaches the collision check above. Projecting the published name would apply a’s declared policies to a key written into b, and a is never bound at all |
| A case-insensitive mapper | With MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES enabled the projection is keyed by the Locale.ROOT case-folded wire name and folds each lookup the same way, so SECRET selects the policies of the property Jackson binds it to. The identity short circuit is never taken against such a mapper. Against an exact-match mapper a differently-cased key is returned unchanged, because Jackson does not bind it either |
@JsonUnwrapped members |
Reported through promotedFields(Class) and promotedField(Class, String), keyed by the wire name the mapper binds — a promoted key is never added to the projection, so it can never shadow a same-named field of the enclosing type. A prefix or suffix is applied through Jackson’s own NameTransformer and nested unwrapping chains them. Members reached through a getter or setter are promoted like field-backed ones. Claims on one key resolve as Jackson binds them: a bean’s own property claims a key before anything unwrapped into it, at every depth, and within one level a primary name claims before an alias. An inner field’s @JsonAlias is promoted through a bare unwrap only — Jackson does not apply a prefix or suffix to aliases. Two members at one depth claiming one key fail composition naming the owner being composed, because Jackson feeds both from the same value |
| Bound Java names | boundJavaNames(Class) returns the internal name of every property the mapper’s deserialization introspection reports — the names Jackson derives from the fields, accessors and creator parameters it finds. A field reached only through an accessor of a different implicit name is not among them, nor is an ignored or transient field, which is what lets the engine refuse at startup a declared policy the mapper would never reach. It is null for a type the mapper binds outside its declaration view — a custom deserializer, a builder, or a delegating creator — so such a type is trusted rather than refused. That trust is a documented limitation for builders: a governed field a hand-written builder writes under a differently named method is not detected, and its policy silently never runs. Lombok’s @Builder @Jacksonized names its methods after the fields and is unaffected; name builder methods after the fields they write. The detection reads the class’s own @JsonDeserialize(using = …) only: a deserializer registered through a module or declared on the referencing field leaves the type enumerated from a declaration view Jackson does not use, so a governed field named differently from what the introspection reports is refused at startup rather than trusted. Declare the deserializer on the class unroutableWireNames(Class) names the keys bound only through a creator parameter with no linked field or setter and no declared field of the parameter’s own name: @JsonCreator Dto(@JsonProperty("street_name") String s) binds street_name into a field Jackson cannot name. Name the parameter after the field (@JsonProperty("streetName")), or give the field the same @JsonProperty name so Jackson links the two under the field’s own name. Record components are always linked. A key the owner ignores (@JsonIgnoreProperties) is never promoted, because Jackson drops it before any unwrapped member sees it |
| Polymorphic subtypes | polymorphicSubtypes(Class) is non-empty only for a type carrying @JsonTypeInfo (the annotation introspector reports a type resolver for it). It returns what the mapper’s subtype resolver lists by type id — @JsonSubTypes entries and subtypes registered on the mapper — without the type itself, and reads class annotations only, so it never composes the property projection and cannot raise a property-name collision. The input-processing engine uses it to refuse a base whose subtype declares an input policy the base does not. Subtypes resolved only from a runtime type id are not listed, and neither are other ways of retargeting the bound class: @JsonTypeInfo on a field or parameter, a defaultImpl outside @JsonSubTypes, @JsonDeserialize(as) / contentAs, an abstract-type mapping on the mapper, or a custom type-id resolver |
| Unknown wire name | Returned unchanged — the projection is total and never throws for an unrecognized key |
| Caching | One ClassValue-cached projection per (mapper, type); entries are collected with the DTO’s classloader |
| When the projection is composed | precompute(Class) composes it for one owner type at registration. It is the InputFieldNameResolver SPI hook the input-processing engine calls — once per owner type it may pass to logicalName while processing a declared body or message type — from InputObjectProcessor.precomputeFieldNameResolution(Type, InputFieldNameResolver). So logicalName neither introspects nor raises anything on the request path, and a name collision fails startup instead of failing every request that touches the type |
| Which types get composed | Decided by the engine, not by this module: the resolver composes exactly the class it is handed and walks no type graph of its own. See the vertique-input-processing reference for the owner set precomputeFieldNameResolution prepares and the shapes it cannot reach statically |
| Identity short circuit | When the computed projection maps every wire name onto itself, logicalName returns the wire name directly and no per-field lookup happens |
Nested types are covered, but that coverage is the engine’s postcondition rather than this module’s:
the engine consults the projection of the owner of every nested fragment, and it is
precomputeFieldNameResolution that enumerates those owners and calls precompute for each. A type
reached only through a shape the declared types do not name — a Map- or Object-typed field’s
runtime value, or a @JsonTypeInfo subtype — is still composed lazily on first use, because no
static walk can name it.
Instances are immutable and safe for concurrent use from several event-loop threads. A mapper selected for a mounted boundary is treated as immutable afterwards: an application that mutates a process-global mapper must rebuild the router.
Boundaries that process a bare String (a query, header, path, or form parameter) use
InputFieldNameResolver.IDENTITY instead — there is no object whose fields could be renamed.
Extension Points
Section titled “Extension Points”JsonMapperProfile
Section titled “JsonMapperProfile”Contribute a profile by declaring an @IntoSet JsonMapperProfile binding in a Dagger @Module.
@Moduleabstract class PaymentsModule {
@Provides @Singleton @IntoSet static JsonMapperProfile strictPaymentsProfile() { // Sanctioned seed: the baseline recipe on a fresh mapper (this also registers Vert.x's // JSON type support, so JsonObject and Buffer serialize correctly). ObjectMapper mapper = JacksonDefaults.applySystem(new ObjectMapper()); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true); return JsonMapperProfiles.of(JsonProfileId.of("strict-payments"), mapper); }}The profile id must not be system, vertique, vertique-strict, or the retired vertx, and the
mapper must not have Jackson default typing active — the registry rejects both at construction.
Select it anywhere a profile id is accepted — @JsonProfile("strict-payments"), a per-binding config
value, or json.jsonProfile.
Consumer modules install JsonRuntimeModule via @Module(includes = JsonRuntimeModule.class), so an
application using a REST, REST-client, or Kafka boundary does not add it to the component itself.
Startup Validation
Section titled “Startup Validation”All profile validation happens eagerly, when the registry is first constructed. Every failure raises
JsonProfileConfigurationException and prevents the application from serving traffic.
| Check | Failure |
|---|---|
| Reserved id | An application profile claiming system, vertique, or vertique-strict is rejected. |
| Retired id | An application profile claiming vertx is rejected with a message naming the rename to system; so is a configured vertx id at any tier. |
| Default typing | An application profile whose mapper has activateDefaultTyping(...) active is rejected — every profile binds untrusted input. Annotation-driven @JsonTypeInfo stays allowed. |
| Uniqueness | Two application profiles sharing an id are rejected. |
| Round-trip probe | Every mapper — the three built-ins included — serializes and deserializes a representative JsonObject/JsonArray (nested object, nested array, string, number, boolean) and must preserve structure. The payload carries no null-valued field: omitting nulls is a serialization-inclusion policy, not structural corruption, and NON_NULL is exactly what JacksonDefaults.applyOpinionated sets. A profile seeded from JacksonDefaults.apply therefore passes, as this document promises. |
| Configured default | A non-blank json.jsonProfile (or a per-boundary default key) that names no registered profile is rejected during the VALIDATE phase. |
Resolving an unknown id at runtime throws JsonProfileConfigurationException with a message listing
every registered id.
The wire-name projection validates separately, at boundary registration rather than at registry
construction, and raises ConfigurationException. Its three rejections are in the
JacksonFieldNameResolver table above: two properties claiming one primary wire name, two
properties claiming one unclaimed alias, and two Java fields Jackson merged into a single property.
Forcing validation without a JSON boundary
Section titled “Forcing validation without a JSON boundary”The registry is a Dagger @Singleton, so it is constructed on first access. An application with a
REST, REST-client, or Kafka boundary gets that access during startup automatically. An application
with none of those boundaries gets it from this module’s CONFIGURE-phase install step, which
resolves the system profile through the registry. An application that drives startup by hand, without
the lifecycle runner, must force the access itself:
@Overridepublic Future<Void> start() { AppComponent c = DaggerAppComponent.create(); c.jsonMapperProfileRegistry(); // force eager validation of profiles // ... deploy HttpVerticle, etc.}A validation failure then propagates out of start() and the verticle never becomes live.
Module Dagger Bindings
Section titled “Module Dagger Bindings”JsonRuntimeModule provides:
| Binding | Notes |
|---|---|
Set<JsonMapperProfile> |
@Multibinds seed — the application-contributed profile set, possibly empty. The built-ins are not members. |
JsonMapperProfileRegistry |
Bound to the validating default implementation. |
JsonConfig |
Parsed from the json config section through the injected ConfigParser — both jsonProfile and systemProfile. |
ApplicationStartupStep (@IntoSet) |
The CONFIGURE-phase step that installs the json.systemProfile profile’s mapper as the process JSON codec’s mapper. |
ComposeValidator (@IntoSet) |
The json.jsonProfile validator, forced during the VALIDATE phase. It is a pure delegate to JsonMapperProfileRegistry.validateConfigured(...), which owns every message. |
Framework seams
Section titled “Framework seams”Two public types are named nowhere above because no application uses one:
DefaultJsonMapperProfileRegistry and JsonDefaultProfileValidator. They are public because
sibling framework modules reference them across package boundaries — the REST client and the two
cache stores resolve mappers through the registry, and the JAX-RS module composes its own
default-profile validator on top of the validator here. Their Javadoc marks them INTERNAL and they
sit outside this module’s compatibility promise.
An application reaches the registry through dev.vertique.core.json.JsonMapperProfileRegistry,
which vertique-core owns and documents. What this module promises an application is the three
profile ids and their recipes, the two configuration keys, JacksonDefaults and the factories
beside it, the opt-in serdes, the projection contract above, and the JsonMapperProfile extension
point.
Dependencies
Section titled “Dependencies”| Artifact | Scope | Purpose |
|---|---|---|
dev.vertique:vertique-core |
compile | JsonProfileId, JsonMapperProfile, JsonMapperProfileRegistry, JsonProfileConfigurationException, @JsonProfile, @KeyedBy, ConfigurationException, ConfigParser, ComposeValidator, ApplicationStartupStep, LifecyclePhase, VertiqueJson, InputFieldNameResolver — the codec-neutral projection contract JacksonFieldNameResolver implements |
io.vertx:vertx-core |
compile | DatabindCodec.mapper(), the Vert.x Jackson module, JsonObject, JsonArray |
com.fasterxml.jackson.core:jackson-databind |
compile | ObjectMapper, Module, BeanDeserializerModifier, ContextualDeserializer |
com.fasterxml.jackson.datatype:jackson-datatype-jsr310 |
compile | JavaTimeModule — ISO-8601 java.time support in the vertique defaults |
com.fasterxml.jackson.datatype:jackson-datatype-jdk8 |
compile | Jdk8Module — Optional* support, and serialize-only java.util.stream support, in the vertique defaults |
com.google.dagger:dagger |
compile | @Module, @Multibinds, @Binds, @Provides |
jakarta.inject:jakarta.inject-api |
compile | @Singleton, @Inject |