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-class smart constructors
Section titled “Value-class smart constructors”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@JvmInlinevalue class LocalPart(val value: String) { init { require(value.matches(localPartRegex)) { "Invalid local part: $value" } }}
// CORRECT — private constructor, companion operator invoke returning Result@JvmInlinevalue 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 sitesLocalPart("noreply") .flatMap { local -> EmailAddress(local, domain) } .flatMap { address -> sendTo(address) }Conventions:
-
Factory name:
operator fun invoke, nevercreate. Call sites readLocalPart("noreply"), identical to the deprecated constructor call. -
Companion-only construction. The private constructor and the companion share the value class;
kotlinx.serializationstill generates a serializer via the companion-adjacent declaration, so@Serializablevalue classes work without changes. -
Constant baselines for known-valid defaults. Expose a
companion objectconstant (RecentHealth.OK) rather than calling the factory and.getOrThrow()at every reference. The constant is built once at class load with verified inputs.@JvmInlinevalue 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.flatMapin 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:
- 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.
- 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. - 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.
URL and URI Types
Section titled “URL and URI Types”- Prefer
java.net.URIoverjava.net.URLfor URL-typed fields.URL.equals()andURL.hashCode()trigger DNS resolution, makingURLunsafe as a map key, in collections, or inside data classes. - Use
URI(...).toURL()instead ofURL(String). TheURL(String)constructor is deprecated in modern Java. Construct viaURIfirst, then convert:URI("https://example.com/path").toURL(). - Both
URISerializerandURLSerializerare registered as contextual serializers inJsonConfig.standardJson. UseURISerializerexplicitly when needed (see Database Mappings rule 4). - The wire format for
URIis a plain JSON string. Changing a field fromStringtoURIrequires 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+ companionoperator 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.dataauthority—EntityId,TenantId,RecordId— never barejava.util.UUID.EntityIdfor an entity’s own id or a foreign entity id (eId,affiliateEId, a role id);TenantIdfor the tenant scope;RecordIdfor a pinned bitemporal record/tombstone id (rId,tombstoneRId). These are alltypealias … = UUID, so adopting them is a compile-time-only change — identical bytecode, unchanged serialization (UUIDSerializerstill applies), unchanged Exposed columns. - The same principle generalizes: reach for
general.URL/URIoverStringfor URLs, a value class or enum over aStringfor a constrained domain value, and a domaintypealiasover 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(...)returnsColumn<UUID>by the library’s contract — annotatingColumn<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/Stringids is its own task, not a rider on an unrelated PR.
Type Migration Checklist
Section titled “Type Migration Checklist”When changing a field type in a domain value object (e.g., String to URI),
update all three layers:
- Domain: sealed interface property and
Valuedata class property, including any default values. - Persistence: component column definition,
fill(),getComponentValue(), andsetComponentValue(). - Tests: update test data construction and assertions.
Verify that the wire format (JSON serialization) is unchanged if no database migration is planned.
Related
Section titled “Related”- Nullability and Return Types — a private constructor is how a bare return is earned.
- Errors and Exceptions —
AppErrorvariants a factory returns. - Naming Conventions — what to call the types you build.
- Kotlin Coding Standards — index.
Copyright: © Arda Systems 2025-2026, All rights reserved