Skip to content

Construction and Typed Values

The rule in one line: if a type has an invariant, make it impossible to construct a value that breaks it. A documented invariant is a comment; a private constructor is a guarantee.

Value classes that validate their input must use the private constructor + companion operator fun invoke pattern, not init { require(...) }. The factory returns Result<T> so callers see validation failures as values, not exceptions.

// WRONG — throws on invalid input; callers must runCatching every construction
@JvmInline
value class LocalPart(val value: String) {
init { require(value.matches(localPartRegex)) { "Invalid local part: $value" } }
}
// CORRECT — private constructor, companion operator invoke returning Result
@JvmInline
value class LocalPart private constructor(val value: String) {
companion object {
operator fun invoke(value: String): Result<LocalPart> = when {
value.matches(localPartRegex) -> Result.success(LocalPart(value))
else -> Result.failure(
AppError.ArgumentValidation("localPart", "Invalid local part: $value")
)
}
}
}
// Call sites
LocalPart("noreply")
.flatMap { local -> EmailAddress(local, domain) }
.flatMap { address -> sendTo(address) }

Conventions:

  • Factory name: operator fun invoke, never create. Call sites read LocalPart("noreply"), identical to the deprecated constructor call.

  • Companion-only construction. The private constructor and the companion share the value class; kotlinx.serialization still generates a serializer via the companion-adjacent declaration, so @Serializable value classes work without changes.

  • Constant baselines for known-valid defaults. Expose a companion object constant (RecentHealth.OK) rather than calling the factory and .getOrThrow() at every reference. The constant is built once at class load with verified inputs.

    @JvmInline
    value class RecentHealth private constructor(val consecutiveFailureCounter: Int) {
    companion object {
    val OK = RecentHealth(consecutiveFailureCounter = 0)
    operator fun invoke(consecutiveFailureCounter: Int): Result<RecentHealth> = when {
    consecutiveFailureCounter < 0 -> Result.failure(
    AppError.ArgumentValidation("consecutiveFailureCounter", "must be >= 0")
    )
    else -> Result.success(RecentHealth(consecutiveFailureCounter))
    }
    }
    }
  • Test fixtures. When constructing a value class with a literal valid input inside a test, the trailing .getOrThrow() is acceptable because the input is known-valid at write time. Wrap result-returning factories with .flatMap in production paths.

The same rule applies to any type carrying an invariant

Section titled “The same rule applies to any type carrying an invariant”

The value class above is the common case, not the boundary of the rule. A data class whose valid instances are produced only by a set of typed factories must make its constructor private too, and for the same reason: a public constructor is a second, unvalidated way in, and it silently invalidates every claim the rest of the type makes about itself.

The failure mode is worth seeing, because it does not look like a construction bug when it fires:

// WRONG — nine typed factories, and a public constructor that bypasses all of them.
data class SettingsDefinition<T>(
val key: String,
val type: SettingsType,
val default: T,
) {
companion object {
fun number(key: String, default: Double) = SettingsDefinition(key, SettingsType.NUMBER, default)
// … eight more, each pairing a type with a matching default
}
}
// Compiles. Blows up much later, in an unrelated file, on the response path.
SettingsDefinition("threshold", SettingsType.NUMBER, default = "oops")

SettingsType.NUMBER paired with a String default is not detected at construction. It surfaces as a ClassCastException inside whatever encodes the value for a response, arbitrarily far from the definition that caused it.

Making the constructor private closes three things at once, which is the shape to look for when weighing this change:

  1. Methods over the type can claim totality honestly. A codec that casts on the paired type genuinely cannot fail, so its bare return becomes legal under the return-type rules rather than being a claim the constructor can falsify.
  2. Unchecked casts stop being load-bearing. A @Suppress("UNCHECKED_CAST") inside the type is sound exactly as long as type and factory stay in lockstep — which is precisely what a public constructor lets a caller break.
  3. A “closed set” of values becomes a closed set. Catalogs, registries, and enum-paired definitions can only guarantee their membership rule if membership runs through them.

Reviewed as three separate comments this reads as pedantry; reviewed as one root cause it is usually the cheapest fix in the diff.

When a public constructor is fine. A data class that is purely a transport shape — every field independently valid, no cross-field invariant, no paired type tag — needs no factory. The rule binds when the type asserts a relationship between its fields, or between a field and a type tag, that construction must uphold.

  • Prefer java.net.URI over java.net.URL for URL-typed fields. URL.equals() and URL.hashCode() trigger DNS resolution, making URL unsafe as a map key, in collections, or inside data classes.
  • Use URI(...).toURL() instead of URL(String). The URL(String) constructor is deprecated in modern Java. Construct via URI first, then convert: URI("https://example.com/path").toURL().
  • Both URISerializer and URLSerializer are registered as contextual serializers in JsonConfig.standardJson. Use URISerializer explicitly when needed (see Database Mappings rule 4).
  • The wire format for URI is a plain JSON string. Changing a field from String to URI requires no database migration.

Domain-Typed Values — never naked base types

Section titled “Domain-Typed Values — never naked base types”

Never use a plain String (or another base type) to represent a value that has a specific meaning in the domain. Always use a type alias or a value class. A Upc, an EmailAddress, and a TenantId are not “strings that happen to look a certain way” — they are domain concepts, and the type system should say so at every declaration and call site.

  • Value class when the value has validation rules or invariants — pair it with the smart-constructor pattern (private constructor + companion operator fun invoke(...): Result<T>, see the value-class section above) so an instance existing proves the value is well-formed.
  • Type alias when the value is unconstrained but domain-meaningful (typealias EntityId = UUID) — zero runtime cost, full intent documentation.

Prefer the most specific type alias available over a raw primitive, even when the alias is just a typealias for that primitive. It costs nothing at runtime and documents intent at every call site.

  • For entity, tenant, and record ids, use the canonical aliases from cards.arda.common.lib.module.dataauthorityEntityId, TenantId, RecordId — never bare java.util.UUID. EntityId for an entity’s own id or a foreign entity id (eId, affiliateEId, a role id); TenantId for the tenant scope; RecordId for a pinned bitemporal record/tombstone id (rId, tombstoneRId). These are all typealias … = UUID, so adopting them is a compile-time-only change — identical bytecode, unchanged serialization (UUIDSerializer still applies), unchanged Exposed columns.
  • The same principle generalizes: reach for general.URL/URI over String for URLs, a value class or enum over a String for a constrained domain value, and a domain typealias over the primitive it wraps. “The most specific type the domain offers” is the default; dropping to a primitive needs a reason.
  • Boundaries where the primitive stays: Exposed column builders (tbl.uuid(...) returns Column<UUID> by the library’s contract — annotating Column<EntityId> is a redundant no-op), and any place an external contract fixes the primitive. Don’t force the alias there.
  • Scope discipline still applies: when tightening types in an existing file, change the ids your change touches; a repo-wide sweep of pre-existing raw UUID/String ids is its own task, not a rider on an unrelated PR.

When changing a field type in a domain value object (e.g., String to URI), update all three layers:

  1. Domain: sealed interface property and Value data class property, including any default values.
  2. Persistence: component column definition, fill(), getComponentValue(), and setComponentValue().
  3. Tests: update test data construction and assertions.

Verify that the wire format (JSON serialization) is unchanged if no database migration is planned.