Domain IDs in Scala

Domain IDs in Scala

Opaque Types & Companion Trait

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.

Companion Object

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.

Wrap External Dependencies

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.

Uuid Companion Trait

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.


TODO

  • I would love to reduce the type definition and the object extension method into one statement. I'm not sure how to do that yet. Maybe using Scala macros? Not sure.

Attributions

  • The main post image was generated with Leonardo.AI, using the prompt "Abstract painting representing category theory with bright colours and random numbers embedded in the background".