Jackson Mixins, Annotations & Introspection in Java

Jackson decides how a class looks in JSON by reading annotations on its fields, getters and constructors. Mix-ins let you attach the same annotations to classes you cannot edit, and views let one class produce several JSON shapes. This page explains how Jackson collects annotations, runs the most useful ones, and shows the traps in ignoring properties, mix-in matching and polymorphic types.

Share

Contents

How Jackson reads annotations

When a mapper first meets a class, it collects the class's properties. Each property gathers every member that refers to it: a field name, a getter getName(), a setter setName() and a constructor parameter. Jackson then merges the annotations of all those members, so @JsonProperty on a field also renames the getter. The component that reads annotations is the AnnotationIntrospector; the default one understands the com.fasterxml.jackson.annotation package.

A mix-in plugs into the same step. When you register addMixIn(Target.class, MixIn.class), Jackson looks at the mix-in's members, finds the members of the target with the same name and signature, and treats the mix-in's annotations as if they were written on the target. The target class is not changed, and only that mapper sees the extra annotations.

Jackson annotations by job
JobAnnotationsNotes
Names and order@JsonProperty, @JsonAlias, @JsonPropertyOrder@JsonAlias since 2.9
Leaving properties out@JsonIgnore, @JsonIgnoreProperties, @JsonIncludeProperties, @JsonInclude@JsonIncludeProperties since 2.12
Formats and access@JsonFormat, @JsonProperty(access = ...)Read-only and write-only properties
Shape changes@JsonValue, @JsonCreator, @JsonUnwrapped, @JsonAnyGetter, @JsonAnySetterSingle values, flattening, open-ended properties
Polymorphism@JsonTypeInfo, @JsonSubTypesReading the right subclass back
Several shapes per class@JsonViewSelected per call with writerWithView
Grouping@JacksonAnnotationsInsideYour own annotation that stands for several Jackson ones

The examples use Jackson 2.21 on JDK 21. The annotations are the same in Jackson 3, which still uses the com.fasterxml.jackson.annotation package.

The most useful annotations

The Customer record uses seven annotations at once. The program writes one customer, then reads JSON from an older client that uses old field names, sends a password hash it should not, and includes a field the record does not know.

Java
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.LocalDate;
import java.util.List;

public class Annotations {

    @JsonPropertyOrder({"id", "fullName"})
    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    record Customer(
            long id,
            @JsonProperty("full_name") @JsonAlias({"fullName", "name"}) String fullName,
            @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd.MM.yyyy") LocalDate born,
            @JsonIgnore String passwordHash,
            List<String> tags) {
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = JsonMapper.builder().addModule(new JavaTimeModule()).build();

        Customer c = new Customer(7, "Ana Lima", LocalDate.of(1990, 5, 4), "$2a$10$x", List.of());
        System.out.println("written: " + mapper.writeValueAsString(c));

        // Reading accepts the old field names listed in @JsonAlias and skips unknown fields
        String oldClient = "{\"id\":8,\"name\":\"Ben Cruz\",\"born\":\"12.11.1985\","
                + "\"passwordHash\":\"leaked?\",\"legacyFlag\":true}";
        Customer read = mapper.readValue(oldClient, Customer.class);
        System.out.println("read:    " + read);
    }
}

Output:

Output
written: {"id":7,"full_name":"Ana Lima","born":"04.05.1990"}
read:    Customer[id=8, fullName=Ben Cruz, born=1985-11-12, passwordHash=null, tags=null]
What each annotation did
AnnotationEffect in the output
@JsonProperty("full_name")The field is written as full_name
@JsonAlias({"fullName", "name"})Reading also accepts the old names; the old client's name was used
@JsonFormat(pattern = "dd.MM.yyyy")The date is written and read as 04.05.1990
@JsonIgnorepasswordHash is never written, and the value sent by the client was not read either
@JsonInclude(NON_EMPTY)The empty tags list is left out; NON_NULL would keep it
@JsonIgnoreProperties(ignoreUnknown = true)legacyFlag was skipped instead of failing
@JsonPropertyOrderid and full_name come first

@JsonIgnore working in both directions matters for security: a client cannot set an ignored field such as a role or a password hash by including it in a request. The next section shows the one common way that protection gets lost.

Changing the shape: value types, flattening and open-ended objects

Some annotations change the structure of the JSON rather than single property names. The program reads a user whose email arrives with spaces and capitals, whose address fields sit at the top level, and whose input includes an id and a token the client should not be able to set.

Java
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIncludeProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.LinkedHashMap;
import java.util.Map;

public class MoreAnnotations {

    // @JsonValue / @JsonCreator: a value type written as a single JSON value
    enum Level {
        LOW("low"), HIGH("high");

        private final String code;

        Level(String code) {
            this.code = code;
        }

        @JsonValue
        String code() {
            return code;
        }
    }

    record Email(@JsonValue String value) {
        @JsonCreator
        static Email of(String value) {
            return new Email(value.trim().toLowerCase());
        }
    }

    record Address(String city, String zip) {
    }

    // A combined annotation: one name for a group of Jackson annotations
    @Retention(RetentionPolicy.RUNTIME)
    @JacksonAnnotationsInside
    @JsonIgnore
    @interface Secret {
    }

    public static class User {
        public String name;
        public Email email;
        public Level level;
        @JsonUnwrapped
        public Address address;                         // its fields move up one level
        @JsonProperty(access = JsonProperty.Access.READ_ONLY)
        public long id = 99;                            // written, never read from JSON
        @Secret
        public String token = "t-123";
    }

    // @JsonAnySetter / @JsonAnyGetter: keep properties the class does not declare
    public static class Flexible {
        public String name;
        @Secret
        public String token;
        private final Map<String, Object> extra = new LinkedHashMap<>();

        @JsonAnySetter
        void put(String key, Object value) {
            extra.put(key, value);
        }

        @JsonAnyGetter
        Map<String, Object> extra() {
            return extra;
        }
    }

    @JsonIncludeProperties({"name", "level"})          // allow-list instead of deny-list
    public static class UserSummary extends User {
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        String in = """
                {"name":"Ana","email":"  Ana@Example.COM ","level":"high",
                 "city":"Oslo","zip":"0150","id":1,"token":"stolen"}""";
        User u = mapper.readValue(in, User.class);
        System.out.println("email:   " + u.email + ", level: " + u.level);
        System.out.println("address: " + u.address + ", id: " + u.id + ", token: " + u.token);
        System.out.println("written: " + mapper.writeValueAsString(u));

        Flexible f = mapper.readValue("{\"name\":\"Ben\",\"plan\":\"pro\",\"seats\":5,"
                + "\"token\":\"stolen\"}", Flexible.class);
        System.out.println("any-setter: token=" + f.token + ", extra=" + f.extra());
        System.out.println("any-getter: " + mapper.writeValueAsString(f));

        UserSummary s = mapper.readValue(in, UserSummary.class);
        System.out.println("summary: " + mapper.writeValueAsString(s));
    }
}

Output:

Output
email:   Email[value=ana@example.com], level: HIGH
address: Address[city=Oslo, zip=0150], id: 99, token: t-123
written: {"name":"Ana","email":"ana@example.com","level":"high","city":"Oslo","zip":"0150","id":99}
any-setter: token=null, extra={plan=pro, seats=5, token=stolen}
any-getter: {"name":"Ben","plan":"pro","seats":5,"token":"stolen"}
summary: {"name":"Ana","level":"high"}
  • @JsonValue makes a type serialize as one value: the enum HIGH is written as its code "high", and the Email record as a plain string. The static @JsonCreator factory is used for reading, so " Ana@Example.COM " was normalized to ana@example.com. For enums, Jackson also uses the @JsonValue method to match input, so "high" was read as HIGH.
  • @JsonUnwrapped flattened the Address: its city and zip appear next to name in the JSON and were gathered back into an Address when reading.
  • @JsonProperty(access = READ_ONLY) means "only ever written": the client's "id":1 was ignored and the field kept 99, but 99 was written out. The custom @Secret annotation carries @JacksonAnnotationsInside and @JsonIgnore, so the token was neither read nor written.
  • The trap: @JsonAnySetter receives every property that is not bound to a field, and that includes ignored ones. In Flexible, the @Secret field stayed null, yet token=stolen landed in the extra map, and @JsonAnyGetter wrote it straight back out. Filter the any-setter's keys yourself when ignored properties must never pass through.
  • @JsonIncludeProperties({"name", "level"}) is an allow-list: UserSummary wrote only those two properties. An allow-list is safer than a list of ignored names, because a property added to the class later stays hidden until someone adds it.

Mix-ins for classes you cannot change

A mix-in is a separate class or interface carrying Jackson annotations, registered with addMixIn(target, mixIn). The program uses two mix-ins: one renames a property of a "library" class and hides its API key, and one adds polymorphic type handling to a sealed interface, so a list of shapes can be read back as the right records.

Java
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.util.List;

public class MixIns {

    // Pretend these come from a library: we cannot add annotations to them
    static class Account {
        private final String owner;
        private final String apiKey;

        Account(String owner, String apiKey) {
            this.owner = owner;
            this.apiKey = apiKey;
        }

        public String getOwner() {
            return owner;
        }

        public String getApiKey() {
            return apiKey;
        }
    }

    sealed interface Shape permits Circle, Square {
    }

    record Circle(double radius) implements Shape {
    }

    record Square(double side) implements Shape {
    }

    // Mix-ins: annotations written on a separate type and applied to the target class
    abstract static class AccountMixIn {
        @JsonProperty("account_owner")
        abstract String getOwner();

        @JsonIgnore
        abstract String getApiKey();
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
    @JsonSubTypes({@JsonSubTypes.Type(value = Circle.class, name = "circle"),
        @JsonSubTypes.Type(value = Square.class, name = "square")})
    interface ShapeMixIn {
    }

    record Drawing(List<Shape> shapes) {
    }

    public static void main(String[] args) throws Exception {
        ObjectMapper plain = new ObjectMapper();
        Account acc = new Account("ana", "sk_live_123");
        System.out.println("without mix-in: " + plain.writeValueAsString(acc));

        ObjectMapper mapper = JsonMapper.builder()
                .addMixIn(Account.class, AccountMixIn.class)
                .addMixIn(Shape.class, ShapeMixIn.class)
                .build();
        System.out.println("with mix-in:    " + mapper.writeValueAsString(acc));

        // Polymorphism added by a mix-in: a "type" property selects the subclass
        String json = mapper.writeValueAsString(new Drawing(List.of(new Circle(1.5),
                new Square(2))));
        System.out.println("shapes written: " + json);
        Drawing back = mapper.readValue(json, Drawing.class);
        System.out.println("shapes read:    " + back);

        try {
            plain.readValue(json, Drawing.class);
        } catch (Exception e) {
            System.out.println("same JSON without the mix-in: " + e.getClass().getSimpleName());
        }
    }
}

Output:

Output
without mix-in: {"owner":"ana","apiKey":"sk_live_123"}
with mix-in:    {"account_owner":"ana"}
shapes written: {"shapes":[{"type":"circle","radius":1.5},{"type":"square","side":2.0}]}
shapes read:    Drawing[shapes=[Circle[radius=1.5], Square[side=2.0]]]
same JSON without the mix-in: InvalidDefinitionException
  • Without the mix-in, the API key went straight into the JSON. With it, the owner is renamed to account_owner and the key is gone, without touching the Account class.
  • @JsonTypeInfo and @JsonSubTypes make Jackson write a type property and use it when reading, so the list came back as a Circle and a Square. Without that information, Jackson cannot instantiate the interface and throws InvalidDefinitionException.

How mix-ins are matched, and the silent typo

Mix-ins are matched by member name and signature, and nothing checks that a match exists. The program uses an immutable Money class with no default constructor and an isZero() method that should not appear in JSON, because those are the two problems a mix-in typically solves for a library class.

Java
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;

public class MixInRules {

    // "Library" classes: immutable, no default constructor, no annotations
    public static class Money {
        private final long cents;
        private final String currency;

        public Money(long cents, String currency) {
            this.cents = cents;
            this.currency = currency;
        }

        public long getCents() {
            return cents;
        }

        public String getCurrency() {
            return currency;
        }

        public boolean isZero() {
            return cents == 0;
        }

        @Override
        public String toString() {
            return cents + " " + currency;
        }
    }

    public static class Price extends Money {
        public Price(long cents, String currency) {
            super(cents, currency);
        }
    }

    // Mix-in with a creator: describes the constructor of Money
    abstract static class MoneyMixIn {
        @JsonCreator
        MoneyMixIn(@JsonProperty("cents") long cents, @JsonProperty("currency") String c) {
        }

        @JsonIgnore
        abstract boolean isZero();
    }

    // A typo: there is no method isEmpty() in Money, so this does nothing
    abstract static class TypoMixIn {
        @JsonIgnore
        abstract boolean isEmpty();
    }

    public static void main(String[] args) throws Exception {
        Money m = new Money(1999, "EUR");
        System.out.println("plain:       " + new ObjectMapper().writeValueAsString(m));

        ObjectMapper typo = JsonMapper.builder().addMixIn(Money.class, TypoMixIn.class).build();
        System.out.println("typo mix-in: " + typo.writeValueAsString(m));

        ObjectMapper mapper = JsonMapper.builder().addMixIn(Money.class, MoneyMixIn.class).build();
        String json = mapper.writeValueAsString(m);
        System.out.println("mix-in:      " + json + " -> read back " + mapper.readValue(json,
                Money.class));

        // A mix-in for a class also applies to its subclasses' inherited members
        System.out.println("subclass:    " + mapper.writeValueAsString(new Price(500, "USD")));

        // The mix-in is only on this mapper; other mappers see the class unchanged
        System.out.println("has mix-in:  " + mapper.findMixInClassFor(Money.class)
                .getSimpleName() + ", other mapper: "
                + new ObjectMapper().findMixInClassFor(Money.class));
    }
}

Output:

Output
plain:       {"cents":1999,"currency":"EUR","zero":false}
typo mix-in: {"cents":1999,"currency":"EUR","zero":false}
mix-in:      {"cents":1999,"currency":"EUR"} -> read back 1999 EUR
subclass:    {"cents":500,"currency":"USD"}
has mix-in:  MoneyMixIn, other mapper: null
  • A plain mapper writes "zero":false, because isZero() looks like a boolean getter.
  • TypoMixIn annotates isEmpty(), which does not exist in Money. Jackson accepted it without a warning, and zero was still written. When a mix-in seems to do nothing, compare its method names and parameter types with the target's.
  • MoneyMixIn declares a constructor with the same parameter types as Money's and marks it @JsonCreator, which lets Jackson call the real constructor when reading. The output shows the round trip to 1999 EUR. The mix-in's constructor and abstract methods are never executed; only their annotations are used.
  • The mix-in also applied to the subclass Price, whose inherited isZero() was hidden too.
  • findMixInClassFor confirms that the mix-in belongs to one mapper: a new mapper returned null. Spring Boot can register mix-ins on its shared mapper through @JsonMixin classes or a Jackson2ObjectMapperBuilder.

Polymorphic types

A field declared as an interface can hold different classes, and JSON has no built-in way to say which one. The program writes an event feed with a Login and a Purchase in three formats, then shows what happens without a subtype list and with a type id the reader does not know.

Java
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.util.List;

public class Polymorphism {

    sealed interface Event permits Login, Purchase {
    }

    record Login(String user) implements Event {
    }

    record Purchase(String user, double amount) implements Event {
    }

    // Four ways to record the type, each as a mix-in on the same interface
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
    @JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
        @JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
    interface ByName {
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
    @JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
        @JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
    interface Wrapped {
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
    interface ByClass {
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
    interface NoSubtypes {
    }

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", defaultImpl = Login.class)
    @JsonSubTypes({@JsonSubTypes.Type(value = Login.class, name = "login"),
        @JsonSubTypes.Type(value = Purchase.class, name = "purchase")})
    interface WithDefault {
    }

    record Feed(List<Event> events) {
    }

    static ObjectMapper with(Class<?> mixIn) {
        return JsonMapper.builder().addMixIn(Event.class, mixIn).build();
    }

    public static void main(String[] args) throws Exception {
        Feed feed = new Feed(List.of(new Login("ana"), new Purchase("ana", 9.5)));
        for (Class<?> mixIn : List.of(ByName.class, Wrapped.class, ByClass.class)) {
            ObjectMapper m = with(mixIn);
            String json = m.writeValueAsString(feed);
            System.out.println(mixIn.getSimpleName() + ": " + json);
            System.out.println("  read back equal: " + m.readValue(json, Feed.class).equals(feed));
        }

        // Without @JsonSubTypes, Jackson 2 does not look at the sealed permits list
        ObjectMapper none = with(NoSubtypes.class);
        String noneJson = none.writeValueAsString(feed);
        System.out.println("NoSubtypes writes: " + noneJson);
        try {
            none.readValue(noneJson, Feed.class);
        } catch (Exception e) {
            System.out.println("NoSubtypes read:   " + e.getClass().getSimpleName());
        }

        // An unknown type id fails, unless a default implementation is given
        String unknown = "{\"events\":[{\"kind\":\"logout\",\"user\":\"ben\"}]}";
        try {
            with(ByName.class).readValue(unknown, Feed.class);
        } catch (Exception e) {
            String msg = e.getMessage();
            System.out.println("unknown id:   " + msg.substring(0, msg.indexOf(" as a subtype")));
        }
        System.out.println("with default: " + with(WithDefault.class).readValue(unknown,
                Feed.class));
    }
}

Output:

Output
ByName: {"events":[{"kind":"login","user":"ana"},{"kind":"purchase","user":"ana","amount":9.5}]}
  read back equal: true
Wrapped: {"events":[{"login":{"user":"ana"}},{"purchase":{"user":"ana","amount":9.5}}]}
  read back equal: true
ByClass: {"events":[{"@class":"Polymorphism$Login","user":"ana"},{"@class":"Polymorphism$Purchase","user":"ana","amount":9.5}]}
  read back equal: true
NoSubtypes writes: {"events":[{"kind":"Polymorphism$Login","user":"ana"},{"kind":"Polymorphism$Purchase","user":"ana","amount":9.5}]}
NoSubtypes read:   InvalidTypeIdException
unknown id:   Could not resolve type id 'logout'
with default: Feed[events=[Login[user=ben]]]
  • Id.NAME with a property writes a logical name, "kind":"login", inside each object. This is the most common format and the easiest for other languages to produce.
  • As.WRAPPER_OBJECT wraps each value in an object whose single key is the type, {"login":{...}}, which some APIs and XML-style formats use.
  • Id.CLASS writes the Java class name, "@class":"Polymorphism$Login". It works without a subtype list, but it ties the JSON to your package and class names. Worse, a reader that accepts any class name lets the input choose which class to instantiate, which has caused many remote code execution vulnerabilities in Jackson-based services. Use logical names with an explicit list, or PolymorphicTypeValidator if class names are unavoidable.
  • Without @JsonSubTypes, Jackson 2.21 wrote default names (the class names) but could not read its own output: InvalidTypeIdException. It does not use a sealed interface's permits list to find subtypes, so list them explicitly.
  • An unknown id such as logout fails with "Could not resolve type id". defaultImpl = Login.class made the unknown event fall back to a Login, which keeps old readers working when a newer producer adds event types.

Views: one class, several JSON shapes

@JsonView tags fields with a view class, and writerWithView writes only the fields of that view. View classes can extend each other, so the admin view includes everything public. The notes field has no view on purpose.

Java
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;

public class Views {

    static class PublicView {
    }

    static class AdminView extends PublicView {
    }

    record Employee(
            @JsonView(PublicView.class) String name,
            @JsonView(PublicView.class) String team,
            @JsonView(AdminView.class) int salary,
            String notes) {                          // no view
    }

    public static void main(String[] args) throws Exception {
        Employee e = new Employee("Ana", "payments", 82_000, "promotion due");
        ObjectMapper mapper = new ObjectMapper();

        System.out.println("no view:      " + mapper.writeValueAsString(e));
        System.out.println("public view:  "
                + mapper.writerWithView(PublicView.class).writeValueAsString(e));
        System.out.println("admin view:   "
                + mapper.writerWithView(AdminView.class).writeValueAsString(e));

        // Fields without @JsonView are included in every view unless this is disabled
        ObjectMapper strict = JsonMapper.builder()
                .disable(MapperFeature.DEFAULT_VIEW_INCLUSION).build();
        System.out.println("public view, DEFAULT_VIEW_INCLUSION off: "
                + strict.writerWithView(PublicView.class).writeValueAsString(e));
    }
}

Output:

Output
no view:      {"name":"Ana","team":"payments","salary":82000,"notes":"promotion due"}
public view:  {"name":"Ana","team":"payments","notes":"promotion due"}
admin view:   {"name":"Ana","team":"payments","salary":82000,"notes":"promotion due"}
public view, DEFAULT_VIEW_INCLUSION off: {"name":"Ana","team":"payments"}

The public view left out the salary, and the admin view included it. But notes, which has no view, appeared in the public view too: fields without @JsonView are included in every view by default. Disabling MapperFeature.DEFAULT_VIEW_INCLUSION makes views opt-in, which is the safer setting when views protect sensitive data. Views also work for reading with readerWithView, which ignores input for properties outside the view.

When the shapes differ a lot, separate response records (DTOs) are often clearer than views, because each record shows exactly what is sent.

Choosing between annotations, mix-ins, views and serializers

Which tool to use
SituationUse
You own the class and the JSON is fixedAnnotations on the class
The class is from a library or generated codeA mix-in registered on the mapper
The same class needs different JSON for different APIsMix-ins on separate mappers, or separate DTO records
Some callers may see fewer fields@JsonView with DEFAULT_VIEW_INCLUSION disabled
Interface or abstract field types@JsonTypeInfo(use = Id.NAME) with @JsonSubTypes
Keeping unknown properties@JsonAnySetter and @JsonAnyGetter, filtering ignored names
A type written as one value@JsonValue with a @JsonCreator factory
Logic no annotation can expressA custom serializer

For mapper-wide settings such as naming strategies and unknown-property handling, see ObjectMapper configuration; for registering serializers in reusable bundles, see Jackson modules.