Spring, Jackson and Visitors

I've found myself developing quite a bit of webhook integrations with 3rd party APIs which, for the most part, work by developing a web application, exposing an endpoint and registering it through the 3rd party developer's site or something of the like.

These integrations work with a single endpoint and through it, the 3rd party usually sends different type of payloads and one is able to differentiate them by a common field that denotes the type of payload.

Through this development I've found a way to design this by using the Visitor pattern and with the help of Spring and Jackson's sub-typing.

Let's assume we are receiving different types of products through a single endpoint. Just for the sake of an example, let's assume the 3rd party expects from us to return a product discount.

We can receive Books or Manufactured Products:

Book

{
  "type": "book"
  "isbn": "The Trial",
  "title": "978-0805210408",
  "numberOfPages": 344
}

Manufactured Product

{
  "type": "manufactured_product"
  "upc": "038000198861",
  "name": "Kellogg's Froot Loops Cereal"
}

Discount Service

In our theoretical service we would receive these products and then we would connect to other services or databases to figure out if there are available deals or discounts on them. Those deal sources would probably vary depending on the product type as well.

So we would start by creating our models right?

Product.java

@Getter
@Setter
public abstract class Product {
    private String type;
}

Book.java

@Getter
@Setter
public class Book extends Product {

    private String isbn;
    private String title;
    private Integer numberOfPages;

}

ManufacturedProduct.java

@Getter
@Setter
public class ManufacturedProduct extends Product {

    private String upc;
    private String name;
    
}

Jackson Sub-typing

Now that we have some Java POJOs for our application, we can leverage Jackson to do some sub-typing. This will come handy later on when creating our endpoint.

@JsonTypeInfo(use = NAME, property = "type")
@JsonSubTypes({
        @Type(name = "book", value = Book.class),
        @Type(name = "manufactured_product", value = ManufacturedProduct.class)
})
public abstract class Product {

    private String type;

}

This way we can leverage Jackson to convert our JSON objects to the appropriate objects. Jackson's object mapper will use the @JsonTypeInfo annotation to in the Product class to identify which attribute to use in the JSON object to discern the sub-types.

It will then use the @JsonSubTypes one to identify which class to instantiate when the value of the type field.

@Test
void jacksonShouldDeserializeAsSubTypes() throws JsonProcessingException {
    // given
    ObjectMapper objectMapper = new ObjectMapper();
    String book = "{\"type\": \"book\", \"isbn\": \"5012349535\"}";

    // when
    Product product = objectMapper.readValue(book, Product.class);

    // then
    assertThat(product).isInstanceOf(Book.class);
    assertThat(((Book) product).getIsbn()).isEqualTo("5012349535");
}

Now we can develop the web endpoint using Spring Web. We will be creating a POST endpoint that will receive the Product and return a 200 with a Discount or a 404 if there were not discounts available for the product at the time.

POST /discount

We can create a RestController with a mapping like this:

@RestController
public class ProductController {

    @PostMapping(value = "/discount",
            consumes = APPLICATION_JSON_VALUE,
            produces = APPLICATION_JSON_VALUE)
    public ResponseEntity<Discount> discount(@RequestBody Product product) {
        ...
    }
}

The controller will use Jackson behind the hood to convert the received JSON to a Product, and as we configured earlier, the real implementation will be of a concrete sub-type of Book or ManufacturedProduct.

The situation is that now if we want to perform logic with the concrete types we are forced to cast the Product.

@PostMapping(value = "/discount",
            consumes = APPLICATION_JSON_VALUE,
            produces = APPLICATION_JSON_VALUE)
public ResponseEntity<Discount> discount(@RequestBody Product product) {
    
    if (product instanceof Book) {
        String isbn = ((Book) product).getIsbn();
        // search for discounts using the ISBN

    } else if (product instanceof ManufacturedProduct){
        String upc = ((ManufacturedProduct) product).getUpc();
        // search for discounts using the UPC
    }
    ...
}

One way to avoid this casting and instanceof conditionals is to create a @Service class to move the business rules there and to implement a Visitor pattern between the Product subtypes and the Service class.

Product Visitor

We will start by creating a the Visitor interface. This interface will provide a strongly coupled mechanism to visit all the different Product sub-types; when we create new sub-types, a new visitor method will have to be added to the interface, thus forcing the Visitors to implement this new sub-type with a compilation error otherwise.

public interface ProductVisitor<T> {

    T visit(Book book);

    T visit(ManufacturedProduct manufacturedProduct);
}

The generic value T represents the return value this Visitor will be returning after visiting the Products.

Now we can update the Product sub-types to accept the visitor and allow it to visit them. This is what leverages the power of method overloading.

public abstract class Product {

    ...

    public abstract <T> T accept(ProductVisitor<T> visitor);
}

I decided to force the sub-types to implement the visitor accepting method by providing an abstract method in the Product class and implementing it in every sub-type.

public class Book extends Product {

    ...

    @Override
    public <T> T accept(ProductVisitor<T> visitor) {
        return visitor.visit(this);
    }
}

Now we can create our Service implementation that looks for Product Discounts.

@Service
public class ProductDiscountService implements ProductVisitor<Optional<Discount>> {

    @Override
    public Optional<Discount> visit(Book book) {
        // search for discounts with the book
    }

    @Override
    public Optional<Discount> visit(ManufacturedProduct product) {
        // search for discounts with the manufactured product
    }
}

This looks a little cleaner and there's no more casting or missing Product sub-type handling.

Now we can update our Controller and let the Product Visitor Service handle it through the Product.

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductDiscountService productDiscountService;

    @PostMapping(value = "/discount",
            consumes = APPLICATION_JSON_VALUE,
            produces = APPLICATION_JSON_VALUE)
    public ResponseEntity<Discount> discount(@RequestBody Product product) {
        Optional<Discount> discount = product.accept(productDiscountService);
        return ResponseEntity.of(discount);
    }
}

And that's pretty much it! The sub-classing is performed by Jackson and the Visitor pattern cleans the casting which comes inherent with sub-classing at the Service layer.