Adopting Ceylon for a Domain‑Model Module: A Practical Architecture Note
Use Ceylon’s union/intersection types and null‑safe optionals to build a small, JVM‑friendly domain model. This guide maps requirements to a minimal design, defines interop boundaries, and lists operational checks and failure modes.
11 Nov 2025, 01:54 UTC

Problem
You need a small, strongly‑typed domain model that can be shared across multiple Java services. The model should express value combinations (e.g., a field that can be either a String or a Number) and enforce null‑safety without runtime checks. Ceylon, a JVM language with first‑class union and intersection types and a built‑in T? null‑safe optional, fits these requirements. However, the 1.3.x toolchain is frozen and the language is no longer actively maintained, so the design must keep the interop surface narrow and the build process reproducible.
Requirements
- Domain model expressed in a single language module.
- Clear boundaries: Java services call Ceylon code through a small API.
- No runtime null errors; compile‑time guarantees via union types.
- Reproducible build on a specific JDK (e.g., JDK 17).
- CI pipeline that verifies compilation, tests, and runtime compatibility.
- Fallback path if the toolchain becomes unusable.
Smallest Suitable Design
The model lives in a single Ceylon module, com.example.domain. It is compiled to ordinary JVM bytecode with ceylon compile and packaged as a JAR. The module exposes only a handful of factory and accessor functions, all annotated with shared so they are visible to Java. Java services use ceylon.interop.java to convert collections and maps where needed.
Example Domain Model
module com.example.domain 1.3.0;
import ceylon.language "1.3.0";
shared class Person(
shared String name,
shared Integer? age, // T? sugar for T|Null
shared Address address)
satisfies Comparable<Person> {
// Intersection type: Person & Comparable
shared actual Integer compareTo(Person other) => name.compareTo(other.name);
}
shared interface Address {
shared String street;
shared String city;
}
shared Address createAddress(String street, String city) => object satisfies Address {
shared actual String street = street;
shared actual String city = city;
};
Here age is a nullable integer. The compiler prevents accessing age without narrowing, ensuring null‑safety. The Person class also demonstrates an intersection type (Person & Comparable<Person>), a feature unique to Ceylon.
Trust and Data Boundaries
All data coming from Java (HTTP payloads, file reads, or external libraries) is treated as Object at the entry point. A dedicated Java wrapper parses the raw data, validates it, and constructs the corresponding Ceylon types via the module’s factory methods. This guarantees that invariants hold inside the Ceylon module and that only well‑typed values cross the boundary.
Java Wrapper Skeleton
public class PersonAdapter {
private final com.example.domain.Person person;
public PersonAdapter(String json) {
// Parse JSON, validate fields
String name = JsonUtils.getString(json, "name");
Integer age = JsonUtils.getIntOrNull(json, "age");
String street = JsonUtils.getString(json, "address.street");
String city = JsonUtils.getString(json, "address.city");
com.example.domain.Address addr = com.example.domain.createAddress(street, city);
this.person = new com.example.domain.Person(name, age, addr);
}
public String getName() {
return person.getName();
}
}
Notice the narrow API: only a constructor and a getter are exposed. All complex logic stays inside Ceylon.
Operational Checks
- Pin the toolchain: Store the exact Ceylon 1.3.x distribution in a local artifact repository (e.g., Nexus) and reference it in your build script.
-- In Gradle: repositories { mavenLocal() maven { url "https://repo.maven.apache.org/maven2" } } dependencies { implementation 'com.ceylon.language:ceylon-language:1.3.0' } - Compile & test:
# Run from the module root ceylon compile ceylon testEnsure you run these on the same JDK as production (e.g.,
JAVA_HOME=/opt/jdk17). - CI Pipeline:
- Checkout the Ceylon module and Java services.
- Set up JDK 17.
- Install the pinned Ceylon distribution.
- Run
ceylon compileandceylon test. - Package the JAR and deploy to a staging environment.
- Run integration tests that exercise the Java‑to‑Ceylon boundary.
- Runtime verification:
- Use JMH or a lightweight benchmark to confirm that calling a Ceylon method from Java incurs no unexpected overhead.
- Enable
-XX:+PrintCompilationto verify that Ceylon classes are compiled normally by the JIT.
Failure Modes
- Toolchain breakage: The 1.3.x compiler may refuse newer JDKs (e.g., JDK 21). Test on the target JDK before every release.
- Dependency resolution: Herd (the original Ceylon repository) may become unreachable. Mirror required modules in an internal Maven repo.
- Interoperability friction: Generics and checked exceptions can lead to subtle bugs. Prototype the exact boundary types early.
- Team skill: Ceylon is niche; onboarding new developers may take longer than for Java or Kotlin.
Conditions That Change the Design
- If the JDK used in production no longer supports the 1.3.x toolchain, the module must be rewritten in a maintained language (Java or Kotlin).
- If the CI cannot reliably compile the module, consider moving the code to a language with a more robust build ecosystem.
- If the maintenance overhead (bug reports, dependency updates) outweighs the benefits of union types, replace the module.
- When the interop boundary expands beyond a handful of methods, the complexity may justify a rewrite.
Practical Example: CI YAML Snippet
name: Ceylon Domain Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
- name: Install Ceylon 1.3.0
run: |
wget https://github.com/ceylon/ceylon/releases/download/1.3.0/ceylon-1.3.0.zip
unzip ceylon-1.3.0.zip -d ~/.ceylon
echo "~/.ceylon/bin" >> $GITHUB_PATH
- name: Compile
run: ceylon compile
- name: Test
run: ceylon test
- name: Package
run: java -jar build/libs/domain.jar
Replace the build/libs/domain.jar path with your actual artifact location.
Conclusion
Using Ceylon for a small domain‑model module gives you powerful type guarantees—union/intersection types and structural null‑safety—without sacrificing JVM compatibility. By keeping the interop surface minimal and pinning the toolchain, you can integrate the module into existing Java services with manageable risk. Regularly verify JDK compatibility, mirror dependencies, and maintain a clear fallback plan to switch to a more mainstream language if the ecosystem evolves unfavorably.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.