Writing Clojure Libraries for Other JVM Languages

How to design Clojure libraries that Java, Kotlin, or Scala consumers can use without fighting Clojure-specific conventions.

Writing Clojure libraries for other JVM languages is mostly an API design problem. The hardest part is not creating the jar. The hard part is deciding what should be visible to Java, Kotlin, or Scala consumers and what should remain idiomatic Clojure implementation detail.

The more your public surface depends on lazy seq behavior, keywords as hidden protocol, or exception semantics that only make sense to Clojure developers, the harder the library becomes to adopt outside Clojure.

Design the Public API for Foreign Consumers

If the library is meant to be called from another JVM language, the public contract should bias toward:

  • plain scalars
  • maps with documented keys
  • Java collections when that helps the consumer
  • narrow entry points
  • predictable exception shapes

The public API should not require the consumer to know how a transducer pipeline or persistent map update was implemented internally.

Prefer Functions First, Facades Second

A common mistake is to jump straight to gen-class as if every cross-language library must generate a Java-style façade. Often that is unnecessary.

A simpler default is:

  1. write ordinary Clojure functions
  2. package the library as a normal jar
  3. let consumers call into it through the Clojure runtime API when appropriate
  4. add a Java-friendly façade only when the consumer experience genuinely needs it

The underlying library can stay straightforward:

1(ns acme.normalize)
2
3(defn normalize-email [s]
4  (some-> s
5          clojure.string/trim
6          clojure.string/lower-case))

Java can call this through clojure.java.api.Clojure:

 1import clojure.java.api.Clojure;
 2import clojure.lang.IFn;
 3
 4public class Example {
 5    public static void main(String[] args) {
 6        IFn require = Clojure.var("clojure.core", "require");
 7        require.invoke(Clojure.read("acme.normalize"));
 8
 9        IFn normalize = Clojure.var("acme.normalize", "normalize-email");
10        Object value = normalize.invoke("  ADMIN@EXAMPLE.COM ");
11        System.out.println(value);
12    }
13}

That is often enough for internal platforms or mixed-language services.

Add a Java-Friendly Façade Only When It Pays Off

If consumers need a more ordinary Java experience, provide a thin wrapper around the Clojure core:

  • stable class names
  • explicit method names
  • Java collection types where appropriate
  • checked documentation around nulls and exceptions

The key principle is that the façade should stay small. If the whole library must be contorted into a Java imitation, the boundary has probably been chosen badly.

Package Like a Normal JVM Library

For a modern Clojure library, a normal deps.edn plus tools.build flow is a good default:

1{:paths ["src" "resources"]
2 :deps {org.clojure/clojure {:mvn/version "1.12.4"}}}

Publish the result like any other JVM artifact:

  • to an internal Maven repository
  • to Clojars
  • or to Maven Central when the library is intended for wider distribution

The important thing is that Java or Kotlin consumers can resolve the library through the same artifact workflow they already understand.

Be Deliberate About Return Types

The public surface should answer one question clearly: what is the consumer supposed to receive?

Good candidates:

  • String
  • long
  • boolean
  • java.util.Map
  • java.util.List
  • a small documented Java class or record-like shape

Riskier candidates:

  • lazy sequences with surprising realization behavior
  • transducers as part of the public contract
  • Clojure-only data conventions that are undocumented outside the source

You can absolutely use these internally. The point is to avoid making them the foreign consumer’s problem by default.

Treat Error Semantics as Part of the API

When another JVM language consumes your library, exceptions become part of the public contract. That means you should decide:

  • which exceptions are expected
  • what messages should say
  • whether contextual data lives in ex-info
  • whether a Java-friendly wrapper exception is needed

If the consumer is a Java team, “something blew up in a macro-generated internal namespace” is not a helpful failure mode.

Documentation Matters More Across Language Boundaries

A cross-language library should usually document:

  • how to add the dependency
  • the smallest working call from Java or Kotlin
  • the expected input and output types
  • nullability assumptions
  • exception behavior
  • whether the consumer needs to initialize the Clojure runtime explicitly

That is not overhead. It is part of making the library actually usable.

A Better Architecture Model

    flowchart LR
	    A["Foreign JVM Consumer"] --> B["Small Java-Friendly Facade (Optional)"]
	    B --> C["Clojure Core Functions"]
	    A --> C
	    C --> D["Private Clojure Implementation"]

The important thing to notice is layering. The Clojure core can remain idiomatic while the external contract stays stable and understandable.

Key Takeaways

  • Cross-language Clojure libraries succeed or fail mainly on public API design, not on packaging tricks.
  • Start with ordinary Clojure functions and add a façade only when the consumer experience needs it.
  • Publish artifacts through normal JVM dependency channels so other teams can consume them naturally.
  • Return types, nullability, and exceptions are part of the cross-language contract.
  • Keep the public surface smaller and simpler than the internal implementation.

References and Further Reading

Ready to Test Your Knowledge?

Loading quiz…
Revised on Thursday, April 23, 2026