Usage of immutable data structures for simple objects is generally not a controversial topic. Unfortunately, implementation of this idea in real life code often proves to be quite difficult to properly manage, especially considering the tradeoff between boilerplate, maintainability and usability.
This is why, at Treatwell, we have become huge fans of the Immutables library over the years.
This brought us to building our own custom @Style
extensions on top of it, so that it would feel just right to blend it into our our existing
codebase with as little effort as possible while still covering a broad amount of use cases. This has been a great success so far, and while
not everything is perfect yet, we decided we would share these with a wider audience to make it easier for other to benefit from the lessons we
learned on our way to generalizing its usage in-house.
Hopefully you find as much enjoyment to using these as we did and still do!
There are many combinations of non-standard configurations that we do use for better usage and QoL. Here's a few to give you an idea:
- A wider variety of naming strategies to match the intended meaning of your data classes. Never forget the fact that "There are only two hard things in Computer Science: cache invalidation and naming things."
- Out-of-the-box support for FasterXML's wildly popular Jackson serialization library, which is also the de facto standard Json/XML/Yaml serialization library in the Java world, especially for Spring (Boot) using companies like us
- Matching JavaBeans-like
get*
andis*
accessor property names (instead of onlyget*
by default) when you need them - Full support for most proxying/ORM frameworks (think Hibernate & friends), by forcing the presence of a private no argument constructor in generate classes
- And a couple other more specific little tunings (protected definition yet public generated class, safe builders...)
You will find all of the styles that we came up with over the years here.
But we understand that this would be a lot to take in randomly, so here are the two most important (and recommended for general use) ones to start with:
- Client API:
- Naming strategy:
AbstractXyz -> Xyz
- Visibility: The generated class is always
public
, which allows for keeping the abstract one package-private
- Naming strategy:
- Internal implementation:
- Strict builders: Copy and pasting is a dangerous habit, and it is always better to be safe than sorry. Which is why you
cannot set a builder's value multiple times in a row (for this niche use case, there is
@NonStrictValueObjectStyle
) Optional
andnull
: Settingnull
as value for anOptional
field will map it toOptional#empty
(basically likeOptional#ofNullable
) does, as while- Proxying frameworks compatibility (Hibernate etc.): Supported via generated private no-arg constructor
- Strict builders: Copy and pasting is a dangerous habit, and it is always better to be safe than sorry. Which is why you
cannot set a builder's value multiple times in a row (for this niche use case, there is
- Serializable: out of the box with Jackson
- Leaves serialized field name inference to Jackson rather than using Immutables' inference system
@Immutable
@ValueObjectStyle
/* package-private */ abstract class AbstractPerson {
@Parameter
public abstract String getName();
@Parameter
public abstract Instant creationTime();
}
@RestConstroller
public class PersonController {
private final PersonDao personDao;
@PostMapping
public Person createPersonWithName(@RequestParameter("name") String name) {
Person newPerson = Person.of(name, Instant.now());
// or Person.builder().name(name).creationTime(Instant.now()).build();
personDao.savePerson(newPerson);
return newPerson; // automatically serialized by Jackson
}
}
- Client API:
- (default) naming strategy:
Xyz -> ImmutableXyz
- Visibility: The generated class has the same visibility as the abstract one
- (default) naming strategy:
- Internal implementation:
- Strict builders: Copy and pasting is a dangerous habit, and it is always better to be safe than sorry. Which is why you cannot set a builder's value multiple times in a row
Optional
andnull
: Settingnull
as value for anOptional
field will map it toOptional#empty
(basically likeOptional#ofNullable
) does, as while- Proxying frameworks compatibility (Hibernate etc.): Supported via generated private no-arg constructor
- Serializable: out of the box with Jackson
- Leaves serialized field name inference to Jackson rather than using Immutables' inference system
@Immutable
@DefaultStyle
@JsonSerialize(as = ImmutableCount.class) // because Jackson will only see the abstract type instead of the generated one,
@JsonDeserialize(as = ImmutableCount.class) // it needs a little bit of extra help when handling the abstract type directly
public interface Count { // N.B.: If you always use only the generated type, this is unnecessary, but then
// @ValueObjectStyle seems more appropriate
@Parameter
int getCount();
@Parameter
Instant getLastIncrementTime();
}
@RestController
public class MyCountService {
private final AtomicReference<Count> currentCount = new AtomicReference<>(ImmutableCount.of(0, LocalDateTime.now()));
@PostMapping
public void incrementCount(Count count) {
Count oldCount = currentCount.get();
currentCount.set(ImmutableCount.of(count.getCount() + oldCount.getCount(), count.getLastIncrementTime()));
}
@GetMapping
public Count getCurrent() {
return currentCount.get();
}
}
This is a design choice to be made so there's no silver bullet answer to begin with, but a couple of things will often motivate the choice to fall upon one or the other:
The main difference between these two relates to whether you want to manipulate:
- the concrete (generated) class (handled better with
@ValueObjectStyle
) and be mostly blind to the abstract (annotated) one, - or the contrary (and handled better with
@DefaultStyle
).
Now the choice between these two approaches also relates to your concerns balance between serialization, visibility and inheritance
If serialization is the major concern, @ValueObjectStyle
will be the most convenient:
- The naming strategy for it (
AbstractXyz -> Xyz
) makes it much cleaner to use the generated class - This generated class is directly (de)serializable without supplementary code/effort as it is the generated one
- on the other hand, (de)serializing the abstract class, which common with
@DefaultStyle
requires adding@Json{S, Des}erialize(as = ...)
to it
If on the other hand, you have a deep hierarchy, it is much easier to manage it with @DefaultStyle
and its
interface-based usage (not to mention using interfaces is always a pleasant advantage as composition is far easier to manage than inheritance):
- The naming strategy (
Xyz -> ImmutableXyz
) is unwieldy, but does not matter a lot as we will mostly be using the annotated one (i.e. larger instance consumer-to-producer ratio). - Despite the annotated class not being serializable out of the box, you can work around it by using the generated one (which is (de)serializable in this case) as parameters and return types in controllers.
A last point is that @ValueObjectStyle
allows keeping the abstract class package-private which could be useful to
make sure to avoid involuntary usage/inheritance of the abstract one.
To wrap this up, @ValueObjectStyle
should be mostly sufficient in almost all cases that do not require support for
complex class hierarchies.