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:
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:
With the correct dependencies in place you can now define an object to be serialized:
Tip
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;@MicronautTestpublic 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 exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.junit5.annotation.MicronautTestimport org.junit.jupiter.api.Assertionsimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Testimport java.io.IOExceptionimport java.util.Map@MicronautTestclass 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) assertEquals( "The Stand", book.title ) assertEquals(50, book.quantity) } @Test @Throws(IOException::class) fun testListOfBooks(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString( listOf( Book("The Stand", 50), Book("Godfather", 10), Book("VALIS", 100) ) ) val books: MutableList<Book> = objectMapper.readValue(result, Argument.listOf(Book::class.java)) assertEquals(3, books.size) val firstBook = books[0] assertEquals( "The Stand", firstBook.title ) assertEquals(50, firstBook.quantity) } @Test @Throws(IOException::class) fun testMapOfBooks(objectMapper: ObjectMapper) { val result: String? = objectMapper.writeValueAsString( Map.of<String?, Book?>( "myBook", Book("The Stand", 50), "hisBook", Book("Godfather", 10), "herBook", Book("VALIS", 100) ) ) val books = objectMapper.readValue( result, Argument.mapOf(String::class.java, Book::class.java) ) assertEquals(3, books.size) val herBook: Book = books["herBook"]!! assertEquals("VALIS", herBook.title) assertEquals(100, herBook.quantity) } @Test @Throws(IOException::class) fun testBoxOfBook(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString(Box(Book("The Stand", 50))) val box: Box<Book> = objectMapper.readValue(result, Argument.of(Box::class.java, Book::class.java)) as Box<Book> val book = box.item!! Assertions.assertNotNull(book) assertEquals("The Stand", book.title) assertEquals(50, book.quantity) } @Serdeable data class Box<I>(val item: I?)}
package exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.spock.annotation.MicronautTestimport jakarta.inject.Injectimport spock.lang.Specification@MicronautTestclass 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 } void "test list of books"() throws IOException { when: String result = objectMapper.writeValueAsString(List.of( new Book("The Stand", 50), new Book("Godfather", 10), new Book("VALIS", 100) )); List<Book> books = objectMapper.readValue(result, Argument.listOf(Book.class)); then: books.size() == 3 Book firstBook = books.get(0); firstBook != null firstBook.title == "The Stand" firstBook.quantity == 50 } void "test map of books"() throws IOException { when: String result = objectMapper.writeValueAsString(Map.of( "myBook", new Book("The Stand", 50), "hisBook", new Book("Godfather", 10), "herBook", new Book("VALIS", 100) )); Map<String, Book> books = objectMapper.readValue(result, Argument.mapOf(String.class, Book.class)); then: books.size() == 3 Book herBook = books.get("herBook"); herBook.getTitle() == "VALIS" herBook.getQuantity() == 100 } void "test a box of a book"() throws IOException { when: String result = objectMapper.writeValueAsString(new Box<>(new Book("The Stand", 50))); Box<Book> box = objectMapper.readValue(result, Argument.of(Box.class, Book.class)); then: Book book = box.item book.getTitle() == "The Stand" book.getQuantity() == 50 } @Serdeable static class Box<I> { I item Box(I item) { this.item = item } }}
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:
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:
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;@MicronautTestpublic 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 exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.junit5.annotation.MicronautTestimport org.junit.jupiter.api.Assertionsimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Testimport java.io.IOExceptionimport java.util.Map@MicronautTestclass 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) assertEquals( "The Stand", book.title ) assertEquals(50, book.quantity) } @Test @Throws(IOException::class) fun testListOfBooks(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString( listOf( Book("The Stand", 50), Book("Godfather", 10), Book("VALIS", 100) ) ) val books: MutableList<Book> = objectMapper.readValue(result, Argument.listOf(Book::class.java)) assertEquals(3, books.size) val firstBook = books[0] assertEquals( "The Stand", firstBook.title ) assertEquals(50, firstBook.quantity) } @Test @Throws(IOException::class) fun testMapOfBooks(objectMapper: ObjectMapper) { val result: String? = objectMapper.writeValueAsString( Map.of<String?, Book?>( "myBook", Book("The Stand", 50), "hisBook", Book("Godfather", 10), "herBook", Book("VALIS", 100) ) ) val books = objectMapper.readValue( result, Argument.mapOf(String::class.java, Book::class.java) ) assertEquals(3, books.size) val herBook: Book = books["herBook"]!! assertEquals("VALIS", herBook.title) assertEquals(100, herBook.quantity) } @Test @Throws(IOException::class) fun testBoxOfBook(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString(Box(Book("The Stand", 50))) val box: Box<Book> = objectMapper.readValue(result, Argument.of(Box::class.java, Book::class.java)) as Box<Book> val book = box.item!! Assertions.assertNotNull(book) assertEquals("The Stand", book.title) assertEquals(50, book.quantity) } @Serdeable data class Box<I>(val item: I?)}
package exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.spock.annotation.MicronautTestimport jakarta.inject.Injectimport spock.lang.Specification@MicronautTestclass 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 } void "test list of books"() throws IOException { when: String result = objectMapper.writeValueAsString(List.of( new Book("The Stand", 50), new Book("Godfather", 10), new Book("VALIS", 100) )); List<Book> books = objectMapper.readValue(result, Argument.listOf(Book.class)); then: books.size() == 3 Book firstBook = books.get(0); firstBook != null firstBook.title == "The Stand" firstBook.quantity == 50 } void "test map of books"() throws IOException { when: String result = objectMapper.writeValueAsString(Map.of( "myBook", new Book("The Stand", 50), "hisBook", new Book("Godfather", 10), "herBook", new Book("VALIS", 100) )); Map<String, Book> books = objectMapper.readValue(result, Argument.mapOf(String.class, Book.class)); then: books.size() == 3 Book herBook = books.get("herBook"); herBook.getTitle() == "VALIS" herBook.getQuantity() == 100 } void "test a box of a book"() throws IOException { when: String result = objectMapper.writeValueAsString(new Box<>(new Book("The Stand", 50))); Box<Book> box = objectMapper.readValue(result, Argument.of(Box.class, Book.class)); then: Book book = box.item book.getTitle() == "The Stand" book.getQuantity() == 50 } @Serdeable static class Box<I> { I item Box(I item) { this.item = item } }}
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:
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:
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;@MicronautTestpublic 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 exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.junit5.annotation.MicronautTestimport org.junit.jupiter.api.Assertionsimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Testimport java.io.IOExceptionimport java.util.Map@MicronautTestclass 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) assertEquals( "The Stand", book.title ) assertEquals(50, book.quantity) } @Test @Throws(IOException::class) fun testListOfBooks(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString( listOf( Book("The Stand", 50), Book("Godfather", 10), Book("VALIS", 100) ) ) val books: MutableList<Book> = objectMapper.readValue(result, Argument.listOf(Book::class.java)) assertEquals(3, books.size) val firstBook = books[0] assertEquals( "The Stand", firstBook.title ) assertEquals(50, firstBook.quantity) } @Test @Throws(IOException::class) fun testMapOfBooks(objectMapper: ObjectMapper) { val result: String? = objectMapper.writeValueAsString( Map.of<String?, Book?>( "myBook", Book("The Stand", 50), "hisBook", Book("Godfather", 10), "herBook", Book("VALIS", 100) ) ) val books = objectMapper.readValue( result, Argument.mapOf(String::class.java, Book::class.java) ) assertEquals(3, books.size) val herBook: Book = books["herBook"]!! assertEquals("VALIS", herBook.title) assertEquals(100, herBook.quantity) } @Test @Throws(IOException::class) fun testBoxOfBook(objectMapper: ObjectMapper) { val result: String = objectMapper.writeValueAsString(Box(Book("The Stand", 50))) val box: Box<Book> = objectMapper.readValue(result, Argument.of(Box::class.java, Book::class.java)) as Box<Book> val book = box.item!! Assertions.assertNotNull(book) assertEquals("The Stand", book.title) assertEquals(50, book.quantity) } @Serdeable data class Box<I>(val item: I?)}
package exampleimport io.micronaut.core.type.Argumentimport io.micronaut.serde.ObjectMapperimport io.micronaut.serde.annotation.Serdeableimport io.micronaut.test.extensions.spock.annotation.MicronautTestimport jakarta.inject.Injectimport spock.lang.Specification@MicronautTestclass 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 } void "test list of books"() throws IOException { when: String result = objectMapper.writeValueAsString(List.of( new Book("The Stand", 50), new Book("Godfather", 10), new Book("VALIS", 100) )); List<Book> books = objectMapper.readValue(result, Argument.listOf(Book.class)); then: books.size() == 3 Book firstBook = books.get(0); firstBook != null firstBook.title == "The Stand" firstBook.quantity == 50 } void "test map of books"() throws IOException { when: String result = objectMapper.writeValueAsString(Map.of( "myBook", new Book("The Stand", 50), "hisBook", new Book("Godfather", 10), "herBook", new Book("VALIS", 100) )); Map<String, Book> books = objectMapper.readValue(result, Argument.mapOf(String.class, Book.class)); then: books.size() == 3 Book herBook = books.get("herBook"); herBook.getTitle() == "VALIS" herBook.getQuantity() == 100 } void "test a box of a book"() throws IOException { when: String result = objectMapper.writeValueAsString(new Box<>(new Book("The Stand", 50))); Box<Book> box = objectMapper.readValue(result, Argument.of(Box.class, Book.class)); then: Book book = box.item book.getTitle() == "The Stand" book.getQuantity() == 50 } @Serdeable static class Box<I> { I item Box(I item) { this.item = item } }}
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.
Tip
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.
Note
If an unsupported annotation or member is used, a compilation error will result.
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:
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:
The Micronaut HTTP server supports declaring the Jackson @JsonView annotation on controllers to configure a subset of
fields to be serialized. micronaut-serialization supports this feature when enabled through the
jackson.json-view.enabled or micronaut.serde.json-view-enabled configuration property.
4.1 Custom Property Filters
Custom property filters can be written by implementing the PropertyFilter interface.
For example, given the following class:
A custom property filter can be defined as follows:
The filter omits the name field when the preferredName field is set:
package exampleimport io.micronaut.serde.ObjectMapperimport io.micronaut.test.extensions.junit5.annotation.MicronautTestimport org.junit.jupiter.api.Assertionsimport org.junit.jupiter.api.Testimport java.io.IOException@MicronautTestclass 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) }}
package exampleimport io.micronaut.serde.ObjectMapperimport io.micronaut.test.extensions.spock.annotation.MicronautTestimport jakarta.inject.Injectimport spock.lang.Specification@MicronautTestclass 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 }}
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:
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 exampleclass 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) } }}
package examplefinal 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) }}
A custom serde (a combined serializer and deserializer) can be implemented as follows:
You can now serialize and deserialize classes of type Point:
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.
Note
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:
You can then define annotations at field, parameter, method etc. level to customize serialization/deserialization for just that case:
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 exampleclass Product(val name: String, val quantity: Int)
package exampleclass Product { final String name final int quantity Product(String name, int quantity) { this.name = name this.quantity = quantity }}
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:
Then the mixin can be used when declaring SerdeImport:
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;@Serdeablepublic 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 exampleimport io.micronaut.serde.annotation.Serdeable@Serdeabledata class Location( val features: Map<Feature, Point>)
package exampleimport io.micronaut.serde.annotation.Serdeable@Serdeableclass Location { final Map<Feature, Point> features Location(Map<Feature, Point> features) { this.features = features }}
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:
10 Repository
You can find the source code of this project in this repository: