Using Java Immutables at HubSpot.
Include the following in your pom.xml
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>com.hubspot.immutables</groupId>
<artifactId>hubspot-style</artifactId>
</dependency>
<dependency>
<groupId>com.hubspot.immutables</groupId>
<artifactId>immutables-exceptions</artifactId>
</dependency>
<dependency>
<groupId>org.immutables</groupId>
<artifactId>value</artifactId>
<scope>provided</scope>
</dependency>
if com.google.code.findbugs:annotations
is included as a transitive dependency, it will be used during code generation;
this means it will become a full dependency in your project. If you wish to avoid it, then you'll need to exclude it
from any dependencies that pull it in, otherwise you'll need to explicitly add it.
Start writing POJOs as abstract classes:
@Immutable
@HubSpotStyle
public abstract class AbstractWidget {
public abstract int getPortalId();
public abstract List<Integer> getFoo();
public abstract Optional<String> getBar();
}
or interfaces:
@Immutable
@HubSpotStyle
public interface WidgetIF {
int getPortalId();
List<Integer> getFoo();
Optional<String> getBar();
}
both interfaces and Abstracts can have the name AbstractAbc
or AbcIF
to create classes named Abc
:
@Immutable
@HubSpotStyle
public interface AbstractWidget {
int getPortalId();
List<Integer> getFoo();
Optional<String> getBar();
}
This will generate a class at compile time called Widget
, which you can use as follows:
Widget widget = Widget.builder()
.setPortalId(53)
.addFoo(1, 1, 2, 3)
.setBar(Optional.of("Hello, World"))
.build();
widget.getPortalId(); // 53
widget.getFoo(); // [1, 1, 2, 3]
widget.getBar(); // Optional.of("Hello, World")
If your project isn't in a compilable state, but still want to have access to the generated classes, compile only the immutable interface in question (This is easily done in intellij by just opening the editor to the file and then Build->"Compile blahblah.java" in the menu.
You can add derived properties via default methods:
@Immutable
@HubSpotStyle
public abstract class AbstractWidget {
public abstract int getPortalId();
public abstract Set<Integer> getFoo();
public abstract Optional<String> getBar();
@Value.Derived
public boolean portalIdInFoo() {
return getFoo().contains(getPortalId());
}
}
- Be sure to apply the
@HubSpotStyle
, which will do a few things in the background including- Strip the
Abstract
prefix when naming the generated class - Ensure that the getter methods are properly recognized
- Overall, we use it to make
Immutables
across HubSpot a more uniform experience
- Strip the
- All non
@Nullable
fields must be set before callingbuild()
, otherwise aInvalidImmutableStateException
will be thrown. The only exception to this rule areOptional
s which are detected and defaulted toempty()
. See docs. - Unless explicitly specified (very much discouraged),
null
s are not allowed as field values. See docs ImmutableConditions
methods rely on String.format for formatting, so if there are missing parameters, you may see interesting errors.
You can also add validation to your built object by annotating a method (or multiple methods) with @Value.Check
.
Validation methods should throw InvalidImmutableStateException
on invalid input. Message bodies parsed with Jackson
server-side will automatically throw a 400 Bad Request on upon encountering such an exception. Elsewhere, the exception
will be thrown from the .build()
call (or any .with
calls). To assist with validation, the ImmutableConditions
class is available; it is more or less a drop-in replacement for Preconditions
/PublicPreconditions
:
@Immutable
@HubSpotStyle
public interface WidgetIF {
int getWidgetId();
Set<Integer> getFoo();
Optional<String> getBar();
@Value.Check
default void widgetIdInFoo() {
ImmutableConditions.checkValid(getFoo().contains(getWidgetId()), "widgetId %d must be in Foo!", getWidgetId());
}
}
Methods annotated with @Value.Check
will be executed on .build()
, so once you have a built immutable object, you are
guaranteed that it is in a valid state.
Jackson support is always enabled, since we use it extensively. If you wish to deserialize your AbstractWidget
or
WidgetIF
as a Widget
you will need to add @JsonDeserialize(as=Widget.class)
to the annotations of your WidgetIF
or AbstractWidget
. However if you are only using your AbstractWidget
or WidgetIF
for code generation, and always
expecting to receive a Widget
, you don't need to add any specific Json annotations.
Rosetta
annotations are also passed through, so you can annotate your methods with the correct Rosetta
annotations,
so that those properties are only ever used when working with the DB.
NB: At first adding the annotations will cause your IDE to report an error, as the generated class doesn't exist yet. All will be fine after the project is compiled. This has to do with the way annotation processing works.
@Immutable
@HubSpotStyle
@JsonDeserialize(as = Widget.class) // Only add this line if you expect to deserialize something to an AbstractWidget
public abstract class AbstractWidget {
public abstract int getPortalId();
public abstract Optional<String> getUserEmail();
public abstract List<String> getWidgetStrings();
@RosettaProperty("hubspot_id")
public abstract int getHubSpotId();
}
Sometimes you're going to want to accept an object in a POST
that doesn't contain an ID. Rather than making your ID
Optional
, or @Nullable
, when you know that they are almost never missing, and you don't want to check for them.
That is when the Egg pattern is a good idea:
public interface FooCore {
String getName();
Optional<String> getDescription();
}
@Immutable
@HubSpotStyle
public interface FooEggIF extends FooCore {
}
@Immutable
@HubSpotStyle
public interface FooIF extends FooCore {
int getId();
}
With this pattern your POST
methods can accept a FooEgg
, and you can do something like this:
@POST
public Foo createFoo(FooEgg toCreate) {
int fooId = fooDao.create(toCreate);
return Foo.builder()
.from(toCreate)
.setId(fooId)
.build();
}
This is similar to the Egg Pattern above, however you might want to use it if there are some fields that are required on
your object, but you might not always receive them from the client. The Modifiable object does not perform any
validation until toImmutable
is called.
@HubSpotStyle
@Value.Immutable
@Value.Modifiable
public interface BarIF {
String getName();
String getDescription();
}
@PUT
@Path("bars/{name}/description")
public Bar updateDescription(@PathParam("name") String barName, ModifiableBar modifiableBar) {
Bar bar = modifiableBar
.setName(barName)
.toImmutable();
barDao.update(bar);
return bar;
}
Since immutables 2.1.14 we've been able to use normalization with our classes. This means that we can perform things
like String.trim()
during build time. It is done by a special @Value.Check
that returns the interface, and is always
named normalize
. The key things to remember are
- Always do a test first to see if the instance is already normalized
- Validation has to happen in your
normalize
method - Perform your validations after the normalization, so it only gets performed on your normalized instances
- There can ONLY BE ONE
@Value.Check
method
An example is below:
@HubSpotStyle
@Value.Immutable
public interface NormalizedWidgetIF {
BigDecimal getOptionalBigDecimal();
String getCleanEmail();
@Value.Check
default NormalizedWidgetIF normalize() {
if (!isNormalized()) {
return NormalizedWidget.builder()
.from(this)
.setOptionalBigDecimal(getOptionalBigDecimal().map(bigDecimal -> bigDecimal.setScale(6, BigDecimal.ROUND_HALF_UP)))
.setCleanEmail(getCleanEmail().trim())
.build();
}
ImmutableConditions.checkValid(!getOptionalBigDecimal().isPresent() ||
getOptionalBigDecimal().get().compareTo(BigDecimal.ZERO) > 0,
"Optional BigDecimal must be greater than zero");
return this;
}
@JsonIgnore
@Value.Auxiliary
default boolean isNormalized() {
return getOptionalBigDecimal().map(bigDecimal -> bigDecimal.scale() == 6).orElse(true)
&& getCleanEmail().equals(getCleanEmail().trim());
}
}
Based on https://immutables.github.io/apt.html#intellij-idea
To work with Immutables
you must enable annotation processing. To do so globally follow these steps:
- Go to File → New Project Setup → Preferences for New Projects... (This varies with IntelliJ versions)
- Under Compiler → Annotation Processors toggle
Enable annotation processing
- Set
Store generated sources relative to:
toModule content root
- Set the sources directories to
target/generated-sources/annotations
andtarget/generated-test-sources/test-annotations
respectively. - Under Build Tools → Maven → Importing select
Generated sources folders: Detect automatically
.
You may have to restart IntelliJ (File → Invalidate Caches and Restart...) for it to pick up all the changes successfully. You should now be able to use your generated classes, and build, test and run your code inside Intellij as usual.
IntelliJ compiler seeing the generated classes, but not showing up for autocomplete/autoimport? Try a maven project refresh.
If you're finding that your immutable classes are not being generated; it's possible that there is an unreported error in your code; in that case add this compiler configuration:
<compilerArguments>
<Xmaxerrs>1000000</Xmaxerrs>
</compilerArguments>
so it looks like something below:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArguments>
<Xmaxerrs>1000000</Xmaxerrs>
</compilerArguments>
</configuration>
</plugin>
</plugins>
</build>
Then run mvn clean compile -X
and you'll get the errors out!