Micronaut Jakarta EL is an implementation of the
Jakarta Expression Language 6.0
specification whose expressions and bean resolution are prepared at compilation time, with
Micronaut SourceGen and an annotation
processor.
An interpreting implementation parses an expression string, builds an abstract syntax tree and walks it on every
evaluation, resolving each property reflectively along the way. This module moves all of that to compilation time:
every declared expression becomes a generated jakarta.el.ValueExpression or jakarta.el.MethodExpression
whose body is the compiled form of the expression, so nothing is parsed and no tree is walked at evaluation time;
every @Introspected type is resolved through the bean introspection Micronaut already generates for it, which
replaces the reflective lookups of jakarta.el.BeanELResolver;
every property access, method invocation, function call and static reference whose type is known at compilation
time becomes a direct Java invocation.
The annotation processor runs for Java, Groovy and Kotlin and produces the same expression classes for all
three; a Java build additionally gets them as readable generated sources.
The implementation passes the Jakarta Expression Language 6.0 Technology Compatibility Kit — 360 tests, no
failures, none skipped — on every build; the tests/jakarta-el-tck module of the repository runs it, and a
workflow publishes the evidence of each run.
Note
The public API, annotations included, is marked @Experimental: it can change between minor versions until the
first stable release.
2 Why Micronaut Jakarta EL?
The goal of this module is to be a complete build-time implementation of Jakarta Expression Language 6.0 that
does not parse, walk trees or use reflection at runtime. The reasons to prefer it over an interpreting
implementation are outlined below.
Runtime Performance
The EvaluationBenchmark JMH benchmark evaluates the same set of expressions — from a property read to a stream
pipeline with two lambda expressions — with compiled Micronaut Jakarta EL, with its runtime interpreter, with
Eclipse Expressly (the reference implementation) and with Apache Tomcat Jasper EL, each against the context it
provides by default. The figure that sums a run up is the geometric mean of the average evaluation times over
all the benchmarks; its ratio reads as "how many times slower than the compiled expressions, on average".
OpenJDK 25.0.2 on Apple Silicon, JMH 1.37, 1 fork, 3 warmup and 5 measurement iterations of 1 s, average time:
Implementation
Geometric mean of the average times
Relative to the compiled stack
Micronaut compiled
8.63 ns
1.0x
Micronaut interpreted
82.9 ns
9.6x
Eclipse Expressly
367 ns
42.5x
Tomcat Jasper EL
449 ns
52.0x
In this run a compiled expression evaluated in 8.6 ns on average — about 43 times faster than the reference implementation and 52 times faster than Tomcat’s. The gap is narrowest, around 6x, where every implementation resolves at runtime (a bean not declared with a type), and widest — one hundred times and more — wherever the others fall back to reflection: a method call on a List or a String, a lambda expression coerced to a functional interface, a stream pipeline. The interpreter of this module, the fallback for expressions created at runtime, averaged 83 ns — itself over 4 times faster than the reference implementation — because it walks its trees against the same compiled runtime.
Click the chart for the chart of every benchmark; the benchmarks module of the repository holds the harness,
every figure with its error in its README.md, and runs with ./gradlew :micronaut-benchmarks:jmh.
No Reflection
A declared variable’s properties and methods compile to direct invocations, dynamic types resolve through the
bean introspections Micronaut already generates, operators whose operand types are known compile to the Java
operators, and a lambda expression passed to a method becomes a Java lambda implementing the parameter’s
functional interface — no LambdaExpression, no argument maps, no java.lang.reflect.Proxy. The paths a
typical expression takes use no reflection at all, which also means nothing to configure for a GraalVM native
image; the few reflective paths that remain are the ones the specification defines in reflective terms, listed
in When Reflection Is Used. This is verified continuously: the Java test suite and the complete
TCK also run compiled into a native image, with ./gradlew nativeTest.
Type Safety
The expressions are checked when the class compiles, not when a request arrives:
a property or method a declared type does not have is a compilation error, and a member served by a custom
resolver at runtime is a warning;
an argument whose static type cannot be coerced to a function’s parameter is a compilation error;
an omitted expectedType is inferred from the static type of the expression, and an expression whose type
cannot be determined because an identifier is undeclared is a compilation error telling you what to declare;
a syntax error reports the expression and the position, on the class that declares it.
Smaller Runtime
An application that declares its expressions in source ships no parser: the parser is a build-time module the
runtime never sees. An expression is a generated class, and looking one up by its string through the standard
jakarta.el.ExpressionFactory is a switch, not a parse.
How It Works
For the expression of the Quick Start the annotation processor generates:
The string was parsed once, by the processor. book is resolved once and its getters are called directly; the
concatenation is Java’s own; the result is returned without a coercion because its type is already the expected
one. What remains at runtime is one shared module — the coercions, the operators on values typed only at
evaluation time, the resolution for what stays dynamic and the typed stream API — and the interpreter walks its
trees against that same runtime, which is why it too stays well ahead of the other implementations.
3 Release History
You can find a list of releases (with release notes) here:
and a misspelt property is reported at compilation time.
<2> A value expression, the type its result is coerced to and the name of the constant holding it in the generated
registry of the class.
<3> A composite expression: the literal text and the eval-expressions are concatenated, then coerced to the
expected type.
<4> A method expression, which invokes the method rather than reading a value.
Evaluate them against any jakarta.el.ELContext:
BookExpressionsTest
front, and holds the beans the expressions refer to by name.
<2> The expressions are returned by the jakarta.el.ExpressionFactory of the module, registered as a service, so
code written against the standard API keeps working: createValueExpression is a lookup of the expression string
in the registries, not a parse.
<3> What the factory returns is the class generated at compilation time.
<4> A method expression is invoked the same way; the parameters are the ones written in the expression.
<5> In Java and Kotlin the generated registry, BookExpressions$ELExpressions, also exposes each expression
under the name it was declared with. The Groovy compiler resolves the names of a class before the registry is
generated, so Groovy code reaches the expressions through the factory.
5 Declaring Expressions
The module ships one small set of annotations, all in io.micronaut.el.annotation:
Declare a method expression — one that names a method to invoke, such as ${book.discounted(10)} — when the
caller needs a jakarta.el.MethodExpression rather than a value.
Describe the world the expressions of a class or member live in: the typed variables, the imported classes and
packages, the static imports and the function libraries.
Give one variable a name and a static type inside an @ELEnvironment, so its properties and methods compile to
direct calls and are checked at compilation time.
Mark a single method as a function, with its own name and prefix, when the class should not export every
static method — also works on instance methods of beans.
An expression is declared with @ELExpression, which is repeatable, on a type, a field, a method or a parameter.
The element is only a holder: the processor generates one class per expression, plus a registry per declaring
class that maps each expression string to its implementation.
CatalogExpressions
this way is still looked up in the ELContext at evaluation time, but every property access, method invocation
and coercion applied to it is resolved statically. A member the type does not declare is reported at compilation
time.
<2> imports, importPackages and staticImports make classes, packages and statically imported fields
available to the expressions, the way the language defines static references. Among several overloads,
Math.max here, the one whose parameters fit the static types of the arguments best is selected at compilation
time.
<3> functions lists a class whose public static methods are functions without any annotation, optionally under
a namespace prefix; type is an alias of value. A class declaring its functions with @ELFunction needs no
listing in its own module, and keeps the names and prefixes it declares when listed from another.
<4> A function is called with its prefix.
<5> A static method of an imported class is called through the class name.
<6> The collection operations of the chapter 2 of the specification, lambdas included, compile like the rest.
<7> A lambda expression invoked immediately, and expression as an alias of value.
<8> A lambda expression assigned to a variable and invoked by name; the semicolon operator evaluates the
assignment, then the call.
<9> A lambda expression with two parameters, passed to the sorted operation as its comparator.
The functions are plain static methods:
TextFunctions
annotated, only the annotated methods are functions; a class with no annotated method exposes all its public
static methods, when listed with @ELFunctions.
<2> @ELFunction can give a function another name, with name, an alias of value, and a namespace prefix of
its own.
Functions on beans
A function does not have to be static. The public instance methods a class declares are functions too, invoked
on the instance the ELContext provides at evaluation time, which is how a Micronaut bean offers functions:
PricingService
the module declaring it, whatever the order of its classes; a function of another module is listed with
@ELFunctions(type = …), and keeps the name and the prefix it declares.
<2> @ELFunction declares the method as a function, here with its namespace prefix; an instance method is invoked
on the instance. Once a method of the class is annotated, only the annotated methods are functions: a bean does
not expose every public method it has.
<3> A static method is a function as well, invoked directly, here under another name.
PricingExpressions
match the function, in name or in number of arguments, fails the compilation.
<3> Both kinds mix in one expression.
PricingExpressionsTest
and every bean can declare functions.
<2> The function is invoked on the bean.
<3> Without a container, the instance is registered under its type with the standard putContext.
<4> With neither, the evaluation fails with an ELException saying so.
Note
A function on a bean is a compiled construct: the interpreter resolves functions through the
jakarta.el.FunctionMapper of the context, which the specification defines over static methods only.
@ELEnvironment is the compilation time counterpart of the jakarta.el.ELContext. It is declared on the class,
or on the member holding the expressions, in which case it applies to those on top of the environment of the
class. The expressions declared on a method, or on one of its parameters, additionally see the parameters of the
method as variables, under their names and with their declared types.
Note
The generated registry matches the expected type of a request: an expression declared with
expectedType = String.class is only returned for a request with String.class. A primitive and its wrapper
are the same expectation, so double.class and Double.class match each other. A request for the same string
with another type, Object.class included, is treated as a different expression, and is rejected as not compiled
unless the interpreter module is present.
Warning
Any annotation string containing #{...} is treated by Micronaut as one of its own evaluated
expressions. The processor reads the original text back out, so #{...} works in a plain holder class, but on a
Micronaut bean Micronaut will also try to compile it with its own expression language. Prefer ${...} for
@ELExpression: the specification parses the two identically. An annotation of your own can use #{...} with
the processor described in Examples and Use Cases.
What the processor generates
For ${book.title} with book declared as a Book, the generated implementation contains the invocation
itself:
Errors at compilation time
An expression that cannot be compiled fails the compilation of the class declaring it. The message carries the
expression, the position of the error in it, and the class, so that the mistake never reaches a runtime:
The same holds for a composite method expression, a function that is not declared, a static field the imported
class does not have, an assignment to something that is not an lvalue, or a construction mixing set elements and
map entries.
A member that the static type of a variable does not declare is a warning rather than an error, because a custom
jakarta.el.ELResolver may serve it at runtime, which is where the access is then left:
warning: The type example.Book does not declare the property 'titel'; the access is left to the resolvers at runtime
The types the standard resolvers read by key, index or name, maps, collections, arrays, resource bundles and
optionals, are not reported.
The expected type
expectedType names the type the result of every evaluation is coerced to, with the standard coercion rules of
the language. It can be omitted: the compiler then infers it from the static type of the expression —
${book.discounted(10)} is a Double because discounted returns one, a comparison is a Boolean, a
composite expression is a String, and an expression whose type the compiler does not know is an Object. The
same applies to expectedReturnType of an @ELMethodExpression, inferred from the invocation. The generated
ExpressionFactory serves an expression with an inferred type for that type, its primitive counterpart and
Object.class.
6 Declaring Beans
Beans are declared with Micronaut’s own Introspected, not with an annotation of this module:
Book
reads and writes the properties through it. The introspection dispatches to a direct invocation, so no reflection
is involved.
<2> A property is whatever the introspection exposes: a getter, a record component, a Kotlin property.
<3> A method reaches the same path once it is annotated with Executable, which is what
puts it into the introspection. A method that is not executable is resolved reflectively, as described in
When Reflection Is Used.
IntrospectionELResolver is the first resolver of the chain built by ELResolvers.standard(). A type with no
introspection is left unresolved, so the standard resolvers of the specification pick it up and a model that
mixes introspected and plain types still resolves.
Tip
Any type that is already introspected for another reason is resolvable by expressions with no further
annotation, including third party types brought in with @Introspected(classes = …).
7 Examples and Use Cases
Jakarta Expression Language earns its place wherever your application wants a little logic as data: readable at
the declaration, changeable without touching the surrounding Java, and — with this module — compiled and
type-checked at build time, so a typo fails the build instead of a request, and evaluated in nanoseconds without
parsing or reflection. Some places it fits:
Business rules on methods
Guard an operation with the rule written right where the operation is declared: a customer must be an adult and
live in Europe before registering, an order must stay under a credit limit, a discount only applies on weekdays.
@Eligible(value = "#{ fn:adult(customer.age) && fn:inEurope(customer.country) }", otherwise = "#{ customer.name += ' must be an adult in Europe' }")public String register(Customer customer) { ... }
The parameters of the method are the variables of the expressions; the functions under fn: come from a shared
library of the rules of the domain.
Validation messages
A constraint in the style of Jakarta Validation, whose message is a template over the attributes of the
constraint and the validated value:
@MinAmount(value = 100)long amount// its default message: "Must be greater than ${inclusive == true ? 'or equal to ' : ''}{value}"
The {value} attribute interpolates first, the expression sees inclusive and validatedValue as typed
variables, and each segment of the template is compiled at build time.
Feature flags and routing conditions
Any annotation that decides something can carry the decision as an expression — which customers see a feature,
which handler takes a message, what gets audited:
When the expression itself is data — a discount rule from a database, a condition an operator edits — add the
interpreter module and create the expression from its string with the standard
jakarta.el.ExpressionFactory; everything an application declares in source stays compiled.
Building your own annotation
@Eligible and @MinAmount above are not part of this module: they are ordinary user annotations, and the rest
of this chapter walks through building them. Everything shown exists as working code — the
test-suite-custom-annotation module of the repository is the library declaring them, and the doc-examples
modules use it.
The annotation
Declare the annotation as you would any other — here it also binds an AOP interceptor, so that the condition
guards the method call. Its members are plain strings holding the expressions:
Using it looks like this. The parameters of the method are typed variables inside the expressions — customer
is the Customer parameter, so customer.age compiles to a direct call of getAge() and a misspelt property
is a compilation error. The functions of the library are available under their prefix, and imported classes by
their simple name:
RegistrationService
Micronaut resolves as a property placeholder.
<5> A constraint in the style of Jakarta Validation on a parameter: its message is a template over the
attributes of the constraint, {value}, and the expressions of the specification, which see those attributes
and the validated value as typed variables.
The constraint declares its message with a default in the style of the constraints of Jakarta Validation:
The processor
Micronaut treats any annotation string containing #{...} as one of its own evaluated expressions, and would
fail to compile an expression of the Jakarta language. An io.micronaut.inject.annotation.AnnotationRemapper,
which runs inside the annotation metadata builder before anything else sees the annotation, takes the text back
and declares it:
The processor of the constraint declares every ${...} segment of the message with the attributes of the
constraint and the validated value as typed variables:
The runtime
The text is read back from the annotation metadata and handed to jakarta.el.ExpressionFactory, which returns
the compiled expression. The parameters of the invocation are bound by name:
EligibleInterceptor
The message is interpolated as the Bean Validation specification orders it:
EligibleTest
the class also exposes them under the names the annotation gave them.
8 Parsing at Runtime
Compiling every expression is only possible when every expression is known at compilation time. When an
expression string is built at runtime, add the interpreter module:
It registers an ELExpressionParser service, which CompiledExpressionFactory consults for the expressions that
no generated source provides. Such an expression is parsed once, when it is created, and its tree is then
evaluated by the interpreter.
RuntimeExpressionTest
Without the module, an expression that was neither compiled nor a literal-expression is rejected:
jakarta.el.ELException: The expression '${book.title}' was not compiled. Declare it with @ELExpression so that itis compiled at compilation time, or add the micronaut-jakarta-el-interpreter module to parse it at runtime.
The interpreter is not a second implementation of the language. It walks the same abstract syntax tree the
compiler consumes, produced by the same micronaut-jakarta-el-parser module, and calls the same runtime
as the generated code, so both share one definition of the semantics of the specification. The compiled path
remains the fast one and the interpreted path is the fallback.
The parser is a module of its own for the same reason: the compiler is not its only consumer, and any code that
needs to inspect an expression without generating one can depend on it alone.
9 When Reflection Is Used
The paths a typical expression takes — declared variables, introspected beans, operators, lambda expressions,
streams, the expression lookup itself — use no reflection at all, which is what the benchmarks
measure and what makes the module work in a GraalVM native image without configuration.
Reflection remains only where the specification itself defines a behaviour in reflective terms:
invoking a method of a type that has no bean introspection, or reading its properties, through the standard
resolvers — annotate the type with @Introspected (and methods with @Executable) to move it to the generated
dispatch instead;
a jakarta.el.FunctionMapper, MethodExpression.getMethodInfo and getMethodReference, whose contracts are
java.lang.reflect types;
coercing a LambdaExpression held in a variable to a functional interface (a lambda written in place is
compiled to the interface directly), and coercing a string through a PropertyEditor;
array access, which the JDK only exposes reflectively.
Even these are served through a per-class method cache, so they stay fast — the staticMethod and
stringMethods benchmarks run them — but a native image needs the involved types registered for reflection.
Tip
To keep a method invocation off the reflective path, annotate the method with @Executable so that it
enters the bean introspection of its type.
10 Repository
You can find the source code of this project in this repository: