Version‑Controlled Modules in Ceylon: Building Reproducible Java‑Compatible Packages
Learn how Ceylon’s module system lets you lock in exact dependency versions, control transitive imports, and package modules as JARs for Maven. A step‑by‑step example shows module creation, version ranges, and practical trade‑offs for reproducible builds.
20 Jun 2026, 05:00 UTC

Concrete Problem: “My builds keep pulling in the wrong version of a library.”
In a multi‑module Java ecosystem, the classpath can become a moving target. When a library is updated, any project that declares a loose dependency may inadvertently compile against the new version, breaking tests or runtime behaviour. Ceylon’s module system was designed to make these version changes explicit and predictable.
Thesis: Ceylon’s versioned modules give you reproducible builds without sacrificing Java compatibility.
The core idea is simple: each module is a named, versioned unit that explicitly declares which other modules it needs. The compiler enforces these boundaries, and the resulting JAR contains a module-info.class that Java’s module system can read. This gives you deterministic dependency resolution, clear visibility of transitive imports, and a path to publish to Maven.
1. Declaring a Module with module.xml
Every Ceylon module starts with a module.xml file at the root of the source tree. The file looks like this:
<module name="com.example.util" version="1.0.0">
<exports>
<package name="com.example.util"/>
</exports>
</module>
- name – unique identifier used by the compiler and Maven.
- version – follows Semantic Versioning (major.minor.patch).
- exports – packages that are visible to modules that import this one.
When you run ceylon compile com.example.util, the compiler produces a JAR containing module-info.class that lists the exported package and the module’s version.
2. Importing with Version Ranges
Another module can depend on com.example.util by adding a requires clause in its own module.xml:
<module name="com.example.app" version="1.0.0">
<requires module="com.example.util" version="[1.0.0,2.0.0)"/>
<exports>
<package name="com.example.app"/>
</exports>
</module>
- The
versionattribute is a range. The syntax[1.0.0,2.0.0)means “any 1.x.x but not 2.0.0 or higher”. - Using a range lets you allow minor upgrades while protecting against breaking major changes.
- If you omit the range, the compiler defaults to the exact version declared in the module’s
module.xml.
To compile the application:
# From the project root containing both modules
ceylon compile com.example.app
The compiler will resolve the dependency to com.example.util-1.0.0.jar because it satisfies the range. If you later publish com.example.util-1.1.0, the same range will pick that newer patch automatically, giving you controlled flexibility.
3. Controlling Transitive Dependencies
By default, a module only exposes the packages you list in exports. Internal packages stay hidden even if they are imported by transitive modules. This prevents accidental leakage of implementation details.
Suppose com.example.util internally uses org.apache.commons.lang3. You can declare that dependency in module.xml without exporting it:
<module name="com.example.util" version="1.0.0">
<requires module="org.apache.commons.lang3"/>
<exports>
<package name="com.example.util"/>
</exports>
</module>
Only com.example.util sees org.apache.commons.lang3, and com.example.app cannot accidentally depend on it unless it explicitly declares a separate requires clause.
4. Packaging for Maven
Because Ceylon modules compile to standard JARs, you can publish them to a Maven repository just like any Java library:
- Run
ceylon compile com.example.utilto generatecom.example.util-1.0.0.jar. - Place the JAR and its
module-info.classin a Mavenpom.xmlthat declares the same groupId/artifactId/version. - Deploy to your internal Nexus or Maven Central using
mvn deploy.
Other projects, whether written in Ceylon or Java, can then declare a dependency on com.example.util:1.0.0 and the Java module system will automatically pick up the module-info.class for compile‑time checks.
Trade‑Offs and Limitations
- Strictness vs. Flexibility – The compiler will refuse to compile if a required module is missing or if a package is not exported. This reduces runtime surprises but may require more granular module design.
- Version Range Pitfalls – A loose range (e.g.,
[1.0.0,)) can silently upgrade to a breaking major version if the module updates its versioning policy. Explicit upper bounds help avoid this. - Legacy Projects – Projects that still use the older Ceylon packaging (without
module.xml) will need to migrate before benefiting from the module system. - Tooling Support – While the compiler enforces boundaries, IDE integration for module resolution is still evolving. Use the command line for reliable builds.
Actionable Checklist
- Define each logical component as a Ceylon module with a clear
module.xml. - Use version ranges that lock the major version but allow patch upgrades.
- Explicitly
exportsonly the public API packages. - Publish modules to a Maven repository and consume them in Java or Ceylon projects.
- Run
ceylon compile --strictto catch missing imports early.
By treating modules as first‑class, versioned units, you gain reproducible builds, clear dependency graphs, and a seamless bridge to the Java ecosystem.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.