September 15, 2024
TLDR; Scroll to the bottom of the page for a code snippet to make domain ids in Scala 3.
Using specific types for domain ids (eg. LocationId, EventId… etc) instead of generic UUIDs (Universally Unique Identifiers), is a great way to communicate intent to yourself, other developers, and leverage the compiler type checker. In Scala 3 there are several ways to implement domain ids and each has it's tradeoffs. My preferred approach right now uses opaque types and traits.
opaque type LocationId = UUID
At compile time, the domain id (LocationId) will be type checked, and at runtime each domain id will actually just be a UUID. It's a slick little feature that has no overhead. It's more efficient than creating a case class that wraps the UUID. This approach does have a few hiccups that need to be addressed though — initialization and imports.
To initialize an opaque type you need a companion object with an apply method or constructor methods. For example:
object LocationId { def apply(value: UUID): LocationId = value def init: LocationId = UUID.randomUUID() def fromBinary(data: Array[Byte]): LocationId = UUID.nameUUIDFromBytes(data) }
The companion object allows us to initialize the id by calling LocationId.init, LocationId.fromBinary or LocationId(value) and all methods will produce a valid LocationId. If there are more than a couple domain ids, this initialization code will be duplicated all over. To minimize code, the companion object can be rewritten as a trait and generalized.
/** A >: UUID is a lower type bound. It means that A is constrained to be a supertype of UUID. */ trait Methods[A >: UUID] { def apply(value: UUID): A = value def init: A = UUID.randomUUID() def fromBinary(data: Array[Byte]): A = UUID.nameUUIDFromBytes(data) } opaque type LocationId = UUID object LocationId extends Methods[LocationId]
This is a cleaner solution, but there's still room for improvement when it comes to imports.
It's seldom a good idea to liter the codebase with external dependencies, even if it is the JDK, such as java.util.UUID. I personally like to confine each external dependency to one file location in the code base, if possible. Wrapping an exernal dependency, whether it's a library, resource, or a function, allows you to maximize your control, minimize change impact and encapsulate code.
It does require more work to write the glue code, but I find it to be a worthwhile tradeoff in the long run. In this case, the wrapping code is just one line.
opaque type Uuid = UUID
This may appear like an unnecessary layer of abstraction, but it is a step towards reducing imports and simplifying domain id usage for other developers.
The final Uuid implementation encapsulates the Uuid methods and keeps the java.util.UUID type confined to one file.
opaque type Uuid = UUID object Uuid { def apply(value: UUID): Uuid = value trait Methods[A >: Uuid] { def apply(value: Uuid): A = value def init: A = Uuid(UUID.randomUUID()) def fromBinary(data: Array[Byte]): A = Uuid(UUID.nameUUIDFromBytes(data)) } }
Defining each domain id type is a lot less work now; one import and two statements.
import mypackage.util.Uuid opaque type LocationId = Uuid object LocationId extends Uuid.Methods[LocationId]
Using the domain type is the same as before.
val id1: LocationId = LocationId.init val id2: LocationId = LocationId.fromBinary(bytes) val id3: LocationId = LocationId(Uuid(value))
Hopefully this helps make your code more readable, type safe, and reduces your maintainable code.