Freezable — Making Old-School Mutable Value Types Safe¶
The problem: value objects that aren't immutable¶
A value object is defined by its value, not its identity: two dates representing the same day are
interchangeable, and once an entity says "the invoice date is 2026-06-25," that value should not silently change
underneath it. Conceptually such objects ought to be immutable, like String, BigDecimal or the
java.time types.
But the classic JDK date/time types — java.util.Date and its JDBC subclasses java.sql.Date,
java.sql.Time, java.sql.Timestamp — are mutable. They expose setTime(long) (and the deprecated
setYear, setMonth, …). The same is true of any byte-buffer-backed binary value. This is a well-known design
wart, and it is actively dangerous the moment such an object becomes an attribute of a persistence entity:
java.util.Date d = new java.util.Date();
invoice.setDate(d); // entity now references d
...
d.setTime(0); // OOPS: mutated the invoice's date behind its back
The caller still holds a reference to the very object the entity stored (aliasing). Mutating it bypasses everything the entity setter is responsible for:
- dirty tracking — the change never goes through
setDate(...), so the tracked entity never marks itself modified and the edit is lost on save (or saved without anyone noticing); - immutability — an entity made
Immutable(a cached master-data row, a read-only view, an aggregate component) could still be mutated through an aliased field value, defeating the whole guarantee; - validation and property-change events — none of them fire.
Java's only built-in answer is final (compile-time, all-or-nothing) — useless here, because the field value
arrives at runtime. Tentackle needs a value object that can be made read-only at runtime, permanently, once it
is handed to an entity. That is Freezable.
The Freezable contract¶
Freezable (in tentackle-common) is a tiny interface:
public interface Freezable {
void freeze(); // make this object immutable
boolean isFrozen();
static void freeze(Freezable freezable) { // null-safe helper, keeps generated code small
if (freezable != null) {
freezable.freeze();
}
}
}
Two rules define its semantics:
- Freezing is one-way and final. Once frozen, an object can never be unfrozen. There is no
unfreeze(). Afterfreeze(), every mutator throws aTentackleRuntimeException. - Clones come out unfrozen. Cloning (or copying) a frozen object yields a fresh, mutable instance. This is what lets you take a frozen stored value, copy it, edit the copy, and assign it back — the normal "change a date" workflow.
The Freezable value types¶
Because you cannot retrofit freeze() onto java.util.Date, Tentackle ships drop-in subclasses that add
the capability. They are the types you use as model attributes instead of the raw JDK ones:
| Tentackle type | Extends / base | Adds |
|---|---|---|
Date |
java.sql.Date |
freezability + timezone-stable YYYYMMDD serialization |
Time |
java.sql.Time |
freezability + database time semantics |
Timestamp |
java.sql.Timestamp |
freezability + optional UTC flag |
Binary / TBinary |
AbstractBinary |
freezability over a serialized byte buffer |
Each carries a private frozen flag and overrides every state-changing method to check it first. From
Date:
@Override
public void setTime(long date) {
assertNotFrozen(); // throws "date is frozen" once frozen
super.setTime(date);
}
private void assertNotFrozen() {
if (frozen) {
throw new TentackleRuntimeException("date is frozen");
}
}
@Override
public Date clone() {
Date date = (Date) super.clone();
date.frozen = false; // clones are always mutable
return date;
}
These types solve a second problem at the same time: stable cross-timezone semantics.
java.sql.Dateserializes epochal milliseconds, so a date can shift by a day when deserialized in another timezone — fatal for remoting. Tentackle'sDateserializes the calendarYYYYMMDDinstead, so the value is identical everywhere. Thefrozenflag is part of that serialized state (writeBoolean(frozen)), so freezing survives a trip over TRIP. See common.md → Database-aware data types.
Note what is not here: BMoney / DMoney extend BigDecimal and are immutable by nature, so they don't
need Freezable. Freezability is only for value types the JDK left mutable.
How the persistence layer uses it¶
The link between "this attribute is a mutable value type" and "freeze it" lives in the
DataType abstraction. A data type
reports whether its Java representation is a mutable, freezable object:
isMutable() returns true exactly for the date/time and binary types above. The code generator
(MethodsImpl)
then emits a freeze on assignment in every generated setter for such an attribute:
void setInvoiceDate(Date invoiceDate) {
Freezable.freeze(invoiceDate); // generated only when the DataType isMutable()
if (!Objects.equals(this.invoiceDate, invoiceDate)) {
assertMutable(); // the entity's own Immutable guard
firePropertyChange(AN_INVOICEDATE, this.invoiceDate, invoiceDate);
this.invoiceDate = invoiceDate;
}
}
The effect: the instant a value object is stored in an entity attribute, it is frozen. The caller may keep
its reference, but can no longer mutate the stored value — the aliasing bug from the opening example becomes a
loud TentackleRuntimeException instead of silent corruption. To change the value, you must call the setter with
a different (or cloned-and-edited) instance, which routes through dirty tracking, the immutability check
(assertMutable()) and property-change notification as intended.
Relationship to Immutable¶
Freezable and Immutable are deliberately different
mechanisms for two different kinds of objects, and they cooperate:
Freezable |
Immutable |
|
|---|---|---|
| Applies to | mutable value objects in entity fields (Date, Timestamp, Binary, …) |
entities / containers (PDOs, ImmutableArrayList) |
| Direction | one-way and final — never unfrozen | switchable back and forth (until setFinallyImmutable()) |
| Granularity | a single scalar value | an object and, cascading, its whole aggregate |
| Lives in | tentackle-common |
tentackle-core (org.tentackle.misc) |
A PDO's scalar attributes are frozen value objects, while the PDO itself is Immutable. Freezing the
field values closes the aliasing hole that Immutable alone cannot — assertMutable() stops you from calling
the setter on an immutable entity, but only Freezable stops you from mutating a value you already pulled out
of (or pushed into) it.
See the Immutable documentation for the entity/container side, including its three-state model and aggregate cascading.
Rules of thumb¶
- Use Tentackle's value types (
Date,Time,Timestamp,Binary/TBinary) for model attributes, not the rawjava.util/java.sqltypes — you get freezability and timezone-stable serialization. - Don't hold on to a stored value expecting to mutate it. Once assigned to an entity, it is frozen. To change it, build/clone a new instance and call the setter.
- Clone to edit.
clone()/copy()of a frozen value returns a mutable one. - Freezing is permanent. There is no unfreeze; a value's only path back to mutability is a fresh clone.
See Also¶
- Immutable — Switchable Read-Only Objects — the switchable entity/container counterpart, and how the two cooperate.
- common.md → Database-aware data types — the value types that implement
Freezableand their database/timezone semantics. - Tentackle SQL — the
DataTypeabstraction whoseisMutable()flag drives freeze-on-assignment. - Snapshots — dirty tracking that aliasing mutation would otherwise defeat.