annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
Table of Contents
Micronaut Serialization
Enables serialization/deserialization in Micronaut applications using build time information
Version: 2.11.0
1 Introduction
Micronaut Serialization is a library that allows the serialization and deserialization of objects to common serialization formats like JSON.
It does so using build-time Bean Introspections that do not use reflection and allows using a variety of common annotation models including Jackson annotations, JSON-B annotations or BSON annotations.
Micronaut Serialization can be used to replace the use of Jackson Databind in a Micronaut application and allows serialization on top of a number of different encoding runtimes including Jackson Core, JSON-P or BSON.
1.1 Why Micronaut Serialization?
The goal of this project is to be an almost complete build-time replacement for Jackson Databind, that does not rely on reflection and has a smaller runtime footprint. The reasons to provide an alternative to Jackson are outlined below.
Memory Performance
Micronaut Serialization consumes less memory and has a much smaller runtime component. As a way of comparison Micronaut Serialization is a 380kb JAR file, compared to Jackson Databind which is well over 2mb. This results in a reduction of 5MB in terms of image size for native image builds.
The elimination of reflection and smaller footprint also results in reduced runtime memory consumption.
Security
Unlike Jackson, you cannot serialize or deserialize arbitrary objects to JSON. Allowing arbitrary serialization is often a source of security issues in modern applications. Instead with Micronaut Serialization to allow a type to be serialized or deserialized you must do one of the following:
-
Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized.
-
If you cannot modify the source code and the type is an external type you can use @SerdeImport to import the type. Note that with this approach only public members are considered.
-
Define a bean of type Serializer for serialization and/or a bean of type Deserializer for deserialization.
Type Safety
Jackson provides an annotation-based programming model that includes many rules developers need to be aware of and can lead to runtime exceptions if these rules are violated.
Micronaut Serialization adds compile-time checking for correctness when using JSON binding annotations.
Runtime Portability
Micronaut Serialization decouples the runtime from the actual source code level annotation model whilst Jackson is coupled to Jackson annotations. This means you can use the same runtime, but choose whether to use Jackson annotations, JSON-B annotations or BSON annotations
This leads to less memory consumption since there is no need to have multiple JSON parsers and reflection-based meta-models if you using both JSON in your webtier plus a document database like MongoDB.
2 Release History
For this project, you can find a list of releases (with release notes) here:
3 Quick Start
There are a number of ways to use Micronaut Serialization including a choice of annotation-model and runtime.
The first step however is configure the necessary annotation processor dependency:
<annotationProcessorPaths>
<path>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-processor</artifactId>
</path>
</annotationProcessorPaths>
For Kotlin, add the micronaut-serde-processor dependency in kapt or ksp scope, and for Groovy add micronaut-serde-processor in compileOnly scope.
|
You should then choose a combination of Annotation-based programming model and runtime implementation that you desire.
3.1 Jackson Annotations & Jackson Core
To replace Jackson Databind, but continue using Jackson Annotations as a programming model and Jackson Core as a runtime replace the micronaut-jackson-databind
module in your application with micronaut-serde-jackson
.
Add the following artifact to the dependencies
block:
implementation("io.micronaut.serde:micronaut-serde-jackson")
<dependency>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-jackson</artifactId>
</dependency>
With the correct dependencies in place you can now define an object to be serialized:
package example;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.micronaut.serde.annotation.Serdeable;
@Serdeable // (1)
public class Book {
private final String title;
@JsonProperty("qty") // (2)
private final int quantity;
@JsonCreator // (3)
public Book(String title, int quantity) {
this.title = title;
this.quantity = quantity;
}
public String getTitle() {
return title;
}
public int getQuantity() {
return quantity;
}
}
package example
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.serde.annotation.Serdeable
@Serdeable // (1)
class Book {
final String title
@JsonProperty("qty") // (2)
final int quantity
@JsonCreator
Book(String title, int quantity) { // (3)
this.title = title
this.quantity = quantity
}
}
package example
import com.fasterxml.jackson.annotation.JsonProperty
import io.micronaut.serde.annotation.Serdeable
@Serdeable // (1)
data class Book (
val title: String, // (2)
@JsonProperty("qty") val quantity: Int
)
1 | The type is annotated with @Serdeable to enable serialization/deserialization |
2 | You can use @JsonProperty from Jackson annotations |
3 | You can use @JsonCreator from Jackson annotations |
If you don’t want to add a Micronaut Serialization annotation then you can also add a type-level Jackson annotation like @JsonClassDescription , @JsonRootName or @JsonTypeName
|
Once you have a type that can be serialized and deserialized you can use the ObjectMapper interface to do so:
package example;
import io.micronaut.serde.ObjectMapper;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@MicronautTest
public class BookTest {
@Test
void testWriteReadBook(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
assertNotNull(book);
assertEquals(
"The Stand", book.getTitle()
);
assertEquals(50, book.getQuantity());
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
@MicronautTest
class BookTest extends Specification {
@Inject ObjectMapper objectMapper
void "test read/write book"() {
when:
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
then:
book != null
book.title == "The Stand"
book.quantity == 50
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@MicronautTest
class BookTest {
@Test
fun testWriteReadBook(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(Book("The Stand", 50))
val book = objectMapper.readValue(result, Book::class.java)
Assertions.assertNotNull(book)
Assertions.assertEquals(
"The Stand", book.title
)
Assertions.assertEquals(50, book.quantity)
}
}
3.2 JSON-B Annotations & JSON-P
To completely remove all dependencies on Jackson and use JSON-B annotations in your source code combined with JSON-P at a runtime replace the micronaut-jackson-databind
and micronaut-jackson-core
modules with micronaut-serde-jsonp
.
Add the following artifact to the dependencies
block:
implementation("io.micronaut.serde:micronaut-serde-jsonp")
<dependency>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-jsonp</artifactId>
</dependency>
If your third-party dependencies have direct dependencies on Jackson Databind it may not be an option to omit it. |
With the correct dependencies in place you can now define an object to be serialized:
package example;
import io.micronaut.serde.annotation.Serdeable;
import jakarta.json.bind.annotation.JsonbCreator;
import jakarta.json.bind.annotation.JsonbProperty;
@Serdeable // (1)
public class Book {
private final String title;
@JsonbProperty("qty") // (2)
private final int quantity;
@JsonbCreator // (3)
public Book(String title, int quantity) {
this.title = title;
this.quantity = quantity;
}
public String getTitle() {
return title;
}
public int getQuantity() {
return quantity;
}
}
1 | The type is annotated with @Serdeable to enable serialization/deserialization |
2 | You can use @JsonbProperty from JSON-B annotations |
3 | You can use @JsonbCreator from JSON-B annotations |
Once you have a type that can be serialized and deserialized you can use the ObjectMapper interface to do so:
package example;
import io.micronaut.serde.ObjectMapper;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@MicronautTest
public class BookTest {
@Test
void testWriteReadBook(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
assertNotNull(book);
assertEquals(
"The Stand", book.getTitle()
);
assertEquals(50, book.getQuantity());
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
@MicronautTest
class BookTest extends Specification {
@Inject ObjectMapper objectMapper
void "test read/write book"() {
when:
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
then:
book != null
book.title == "The Stand"
book.quantity == 50
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@MicronautTest
class BookTest {
@Test
fun testWriteReadBook(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(Book("The Stand", 50))
val book = objectMapper.readValue(result, Book::class.java)
Assertions.assertNotNull(book)
Assertions.assertEquals(
"The Stand", book.title
)
Assertions.assertEquals(50, book.quantity)
}
}
3.3 BSON Annotations and BSON
To completely remove all dependencies on Jackson and use BSON annotations in your source code combined with BSON at a runtime you should replace the micronaut-jackson-databind
and micronaut-jackson-core
modules in your application with micronaut-serde-bson
.
Add the following artifact to the dependencies
block:
implementation("io.micronaut.serde:micronaut-serde-bson")
<dependency>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-bson</artifactId>
</dependency>
If your third-party dependencies have direct dependencies on Jackson Databind it may not be an option to omit it. |
With the correct dependencies in place you can now define an object to be serialized:
package example;
import io.micronaut.serde.annotation.Serdeable;
import org.bson.codecs.pojo.annotations.BsonCreator;
import org.bson.codecs.pojo.annotations.BsonProperty;
@Serdeable // (1)
public class Book {
private final String title;
@BsonProperty("qty") // (2)
private final int quantity;
@BsonCreator // (3)
public Book(String title, int quantity) {
this.title = title;
this.quantity = quantity;
}
public String getTitle() {
return title;
}
public int getQuantity() {
return quantity;
}
}
1 | The type is annotated with @Serdeable to enable serialization/deserialization |
2 | You can use @BsonProperty from BSON annotations |
3 | You can use @BsonCreator from BSON annotations |
Once you have a type that can be serialized and deserialized you can use the ObjectMapper interface to do so:
package example;
import io.micronaut.serde.ObjectMapper;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@MicronautTest
public class BookTest {
@Test
void testWriteReadBook(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
assertNotNull(book);
assertEquals(
"The Stand", book.getTitle()
);
assertEquals(50, book.getQuantity());
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
@MicronautTest
class BookTest extends Specification {
@Inject ObjectMapper objectMapper
void "test read/write book"() {
when:
String result = objectMapper.writeValueAsString(new Book("The Stand", 50));
Book book = objectMapper.readValue(result, Book.class);
then:
book != null
book.title == "The Stand"
book.quantity == 50
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@MicronautTest
class BookTest {
@Test
fun testWriteReadBook(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(Book("The Stand", 50))
val book = objectMapper.readValue(result, Book::class.java)
Assertions.assertNotNull(book)
Assertions.assertEquals(
"The Stand", book.title
)
Assertions.assertEquals(50, book.quantity)
}
}
4 Jackson Annotations
Micronaut Serialization supports a subset of the available Jackson Annotations.
The primary difference is Micronaut Serialization uses build-time Bean Introspections, this means that only accessible getters and setters (and Java 17 records) are supported and @JsonAutoDetect cannot be used to customize mapping.
You can however, enable fields to be included using AccessKind field. See the "Bean Fields" section of the Bean Introspections docs.
|
The full list of supported Jackson annotations and members is described in the table below.
If an unsupported annotation or member is used, a compilation error will result. |
Jackson Annotation | Supported | Notes |
---|---|---|
✅ |
||
❌ |
||
✅ |
unsupported members: |
|
✅ |
unsupported members: |
|
❌ |
||
✅ |
||
✅ |
||
✅ |
||
❌ |
||
✅ |
supported only on types, implement the io.micronaut.serde.PropertyFilter interface |
|
✅ |
unsupported members: |
|
✅ |
||
❌ |
||
❌ |
||
✅ |
unsupported members: |
|
✅ |
||
✅ |
||
✅ |
unsupported members: |
|
❌ |
||
✅ |
||
❌ |
||
✅ |
||
✅ |
||
✅ |
||
❌ |
Not supported for security reasons |
|
✅ |
||
✅ |
unsupported members: |
|
✅ |
||
❌ |
||
✅ |
Only |
|
✅ |
||
✅ |
unsupported members: |
|
✅ |
unsupported members: |
|
✅ |
In addition, limited support for 3 jackson-databind
annotations is included to allow portability for cases where both support for jackson-databind
and Micronaut Serialization is required:
Annotation | Notes |
---|---|
Only with the built-in naming strategies |
|
Only the |
|
Only the |
Note that when using these annotations it is recommended that you make jackson-databind
a compileOnly
dependency since it is not needed at runtime. For example for Gradle:
jackson-databind
as compileOnly
scopecompileOnly("com.fasterxml.jackson.core:jackson-databind")
or Maven:
jackson-databind
as provided
scope<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
4.1 Custom Property Filters
Custom property filters can be written by implementing the PropertyFilter interface.
For example, given the following class:
package example;
import com.fasterxml.jackson.annotation.JsonFilter;
import io.micronaut.serde.annotation.Serdeable;
@Serdeable
@JsonFilter("person-filter") // (1)
public class Person {
private final String name;
private final String preferredName;
public Person(String name, String preferredName) {
this.name = name;
this.preferredName = preferredName;
}
public String getName() {
return name;
}
public String getPreferredName() {
return preferredName;
}
}
package example
import com.fasterxml.jackson.annotation.JsonFilter
import io.micronaut.serde.annotation.Serdeable
@Serdeable
@JsonFilter("person-filter") // (1)
class Person {
String name
String preferredName
}
package example
import com.fasterxml.jackson.annotation.JsonFilter
import io.micronaut.serde.annotation.Serdeable
@Serdeable
@JsonFilter("person-filter") // (1)
data class Person(
val name: String,
val preferredName: String?
)
1 | Annotate with the jackson JsonFilter annotation to require the filter called person-filter . |
A custom property filter can be defined as follows:
package example;
import io.micronaut.serde.PropertyFilter;
import io.micronaut.serde.Serializer;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
@Singleton
@Named("person-filter") // (1)
public class PersonFilter implements PropertyFilter {
@Override
public boolean shouldInclude(
Serializer.EncoderContext encoderContext, Serializer<Object> propertySerializer,
Object bean, String propertyName, Object propertyValue
) {
if (bean instanceof Person) { // (2)
Person person = (Person) bean;
if (propertyName.equals("name")) {
return person.getPreferredName() == null;
} else if (propertyName.equals("preferredName")) {
return person.getPreferredName() != null;
}
}
return true;
}
}
package example
import io.micronaut.serde.PropertyFilter
import io.micronaut.serde.Serializer
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
@Named("person-filter") // (1)
class PersonFilter implements PropertyFilter {
@Override
boolean shouldInclude(
Serializer.EncoderContext encoderContext, Serializer<Object> propertySerializer,
Object bean, String propertyName, Object propertyValue
) {
if (bean instanceof Person) { // (2)
if (propertyName == "name") {
return bean.preferredName == null
} else if (propertyName == "preferredName") {
return bean.preferredName != null
}
}
return true
}
}
package example
import io.micronaut.serde.PropertyFilter
import io.micronaut.serde.Serializer
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
@Named("person-filter") // (1)
class PersonFilter : PropertyFilter {
override fun shouldInclude(
encoderContext: Serializer.EncoderContext,
propertySerializer: Serializer<Any>,
bean: Any,
propertyName: String,
propertyValue: Any?
): Boolean {
if (bean is Person) { // (2)
if (propertyName == "name") {
return bean.preferredName == null
} else if (propertyName == "preferredName") {
return bean.preferredName != null
}
}
return true
}
}
1 | Create a singleton with Named annotation matching the filter name. |
2 | Implement custom filtering for the Person class. |
The filter omits the name
field when the preferredName
field is set:
package example;
import io.micronaut.serde.ObjectMapper;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
@MicronautTest
public class PersonFilterTest {
@Test
void testWritePersonWithoutPreferredName(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(new Person("Adam", null));
assertEquals("{\"name\":\"Adam\"}", result);
}
@Test
void testWritePersonWithPreferredName(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(new Person("Adam", "Ad"));
assertEquals("{\"preferredName\":\"Ad\"}", result);
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
@MicronautTest
class PersonFilterTest extends Specification {
@Inject ObjectMapper objectMapper
void "test write person without preferred name"() {
when:
String result = objectMapper.writeValueAsString(new Person(name: "Adam"))
then:
'{"name":"Adam"}' == result
}
void "test write person with preferred name"() {
when:
String result = objectMapper.writeValueAsString(new Person(name: "Adam", preferredName: "Ad"))
then:
'{"preferredName":"Ad"}' == result
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import java.io.IOException
@MicronautTest
class PersonFilterTest {
@Test
fun testWritePersonWithoutPreferredName(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(Person("Adam", null))
Assertions.assertEquals("{\"name\":\"Adam\"}", result)
}
@Test
fun testWritePersonWithPreferredName(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(Person("Adam", "Ad"))
Assertions.assertEquals("{\"preferredName\":\"Ad\"}", result)
}
}
5 JSON-B Annotations
Micronaut Serialization supports a subset of the available JSON-B annotations.
Note that only the annotations are supported and the runtime APIs are not, hence it is recommended to include JSON-B only as a compileOnly
dependency. For example for Gradle:
jakarta.json.bind-api
as compileOnly
scopecompileOnly("jakarta.json.bind:jakarta.json.bind-api")
or Maven:
jakarta.json.bind-api
as provided
scope<dependency>
<groupId>jakarta.json.bind</groupId>
<artifactId>jakarta.json.bind-api</artifactId>
<scope>provided</scope>
</dependency>
Jackson Annotation | Supported | Notes |
---|---|---|
✅ |
||
✅ |
||
✅ |
||
✅ |
||
✅ |
||
✅ |
||
✅ |
||
❌ |
Exposes runtime API |
|
❌ |
Exposes runtime API |
|
❌ |
Exposes runtime API |
|
❌ |
Requires Reflection |
6 BSON Annotations
The complete set of BSON annotations is supported.
Note that with BSON you can encode both the JSON and to BSON Binary by injecting one of BsonBinaryMapper (Binary) or BsonJsonMapper (JSON).
7 Custom Serializers & Deserializers
Custom serializers and deserializers for types can be written by implementing the Serializer and Deserializer interfaces respectively and defining beans capable of handling a particular type.
For example given the following class:
package example;
public final class Point {
private final int x, y;
private Point(int x, int y) {
this.x = x;
this.y = y;
}
public int[] coords() {
return new int[] { x, y };
}
public static Point valueOf(int x, int y) {
return new Point(x, y);
}
}
package example
final class Point {
private final int x, y
private Point(int x, int y) {
this.x = x
this.y = y
}
int[] coords() {
return new int[] { x, y }
}
static Point valueOf(int x, int y) {
return new Point(x, y)
}
}
package example
class Point private constructor(private val x: Int, private val y: Int) {
fun coords(): IntArray {
return intArrayOf(x, y)
}
companion object {
fun valueOf(x: Int, y: Int): Point {
return Point(x, y)
}
}
}
A custom serde (a combined serializer and deserializer) can be implemented as follows:
package example;
import io.micronaut.core.type.Argument;
import io.micronaut.serde.Decoder;
import io.micronaut.serde.Encoder;
import io.micronaut.serde.Serde;
import jakarta.inject.Singleton;
import java.io.IOException;
import java.util.Objects;
@Singleton // (1)
public class PointSerde implements Serde<Point> { // (2)
@Override
public Point deserialize(
Decoder decoder,
DecoderContext context,
Argument<? super Point> type) throws IOException {
try (Decoder array = decoder.decodeArray()) { // (3)
int x = array.decodeInt();
int y = array.decodeInt();
return Point.valueOf(x, y); // (4)
}
}
@Override
public void serialize(
Encoder encoder,
EncoderContext context,
Argument<? extends Point> type, Point value) throws IOException {
Objects.requireNonNull(value, "Point cannot be null"); // (5)
int[] coords = value.coords();
try (Encoder array = encoder.encodeArray(type)) { // (6)
array.encodeInt(coords[0]);
array.encodeInt(coords[1]);
}
}
}
package example
import io.micronaut.core.type.Argument
import io.micronaut.serde.Decoder
import io.micronaut.serde.Encoder
import io.micronaut.serde.Serde
import jakarta.inject.Singleton
@Singleton // (1)
class PointSerde implements Serde<Point> { // (2)
@Override
Point deserialize(
Decoder decoder,
DecoderContext context,
Argument<? super Point> type) throws IOException {
Decoder array = decoder.decodeArray() // (3)
int x = array.decodeInt()
int y = array.decodeInt()
array.finishStructure() // (4)
return Point.valueOf(x, y) // (5)
}
@Override
void serialize(
Encoder encoder,
EncoderContext context,
Argument<? extends Point> type,
Point value) throws IOException {
Objects.requireNonNull(value, "Point cannot be null") // (6)
int[] coords = value.coords()
Encoder array = encoder.encodeArray(type) // (7)
array.encodeInt(coords[0])
array.encodeInt(coords[1])
array.finishStructure() // (8)
}
}
package example
import io.micronaut.core.type.Argument
import io.micronaut.serde.*
import jakarta.inject.Singleton
@Singleton // (1)
class PointSerde : Serde<Point> { // (2)
override fun deserialize(
decoder: Decoder,
context: Deserializer.DecoderContext,
type: Argument<in Point>
): Point {
decoder.decodeArray().use { // (3)
val x = it.decodeInt()
val y = it.decodeInt()
return Point.valueOf(x, y) // (4)
}
}
override fun serialize(
encoder: Encoder,
context: Serializer.EncoderContext,
type: Argument<out Point>,
value: Point
) {
val coords = value.coords()
encoder.encodeArray(type).use { // (6)
it.encodeInt(coords[0])
it.encodeInt(coords[1])
}
}
}
1 | The Serde is made a bean by annotating it with @Singleton scope. |
2 | The Serde interface is implemented for a given type. |
3 | The Decoder interface is used to starting decoding an array using try-with-resources |
4 | The decoded object is returned |
5 | The value can be null and the decoder should handle whether null is allowed |
6 | The Encoder interface is used to start encoding an array with the encodeArray method using try-with-resources . |
You can now serialize and deserialize classes of type Point
:
package example;
import io.micronaut.serde.ObjectMapper;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@MicronautTest
public class PointTest {
@Test
void testWriteReadPoint(ObjectMapper objectMapper) throws IOException {
String result = objectMapper.writeValueAsString(
Point.valueOf(50, 100)
);
Point point = objectMapper.readValue(result, Point.class);
assertNotNull(point);
int[] coords = point.coords();
assertEquals(50, coords[0]);
assertEquals(100, coords[1]);
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.inject.Inject
import spock.lang.Specification
@MicronautTest
class PointTest extends Specification {
@Inject ObjectMapper objectMapper
void "test read/write point"() {
given:
String result = objectMapper.writeValueAsString(
Point.valueOf(50, 100)
)
Point point = objectMapper.readValue(result, Point.class)
expect:
point != null
int[] coords = point.coords()
coords[0] == 50
coords[1] == 100
}
}
package example
import io.micronaut.serde.ObjectMapper
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@MicronautTest
class PointTest {
@Test
fun testWriteReadPoint(objectMapper: ObjectMapper) {
val result = objectMapper.writeValueAsString(
Point.valueOf(50, 100)
)
val point = objectMapper.readValue(result, Point::class.java)
Assertions.assertNotNull(point)
val coords = point.coords()
Assertions.assertEquals(50, coords[0])
Assertions.assertEquals(100, coords[1])
}
}
Serializer Selection
Note that if multiple Serializer beans exist you will get a NonUniqueBeanException
, in this case you have a number of options:
-
Add
@Primary
to your serializer so it is picked -
Add
@Order
with a higher priority value so it is picked
Deserializer Selection
It is quite common during deserialization to have multiple possible deserializer options. For example a HashSet
can be deserialized to both a Collection
and a Set
.
In these cases you should declare an @Order
annotation higher priority value to control which deserializer is chosen by default.
Property Level Serializer or Deserializer
You can also customize the serializer and/or deserializer on a per field, constructor, method etc. basis by using the @Serializable(using=..)
and/or @Deserializable(using=..)
annotations.
Frequently in this case you will more than one serializer/deserializer for a given type and you should use @Primary or @Secondary to customize bean property so one is selected by default.
|
For example say you add another secondary Serde
to store the previous Point
example in reverse order:
package example;
import java.io.IOException;
import java.util.Objects;
import io.micronaut.context.annotation.Secondary;
import io.micronaut.core.type.Argument;
import io.micronaut.serde.Decoder;
import io.micronaut.serde.Encoder;
import io.micronaut.serde.Serde;
import jakarta.inject.Singleton;
@Singleton
@Secondary // (1)
public class ReversePointSerde implements Serde<Point> {
@Override
public Point deserialize(
Decoder decoder,
DecoderContext context,
Argument<? super Point> type) throws IOException {
Decoder array = decoder.decodeArray();
int y = array.decodeInt(); // (2)
int x = array.decodeInt();
array.finishStructure();
return Point.valueOf(x, y);
}
@Override
public void serialize(
Encoder encoder,
EncoderContext context,
Argument<? extends Point> type, Point value) throws IOException {
Objects.requireNonNull(value, "Point cannot be null");
int[] coords = value.coords();
Encoder array = encoder.encodeArray(type);
array.encodeInt(coords[1]); // (3)
array.encodeInt(coords[0]);
array.finishStructure();
}
}
package example
import io.micronaut.context.annotation.Secondary
import io.micronaut.core.type.Argument
import io.micronaut.serde.Decoder
import io.micronaut.serde.Encoder
import io.micronaut.serde.Serde
import jakarta.inject.Singleton
@Singleton
@Secondary // (1)
class ReversePointSerde implements Serde<Point> {
@Override
Point deserialize(
Decoder decoder,
DecoderContext context,
Argument<? super Point> type) throws IOException {
Decoder array = decoder.decodeArray()
int y = array.decodeInt() // (2)
int x = array.decodeInt()
array.finishStructure()
return Point.valueOf(x, y)
}
@Override
void serialize(
Encoder encoder,
EncoderContext context,
Argument<? extends Point> type,
Point value) throws IOException {
Objects.requireNonNull(value, "Point cannot be null")
int[] coords = value.coords()
Encoder array = encoder.encodeArray(type)
array.encodeInt(coords[1]) // (3)
array.encodeInt(coords[0])
array.finishStructure()
}
}
package example
import io.micronaut.context.annotation.Secondary
import io.micronaut.core.type.Argument
import io.micronaut.serde.*
import jakarta.inject.Singleton
import java.util.*
@Singleton
@Secondary // (1)
class ReversePointSerde : Serde<Point> {
override fun deserialize(
decoder: Decoder,
context: Deserializer.DecoderContext,
type: Argument<in Point>
): Point {
val array = decoder.decodeArray()
val y = array.decodeInt() // (2)
val x = array.decodeInt()
array.finishStructure()
return Point.valueOf(x, y)
}
override fun serialize(
encoder: Encoder,
context: Serializer.EncoderContext,
type: Argument<out Point>,
value: Point
) {
Objects.requireNonNull(value, "Point cannot be null")
val coords = value.coords()
val array = encoder.encodeArray(type)
array.encodeInt(coords[1]) // (3)
array.encodeInt(coords[0])
array.finishStructure()
}
}
1 | This bean is made @Secondary so the primary Serde serializes by default in the correct order |
2 | The coordinates are stored in reverse order |
You can then define annotations at field, parameter, method etc. level to customize serialization/deserialization for just that case:
package example;
import io.micronaut.serde.annotation.Serdeable;
@Serdeable
public class Place {
@Serdeable.Serializable(using = ReversePointSerde.class) // (1)
@Serdeable.Deserializable(using = ReversePointSerde.class) // (2)
private final Point point;
@Serdeable.Serializable(using = ReversePointSerde.class)
private final Point pointCustomSer;
@Serdeable.Deserializable(using = ReversePointSerde.class)
private final Point pointCustomDes;
public Place(Point point, Point pointCustomSer, Point pointCustomDes) {
this.point = point;
this.pointCustomSer = pointCustomSer;
this.pointCustomDes = pointCustomDes;
}
public Point getPoint() {
return point;
}
public Point getPointCustomSer() {
return pointCustomSer;
}
public Point getPointCustomDes() {
return pointCustomDes;
}
}
package example
import io.micronaut.serde.annotation.Serdeable
@Serdeable
class Place {
@Serdeable.Serializable(using = ReversePointSerde.class) // (1)
@Serdeable.Deserializable(using = ReversePointSerde.class) // (2)
final Point point
@Serdeable.Serializable(using = ReversePointSerde.class)
final Point pointCustomSer
@Serdeable.Deserializable(using = ReversePointSerde.class)
final Point pointCustomDes
Place(Point point, Point pointCustomSer, Point pointCustomDes) {
this.point = point
this.pointCustomSer = pointCustomSer
this.pointCustomDes = pointCustomDes
}
}
package example
import io.micronaut.serde.annotation.Serdeable
import io.micronaut.serde.annotation.Serdeable.Deserializable
import io.micronaut.serde.annotation.Serdeable.Serializable
@Serdeable
data class Place(
@Deserializable(using = ReversePointSerde::class) // (1)
@Serializable(using = ReversePointSerde::class) // (2)
val point: Point,
@Serdeable.Serializable(using = example.ReversePointSerde::class)
val pointCustomSer: Point,
@Deserializable(using = ReversePointSerde::class)
val pointCustomDes: Point
)
1 | @Serializable(using=..) indicates to use the ReversePointSerde to serialize the coordinates |
2 | @Serializable(using=..) indicates to use the ReversePointSerde to deserialize the coordinates |
8 Enabling Serialization of External Classes
Unlike Jackson, Micronaut Serialization doesn’t allow the arbitrary serialization of any type. As mentioned in the previous section on Custom Serializers, one option to serializing external types is to define a custom serializer, however it is also possible to import types during compilation using the @SerdeImport annotation.
For example consider the following type:
package example;
public class Product {
private final String name;
private final int quantity;
public Product(String name, int quantity) {
this.name = name;
this.quantity = quantity;
}
public String getName() {
return name;
}
public int getQuantity() {
return quantity;
}
}
package example
class Product {
final String name
final int quantity
Product(String name, int quantity) {
this.name = name
this.quantity = quantity
}
}
package example
class Product(val name: String, val quantity: Int)
There are no serialization annotations present on this type and an attempt to serialize this type will result in an error.
To resolve this you can add @SerdeImport to a central location in your project (typically the Application
class):
@SerdeImport(Product.class)
Note that if you wish to apply customizations the imported class then you can additionally supply a mixin class. For example:
package example;
import com.fasterxml.jackson.annotation.JsonProperty;
public interface ProductMixin {
@JsonProperty("p_name")
String getName();
@JsonProperty("p_quantity")
int getQuantity();
}
package example
import com.fasterxml.jackson.annotation.JsonProperty
interface ProductMixin {
@JsonProperty("p_name")
String getName()
@JsonProperty("p_quantity")
int getQuantity()
}
package example
import com.fasterxml.jackson.annotation.JsonProperty
interface ProductMixin {
@get:JsonProperty("p_name")
val name: String
@get:JsonProperty("p_quantity")
val quantity: Int
}
Then the mixin can be used when declaring SerdeImport
:
package example;
import io.micronaut.runtime.Micronaut;
import io.micronaut.serde.annotation.SerdeImport;
@SerdeImport(
value = Product.class,
mixin = ProductMixin.class
) // (1)
public class Application {
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
package example
import io.micronaut.runtime.Micronaut.*
import io.micronaut.serde.annotation.SerdeImport
fun main(args: Array<String>) {
build()
.args(*args)
.packages("com.example")
.start()
}
@SerdeImport(
value = Product::class,
mixin = ProductMixin::class) // (1)
class Serdes {}
1 | The @SerdeImport is used to make the Product class serializable |
9 Custom Key Converters
Keys with JSON are always written as Strings however you can use types other than strings when serializing and deserializing Map
instances, however you may be required to register a custom TypeConverter.
For example given the following class:
package example;
import io.micronaut.serde.annotation.Serdeable;
import java.util.Map;
@Serdeable
public class Location {
private final Map<Feature, Point> features;
public Location(Map<Feature, Point> features) {
this.features = features;
}
public Map<Feature, Point> getFeatures() {
return features;
}
}
package example
import io.micronaut.serde.annotation.Serdeable
@Serdeable
class Location {
final Map<Feature, Point> features
Location(Map<Feature, Point> features) {
this.features = features
}
}
package example
import io.micronaut.serde.annotation.Serdeable
@Serdeable
data class Location(
val features: Map<Feature, Point>
)
That defines a custom Feature
type for keys. Micronaut Serialization won’t know how to deserialize this type, so along with the type a TypeConverter
should be defined:
package example;
import io.micronaut.core.convert.ConversionContext;
import io.micronaut.core.convert.TypeConverter;
import jakarta.inject.Singleton;
import java.util.Optional;
public class Feature {
private final String name;
public Feature(String name) {
this.name = name;
}
public String name() {
return name;
}
@Override
public String toString() { // (1)
return name;
}
@Singleton
static class FeatureConverter implements TypeConverter<String, Feature> { // (2)
@Override
public Optional<Feature> convert(String object, Class<Feature> targetType, ConversionContext context) {
return Optional.of(new Feature(object));
}
}
}
package example
import io.micronaut.core.convert.ConversionContext
import io.micronaut.core.convert.TypeConverter
import jakarta.inject.Singleton
class Feature {
private final String name
Feature(String name) {
this.name = name
}
String name() {
return name
}
@Override
String toString() { // (1)
return name
}
@Singleton
static class FeatureConverter implements TypeConverter<String, Feature> { // (2)
@Override
Optional<Feature> convert(String object, Class<Feature> targetType, ConversionContext context) {
return Optional.of(new Feature(object))
}
}
}
package example
import io.micronaut.core.convert.ConversionContext
import io.micronaut.core.convert.TypeConverter
import jakarta.inject.Singleton
import java.util.*
class Feature(private val name: String) {
fun name(): String {
return name
}
override fun toString(): String { // (1)
return name
}
}
@Singleton
class FeatureConverter : TypeConverter<String, Feature> {
// (2)
override fun convert(value: String, targetType: Class<Feature>, context: ConversionContext): Optional<Feature> {
return Optional.of(Feature(value))
}
}
1 | For serialization by default toString() is called, but you can also register a TypeConverter from Feature to String to customize this. |
2 | For deserialization a TypeConverter is necessary to convert the string key into the required type. |
10 Repository
You can find the source code of this project in this repository: