I’m curious what the general consensus is with regard to using typed identifiers versus primitive values in DTOs? It seems like I should be able to since they more or less serve as Value Objects.
I think of identifiers in the distributed systems as some special part, they have unique behavior - they (should) are unique across network, meaning every time you create them there it should not cause any conflict.
I’m using unique identifiers based on UUID v3 (v3 because i can make some of them idempotent based on the others, without providing what their idem potency is based on).
When you have alot of aggregates which are identified by some globally identifier which is basically numeric type (eveything is a number really) and thoose aggregates contain other aggregate references based on their identifiers it may be sometimes non trivial to identify what ‘type’ of aggregate this identifier points to.
You have two options to hint yourself in the code what kind of reference you are ‘reffering’ to.
First - naming convention, basically naming your identifiers within aggregates, repositories by their logical target name eg userId, walletId. It is the simplest form of the hint but it requires a focus on naming things and does not provide you any guard, you need to focus and follow the convention yourself.
Second - Wrapping the identfier with some class so you make it ‘typed identifier’, the underlying data structure which is numeric is wrapped around some specific type of your choosing the WalletId for example. Whenever something requires a reference to WalletId you need to create the wrapper and put identifier inside it, axon requires the identifiers to have String data structure, this is because of the database constraints, everytime you use some identifier it needs the String value to save it in the database, in sagas, aggregates, projections etc. So the only requirement of such Wrapper in java would be that it exposes toString method which you would call everytime you need to pass the identifier to some axon persistent component for example saga and their associations.
So the only thing which for me is huge in large distributed systems that wrappers give you is that you have compile time checking.
However, you mention the DDD concept which is ValueObject, the wrapped identifiers are NOT value objects. The value objects are the domain concept and they DO have a behavior, stateless but still, a behavior. For example Username could be a value object which wraps the String value but it also contains methods like ‘isEmpty’, ‘isValid’ which can be invoked without other domain objects but still contain the business requirements such as isValid might introduce checking whether the Username contains illegal characters.
In events and commands you should NOT include the value objects, because the domain requirements might change but your events should not, in your events and objects you should indeed have only ‘primitive values’ from which you can create ValueObjects inside your domain.
With that being said - yes i think that in distributed scenario the identifiers are special type of ‘things’ which can put inside the events/commands, think about it this way - identifier is a stream of ones and zeros, but you then you make number out of it and then you make string out of it, so you should be just fine to add another layer of ‘wrapping’ that would ensure the type checking just fine.
Also a side note, if you are worried about your code being not represented properly across different language (the wrapped type needs to be serialized) and you want to keep primitive value as primitive as possible without introducing another layer of serialization. For example you want to use service used in Rust and handle some event published by java but you do not want to have deserializer in your service for WalletId which was serialized using java serializer, you just want to have the String deserializer, you can use a concept from Kotlin language https://kotlinlang.org/docs/reference/inline-classes.html this kotlin only but i suspect that same thing can be done in scala or java with some annotation processor. Basically what it gives you is the ability to define some class that would be type checked on the compilation time, but on the runtime it will act like it doesn’t exist, only the wrapped primitive value would be used. I use this and I’m happy.