When I say Bene, I of course mean Benedikt Waldvogel, a great colleague of mine at cronn and a dear friend. Just last week, he showed me an idea I think is worth sharing with you. Why? Well, it has type-safety, which is great, it even uses sealed classes in a sensible way, I can add some cute cat dog photos and Bene seemed very enthusiastic while explaining it to me. I will try to recapture that moment. Here we go.
Problem Statement
For our pet hotel we will need a check-in process that adds (multiple) pets to our application. We can model it as a POST
request containing a list of pets to an endpoint called /checkin
in REST. No problems here. It gets more interesting when we have a closer look at the pets we want to add. Are all animals equal? As we know, of course not. They have common attributes like a name, but also unique ones, like a chip number that applies to dogs, but not fish. Let’s make those two types the first ones we want to support in our sample application. How do we model this in REST?
Classic Approach with enum Type Field
Well, we need to distinguish somehow between dogs and fish, and one popular way to do it is to add a new enum field type
to the class Pet
. Here’s a sample JSON request:
{
"pets": [
{
"type": "DOG",
"name": "Io",
"chipNumber": 12345
},
{
"type": "FISH",
"name": "Blub"
}
]
}
The request body holds an array of pets. In this case the array holds two items, a dog named Io – Bene seems to always use my dog in his examples – with the fake chipNumber 12345 and a fish named Blub. So far so good.
How does this approach translate into Java code on the server-side?
We examine the REST controller interface first and also widen our view by including the read operation to get all pets currently residing in the pet hotel.
public interface ClassicPetHotel {
@PostMapping("classic/checkin")
GetPetsResponse checkin(@RequestBody @Valid CheckinRequest request);
@GetMapping("classic/pets")
GetPetsResponse getPets();
}
This looks also pretty straight forward, however there are some things to note, and you might have some issues with some of them (hopefully only at first).
We could have modelled the input for the POST request and also the result as a list of pets, something like this:
List<Pet> checkin(@RequestBody @Valid List<Pet> pets);
However, we prefer to have a wrapper class around lists to – generally speaking – make our life easier, for example to easily add more fields to the request later while keeping backwards-compatibility.
Then you might argue the operation name checkin
should be add
and its path pets
. And you may be right. It does not look very restful… but who cares? Generally, we choose client’s comfort over restfulness especially when it’s an internal API. This point will become clearer later when we will extend the checkin to also include the pet’s owner. Right now it is just a simple add, later it will become more than that.
You may also wonder why the response of a checkin operation is a GetPetsResponse
. Well, both responses are a list of pets – simple as that. And why not reuse it?
Now let’s look into those request and response classes. There will be more details to note.
public record CheckinRequest(@NotNull @NotEmpty List<CheckinPetData> pets) {}
public record CheckinPetData(@NotNull String name, Long chipNumber, @NotNull PetType type) {}
So the CheckinRequest
as promised contains the list, but not of pets, but of CheckinPetData
. Why not Pet
? Well, CheckinPetData
does not have an id
, which is usually created by the server, not the client. Pet
will have an id
and we do not want to make it optional just to make it reusable as input data.
And just so, we have another best-practise to note: always use types as specific as possible for the current purpose.
Besides the name, there are no surprises here: CheckinPetData
has all three attributes, we have seen in the JSON request earlier: name
, chipNumber
and type
. We can enforce @NotNull on name
and type
, but unfortunately not all chipNumber
, since fish do not have one. This one will haunt us later.
When we look at the output data, we see a similar structure:
public record GetPetsResponse(@NotNull List<Pet> pets) {}
public record Pet(long id, @NotNull String name, Long chipNumber, @NotNull PetType type) {}
GetPetsResponse
wraps a list of pets. And here finally we have the Pet
class defined as an aggregate of the server-generated id
, name
, chipNumber
and type
, which can be DOG
or FISH
.
With this, we can create a dog or fish like this:
Pet dog = new Pet(1L, "Io", 12345L, PetType.DOG);
Pet fish = new Pet(2L, "Blub", null, PetType.FISH);
This isn’t very nice, and we can improve on this by introducing some factory methods to the class Pet
:
public record Pet(long id, @NotNull String name, Long chipNumber, @NotNull PetType type) {
public static Pet newDog(long id, String name, long chipNumber) {
return new Pet(id, name, chipNumber, PetType.DOG);
}
public static Pet newFish(long id, String name) {
return new Pet(id, name, null, PetType.FISH);
}
}
With those the creation of dogs and fish becomes a bit simpler:
Pet dog = Pet.newDog(1L, "Io", 12345L);
Pet fish = Pet.newFish(2L, "Blub");
However, the problem we try to conceal remains and will grow the more different pet types with unique attributes we introduce. Let’s have a look at a sample implementation of the controller class that does the checkin:
@RestController
public class ClassicPetHotelController implements ClassicPetHotel {
private static final AtomicLong petId = new AtomicLong();
private static final Map<Long, Pet> petHotel = new ConcurrentHashMap<>();
private static long getNextPetId() {
return petId.addAndGet(1);
}
@Override
@PostMapping("classic/checkin")
public GetPetsResponse checkin(@RequestBody @Valid CheckinRequest request) {
List<CheckinPetData> pets = request.pets();
return new GetPetsResponse(checkinPets(pets));
}
@Override
@GetMapping("classic/pets")
public GetPetsResponse getPets() {
return new GetPetsResponse(new ArrayList<>(petHotel.values()));
}
private List<Pet> checkinPets(List<CheckinPetData> pets) {
return pets.stream().map(this::checkinPet).toList();
}
public Pet checkinPet(CheckinPetData checkinPetData) {
Pet pet = createPet(checkinPetData);
petHotel.put(pet.id(), pet);
return pet;
}
private static Pet createPet(CheckinPetData checkinPetData) {
return switch (checkinPetData.type()) {
case PetType.DOG -> Pet.newDog(
getNextPetId(), checkinPetData.name(), checkinPetData.chipNumber());
case PetType.FISH -> Pet.newFish(getNextPetId(), checkinPetData.name());
};
}
}
The checkin
method creates for each CheckinPetData
the corresponding Pet
based on the provided type
with a unique id
in the method createPet
. The pet is then stored by its id
in a Map
and then returned with all the other newly created pets to the client.
This looks quite neat. But you may have noticed, it assumes the client always sends correct data. But nothing prevents that createPet
receives a dog without a chipNumber
or a fish with a chipNumber
. We must validate CheckinPetData
before we call createPet
(or validate it during the creation). But before we start implementing validation (and testing it), can we improve on the solution in the first place?
Polymorphism? – Go with Pattern Matching
To add better input data validation we must put those unique pet attributes into their own separate classes. You may think of polymorphism at this point – and it could work – actually working specification-first OpenAPI type discriminator would have been the correct keyword – but what we recommend here, is pattern matching. Let’s introduce CheckinDogData
and CheckinFishData
with their specific attributes and validations:
public record CheckinDogData(@NotNull String name, long chipNumber) implements CheckinPetData {}
public record CheckinFishData(@NotNull String name) implements CheckinPetData {}
and one interface CheckinPetData
to bring them all and in the darkness bind them. We sealed the interface to permit only the known implementation of checkin data to make pattern matching even nicer i.e. in a switch expression we omit the default case and get a compile-time error while also adding a new pet type.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CheckinDogData.class),
@JsonSubTypes.Type(value = CheckinFishData.class),
})
public sealed interface CheckinPetData permits CheckinDogData, CheckinFishData {
String name();
}
The interface also holds the metadata info required to defer the type from the property type
. It lists all possible values like CheckinDogData
and CheckinFishData
. As a consequence you will see those values in the JSON request:
{
"pets": [
{
"type": "CheckinDogData",
"name": "Io",
"chipNumber": 12345
},
{
"type": "CheckinFishData",
"name": "Blub"
}
]
}
If you prefer to use Dog
and Fish
instead, you can define those names in the metadata like this:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(name="Dog", value = CheckinDogData.class),
@JsonSubTypes.Type(name="Fish", value = CheckinFishData.class),
})
public interface CheckinPetData {
String name();
}
Resulting in a JSON request as nice as in the classic approach:
{
"pets": [
{
"type": "Dog",
"name": "Io",
"chipNumber": 12345
},
{
"type": "Fish",
"name": "Blub"
}
]
}
With that done, we have also to adjust the output data types analogously i.e. introduce classes Dog
and Fish
and bind them together via an interface Pet
with the common parts: name()
and additionally id()
that is generated by the server.
With those changes in the book, we are ready to look at the GrandPetHotelController
:
@RestController
public class GrandPetHotelController {
private static final AtomicLong petId = new AtomicLong();
private static final Map<Long, Pet> petHotel = new ConcurrentHashMap<>();
private static long getNextPetId() {
return petId.addAndGet(1);
}
@PostMapping("/checkin")
public GetPetsResponse checkin(@RequestBody @Valid CheckinRequest request) {
return new GetPetsResponse(checkinPets(request.pets()));
}
@GetMapping("/pets")
public GetPetsResponse getPets() {
return new GetPetsResponse(new ArrayList<>(petHotel.values()));
}
private List<Pet> checkinPets(List<CheckinPetData> pets) {
return pets.stream().map(this::checkinPet).toList();
}
private Pet checkinPet(CheckinPetData petData) {
Pet pet = createPet(petData);
petHotel.put(pet.id(), pet);
return pet;
}
private static Pet createPet(CheckinPetData petData) {
return switch (petData) {
case CheckinDogData dog -> new Dog(getNextPetId(), dog.name(), dog.chipNumber());
case CheckinFishData fish -> new Fish(getNextPetId(), fish.name());
};
}
}
It looks very similar to the classic version with enum types. The only difference is the createPet
method and even this one looks similar due to the used switch expression. But here the switch is on the class type not on the enum. That has the advantage that there is no room for error. CheckinDogData
contains exactly the attributes it requires – no more, no less – and the same applies to CheckinFishData
. The creation of Dog
and Fish
can be performed without any need for validation in the controller – all this is done in the layers before. We can concentrate on functional logic.
Speaking of functional logic, the checkin operation as of now is a bit dull. It only adds pets to the application. In the next section we will extend it to also include pet owners and showcase the pattern matching pattern in a slightly different scenario.
Objects and object references with Pattern Matching
Our pet hotel application can check in pets. So far so good. But assuming we want a profitable joint venture, we should probably consider also including the owners’ data during the process. Puppy eyes alone ain’t paying the bill, right? So let’s get back to our drawing board and look at our new JSON checkin request that includes the owner:
{
"pets": [
{
"type": "Dog",
"name": "Io",
"chipNumber": 12345
},
{
"type": "Fish",
"name": "Blub"
}
],
"owner": {
"firstName": "Michał",
"lastName": "Bażański"
}
}
Easy. We can add a new field owner
to the CheckinRequest
parallel to the pets
array. What a blessing we did use a wrapper class around the pets list.
So now, Io and Blub have me as their newly created owner in the application – even though, in fact Io is my wife’s dog – I’m just his roommate – and I’ve never heard of Blub.
But what if I’m a recurring customer and already reside in the hotel’s database? Wouldn’t it be neat to just send an owner’s id
instead like this:
{
"pets": [
{
"type": "Dog",
"name": "Io",
"chipNumber": 12345
},
{
"type": "Fish",
"name": "Blub"
}
],
"owner": {
"id" : 9876
}
}
Yes, it would, and we can make it work with pattern matching. But wait… do you feel the disturbance in the Force… as if millions of restfulnians suddenly cried out in terror… should I try to explain why? Nah… you figure it out yourself. I can only repeat myself, client’s comfort over restfulness.
So what’s to do? We have to create a common interface for the owner data and the reference. Let’s call it OwnerData
and the implementing classes CheckinOwnerData
and OwnerReference
.
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
@JsonSubTypes.Type(value = OwnerReference.class),
@JsonSubTypes.Type(value = CheckinOwnerData.class),
})
public sealed interface OwnerData permits OwnerReference, CheckinOwnerData {}
The interface has no methods, since the owner’s reference and the owner data do not have any common fields. You may have also noticed, we are using JsonTypeInfo.Id.DEDUCTION
instead of a dedicated field to keep the type information. With this setting the type will be deduced from the field names available in the request. We decide to omit the type field, since it seems redundant for readability and understandability. You may not even have noticed it was missing in the JSON request above in the first place, have you?
The implementation OwnerReference
and CheckinOwnerData
classes are straight forward. No surprises there.
public record OwnerReference(long id) implements OwnerData {}
public record CheckinOwnerData(String firstName, String lastName) implements OwnerData {}
And lastly we have to add the owner field to the CheckinRequest
:
public record CheckinRequest(
@NotNull @NotEmpty List<CheckinPetData> pets, @NotNull OwnerData person) {}
And we are done. At this point we can send via the same request either the full owner data or just the id. The application can, depending on the input, look up the owner in its database or create a new one.
I will show you only the most important code snippet for it, so we can stay within the recommended length limits of 1500 words… we are at 2427??? oh well, this ship has sailed. But congratulations to you for hanging on. Impressive attention span.
Here is the snippet:
@Override
@PostMapping("/checkin")
public GetPetsResponse checkin(@RequestBody @Valid CheckinRequest request) {
long ownerId = createOrGetOwner(request.person());
return new GetPetsResponse(checkinPets(ownerId, request.pets()));
}
private long createOrGetOwner(OwnerData person) {
return switch (person) {
case OwnerReference ownerRef -> getOwner(ownerRef).id();
case CheckinOwnerData ownerData -> createAndSaveOwner(ownerData);
};
}
Before we add the pets to the application we create or get the owner via a switch expression on the type of OwnerData
. In this simple implementation we return the id
of an existing or newly created owner and can use it to check in the pets e.g. add the owner id to them.
A link to the full source code will be provided at the bottom of the post.
Conclusion
So what should you take out from this post? I think most importantly that you should design your web APIs as precise and use-case specific as possible and that pattern matching can help you with that – by either providing you with heterogeneous lists (the pet list example with dogs and fish) or interchangeable object types (the owner and owner id example).
And now finally on to the promised photo of the main star of this article.
Source Code
https://github.com/cronn/pet-hotel
Credits
Original idea and proofreading: Benedikt Waldvogel
Cover art and photography: Julia Bażańska
Text: me