Ceylon data classes vs Java records vs POJOs: a decision guide for immutable domain models
A decision guide comparing Ceylon immutable classes, Java records, and manual POJOs for domain models, with a working Ceylon example and validation steps.
18 Jul 2026, 04:46 UTC

The decision: how to model immutable domain data
If you are writing Ceylon code that interoperates with a Java-heavy stack, you have three realistic ways to represent immutable domain values: Ceylon's own immutable classes with shared and variable-free declarations, Java records (on modern JDKs), or hand-written Java POJOs with final fields. The wrong choice costs you either boilerplate, interop friction, or accidental mutability. This guide compares the options against concrete constraints and shows a working Ceylon implementation you can validate yourself.
Note on evidence: Ceylon development has slowed considerably in recent years, and the language never shipped a literal data class keyword in its stable releases. The practical mechanism for immutable models in Ceylon is a class whose constructor parameters are declared shared and non-variable, combined with value constructors where useful. Treat any claim of a dedicated data-class keyword as unverified and check it against your installed compiler version before relying on it.
Constraints that drive the choice
- Immutability guarantee: do you need compile-time enforcement, or is convention enough?
- Interop: will Java frameworks (Jackson, JPA, Spring) consume these types via reflection?
- Boilerplate tolerance: how much equals/hashCode/toString code are you willing to maintain?
- JDK baseline: records require JDK 16+; older toolchains rule them out.
Comparing the supported options
| Option | Immutability | Boilerplate | Java interop | Main risk |
|---|---|---|---|---|
| Ceylon immutable class (shared, non-variable) | Compile-time; fields cannot be reassigned | Low: constructor and accessors are implicit | Compiles to a normal JVM class with getters | equals/hashCode must be defined explicitly unless inherited |
| Java record | Fields are final; shallow immutability | Minimal: equals/hashCode/toString generated | Native Java; excellent reflection support | Requires JDK 16+; awkward to extend from Ceylon |
| Manual Java POJO with final fields | By discipline; nothing stops a setter being added later | High: all methods written by hand or IDE-generated | Universal; works with every framework | Drift: generated code goes stale as fields change |
Trade-offs in practice
Ceylon's type system gives you the strongest guarantee: a reference declared without variable cannot be reassigned, and the compiler rejects mutation attempts. That matters in concurrent code where a shared order or event object must never change after publication. The cost is that Ceylon does not auto-generate value equality for arbitrary classes; you refine equals and hash yourself or accept identity equality.
Java records are the best choice when the type is primarily consumed by Java frameworks. Records give you generated accessors, pattern-matching support in newer JDKs, and predictable reflection behavior. The shallow-immutability caveat applies to all three options: a final field referencing a mutable list is still a mutable model.
Manual POJOs are the fallback for legacy JDKs or frameworks that require no-arg constructors and setters (some JPA providers and older serialization libraries). Accept the boilerplate only when a constraint forces it.
Concrete implementation in Ceylon
The following module defines an immutable domain type and exercises equality. Save it in a Ceylon module (for example source/demo/run.ceylon) and run with ceylon compile demo then ceylon run demo from the project root. You need the Ceylon CLI installed and write access to the project directory. These commands modify only your local build output (modules/); delete that directory to clean up.
class Money(shared Integer cents, shared String currency) {
shared actual Boolean equals(Object that) {
if (is Money that) {
return cents == that.cents && currency == that.currency;
}
return false;
}
shared actual Integer hash =>
cents.hash + 31 * currency.hash;
shared actual String string =>
"``currency`` ``cents / 100.0``";
}
shared void run() {
value a = Money(1299, "EUR");
value b = Money(1299, "EUR");
assert (a == b); // value equality holds
assert (a.hash == b.hash); // consistent hashes
print(a);
}Because cents and currency are declared shared but not variable, any later assignment such as a.cents = 0; fails at compile time. That is the immutability guarantee you are buying.
Validating the result
Three practical checks confirm the model behaves as intended:
- Compile check: add a line that reassigns a field and confirm
ceylon compilerejects it. Remove the line afterwards. - Runtime check: the assertions in
run()above pass only if equality and hashing are consistent across distinct instances. - Interop check: inspect the compiled class with
javap -pon the generated.classfile undermodules/and confirm the fields are final with public getters, so Java libraries can consume the type.
Limitations
- Immutability is shallow in all three approaches; wrap mutable collections in unmodifiable views or use Ceylon's immutable collection types.
- Ceylon's toolchain and IDE support are no longer actively evolving; pin your compiler version and verify generated bytecode when upgrading the JDK.
- If a framework requires setters or a no-arg constructor, none of the immutable options fit directly — use a separate mutable DTO at the boundary and map to your immutable model.
Choose Ceylon immutable classes when the model lives in Ceylon code and you want compiler-enforced safety, Java records when frameworks own the consumption path, and manual POJOs only when legacy constraints leave no alternative.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.