The work on this software project is in no way associated with my employer nor with the role I'm having at my employer.
I maintain this project alone and as much or as little as my spare time permits using my personal equipment.
Thin abstract wrappers over Java data types to reinforce your domain with compile-type checks.
- Maven
<dependency>
<groupId>io.github.suppierk</groupId>
<artifactId>pico-types</artifactId>
<version>1.0.0</version>
</dependency>
- Gradle (works for both Groovy and Kotlin)
implementation('io.github.suppierk:pico-types:1.0.0')
- Built for Java 17.
- No third-party dependencies.
- 100% unit and mutation test coverage.
equals
andhashCode
out of the box, no need to use Lombok - tested by strict form of EqualsVerifier.- Extensibility - all classes are
abstract
and ready to be implemented:- In case when you need your own more specific type - you can simply extend
PicoType
itself.
- In case when you need your own more specific type - you can simply extend
Optional
-like API
You have probably seen constructs in the code similar to:
// Somewhere across repositories
UUID place(Collection<UUID> entityIds);
// Somewhere in the API
UUID fetchUser(UUID companyId);
// Also can happen within the same API
UUID placeOrder(UUID userId, UUID merchantId);
There is nothing conceptually wrong with these examples, and we have smart IDEs to help us fetch the context - but we can do this:
UUID userId;
UUID merchantId;
// There is nothing wrong with this language wise - except when it will reach production.
userId = placeOrder(merchantId, userId);
Wouldn't it be nicer if previous examples could be rewritten with more support from the language itself?
// Oh, we are actually placing items in the cart!
CartId place(Collection<ItemId> ids);
// We were in fact looking for an employee in an affiliate company!
EmployeeId fetchUser(AffiliateCompanyId companyId);
// We won't be able to make the same mistake - it will simply not compile
OrderId placeOrder(UserId userId, MerchantId merchantId);
Let's take a look at the working example of the previous problem with placeOrder
:
import java.util.UUID;
public class Solution {
public static class UserId extends UuidPicoType {
public UserId(UUID value) {
super(value);
}
}
public static class MerchantId extends UuidPicoType {
public MerchantId(UUID value) {
super(value);
}
}
public static class OrderId extends UuidPicoType {
public OrderId(UUID value) {
super(value);
}
}
public static OrderId placeOrder(UserId userId, MerchantId merchantId) {
return new OrderId(UUID.randomUUID());
}
public static void main(String[] args) {
var userId = new UserId(UUID.randomUUID());
var merchantId = new MerchantId(UUID.randomUUID());
// Now you will get a compile time warning if you will try anything from the problem above
var orderId = placeOrder(userId, merchantId);
System.out.println(orderId);
}
}
We hold on to a lot of context in our heads - tickets to fix, functionality to implement, system design, etc.
Being able to just read the code and understand what needs to be done is a relief.
The main point of the Domain-Driven Design. When there is no confusion around com.shiny.CustomerUserId
- there is no "
Hold on, when we say customer ID - what do we mean by customer?" type of questions on meetings - less time wasted on
explanations for seemingly obvious things to some and completely unknown to others.
To a man with a hammer, everything looks like a nail.
Abraham Maslow, The Psychology of Science, 1966
Is
UserId
returned from one service the same asUserId
consumed (or returned) by another service?
The only solution to this problem is adopting Domain-Driven Design and building a ubiquitous language dictionary.
For example, if we distinguish customers and merchants - it makes sense to stick to the CustomerUserId
and
MerchantUserId
(duh!).
Two services from different teams, where one has
com.shiny.team1.CustomerUserId
and another hascom.shiny.team2.CustomerUserId
- are these equal?
You probably won't be surprised by the answer - it is ubiquitous language dictionary again, this time in a different flavor:
Depending on the architecture of your system, if all services are consumers of the same API it will make sense to
enforce using specific object from that service, e.g. if everyone connect to team1
team service, there must be only
com.shiny.team1.CustomerUserId
.
- However, once this system constraint will be broken (and it will be) there will be a lot of migration pain.
It would be best to introduce a company-wide library with com.shiny.CustomerUserId
.
It might be tempting to re-wrap IDs to avoid having this library - this will only hide the symptoms and will not solve the problem.
Also known as
ThisTimeThisIsForSureCustomerUserIdPinkySwearAndPromise
.
Please, don't do that. ChatGPT can be quite helpful if you are stuck with the naming.
Consider this example:
UUID doingSuperImportantWork(
UUID userId,
UUID companyId,
Instant startingFrom,
int employeeCount,
double fare,
boolean includeWeekend
);
It might be tempting to do something like:
SuperImportantWorkId doingSuperImportantWork(
UserId userId,
CompanyId companyId,
StartTime startingFrom,
EmployeeCounter employeeCount,
Fare fare,
WeekendToggle includeWeekend
);
but it is just too verbose - so use this in moderation, like so:
SuperImportantWorkId doingSuperImportantWork(
UserId userId,
CompanyId companyId,
Instant startingFrom,
int employeeCount,
Fare fare,
boolean includeWeekend
);
or (better yet) avoid having this many parameters for a method and split it onto smaller methods, otherwise if not possible introduce a single object, capturing these parameters within.
I rarely saw confusion cases related to dates - we usually tend to either work with createdDate
and updatedDate
(
sometimes can be more). If you need to cover this (or any other) specific use case - you can easily create your own
PicoType
.
In order to make sure these types play nicely with databases, they should support null
.
With that being said - it is often quite handy to have Optional
-like API around, if you prefer to maintain null-safety
across your codebase and do not have to write constructs like Optional<MyPicoType>
which can quickly turn into a
wrapper fest.
Sometimes we need to return more than one value - this is where people turn their attention to classes like Pair
from
Apache Commons.
While being handy, I suggest to avoid this approach and use proper POJO objects / records.
Another reason why you want to avoid having more than one generic is Optional
-like API - while it could work under
assumption that both values of both types must not be null
, we might need to have a combination where one value is
nullable and the other is not: in that case, as I described before, please, consider using POJO / records.
Since you will have to extend
these classes, you can easily add support for your serialization library - here is an
example for UUID
and Jackson:
public class MyUuidType extends UuidPicoType {
public MyUuidType(UUID value) {
super(value);
}
@JsonCreator
public MyUuidType(String value) {
this(UUID.fromString(value));
}
@Override
@JsonValue
public UUID value() {
return super.value();
}
}
It is a good idea to make this class
final
and its constructorprivate
to disrupt possible inheritance chain.
This example is taken from
UuidPicoTypeTest
in this repository.