scalaCompilerPlugins("io.micronaut.scala:micronaut-inject-scala_3.9.0:1.0.0-SNAPSHOT")
runtimeOnly("io.micronaut.scala:micronaut-runtime-scala:1.0.0-SNAPSHOT")
Table of Contents
Micronaut Scala
Micronaut Scala compiler plugin and integration tests
Version: 1.0.0-SNAPSHOT
1 Introduction
Micronaut Scala provides the Scala 3 compiler plugin and the Scala-specific processing model that integrates Scala sources with Micronaut Framework.
The implementation is maintained separately from Micronaut Core. Core provides the language-neutral processing and runtime extension points, while this project owns the Scala compiler integration, Scala element model, and Scala-specific type handling.
Supported versions
Two Scala 3 LTS lines are supported, built from the same sources and published as separate artifacts. The compiler plugin API is not binary compatible across compiler releases, so the compiler version is part of the artifact name:
-
io.micronaut.scala:micronaut-inject-scala_3.9.0— current LTS -
io.micronaut.scala:micronaut-inject-scala_3.3.8— previous LTS -
io.micronaut.scala:micronaut-inject-scala-test_3.9.0 -
io.micronaut.scala:micronaut-runtime-scala— application-side support, not crossed with the compiler version
Selecting the wrong one does not fail gracefully: the 3.9.0 plugin loaded into the 3.3.8 compiler throws from inside the plugin phase and generates nothing. Match the artifact to the compiler exactly.
The repository version is independent of the Micronaut Core version. The
initial development version is 1.0.0-SNAPSHOT.
What is included
The compiler plugin connects Scala compiler phases to Micronaut compile-time
visitors. The Scala processing model supports bean definitions, injection,
introspection, AOP, configuration properties, annotation metadata, Scala
collections, Option, BeanRegistration, and Scala 3 enum lookup.
Scala 2 is not currently supported.
If you are new to Micronaut, Micronaut for Scala Developers explains dependency injection, configuration, introspections, serialization and AOP from the beginning, in Scala. For the wider framework documentation, see the Micronaut Framework user guide.
2 Quick Start
This project publishes a Scala compiler plugin for use by Scala 3 builds. The plugin must be selected for the exact Scala compiler version used by the application.
There are two artifacts, and they are not interchangeable:
micronaut-inject-scala_<scala version>-
The compiler plugin. It runs inside the Scala compiler and generates bean definitions. It is crossed with the full compiler version, and it bundles the Micronaut classes it compiles against, so it belongs on the compiler plugin configuration and nowhere else.
micronaut-runtime-scala-
The application-side support: the converters that let Micronaut bind Scala collections and
Option. It is a small ordinary library, is not crossed with the compiler version, and belongs on the runtime classpath.
Gradle
For Scala 3.9.0:
| Do not put the compiler plugin on the application runtime classpath. It bundles Micronaut and ASM at their original package paths, so it would place a second copy of those classes in the running application. |
Use a released Micronaut Scala version in published applications. During development, the repository can be built against the Core SPI branch with IncludeGit or a local Core checkout:
./gradlew check -Plocal.git.micronaut-core=/path/to/micronaut-core
Maven
Use the full compiler-version artifact ID for the compiler plugin, and the uncrossed artifact for the runtime support:
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-inject-scala_3.9.0</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-runtime-scala</artifactId>
<version>1.0.0-SNAPSHOT</version>
<scope>runtime</scope>
</dependency>
sbt and Mill
sbt builds should use CrossVersion.full so the complete Scala compiler
version participates in the artifact coordinate. Mill builds should use the
full-cross ::: dependency syntax. In both tools, select the integration
version independently from the Scala compiler version.
Compiler compatibility
Do not replace _3.9.0 with a binary Scala line suffix. Compiler plugin APIs
can change between compiler releases, so a plugin built for one full compiler
version must not be assumed to work with another. See the
Scala compiler-plugin binary-compatibility guidance.
3 Micronaut for Scala Developers
This chapter is for Scala developers meeting Micronaut for the first time. It explains what the framework does, in the order the pieces build on each other, and shows each in Scala. No prior Micronaut knowledge is assumed.
Everything here is ordinary Micronaut. What the Scala compiler plugin adds is
that a Scala source file is a first-class input to it: the plugin runs inside
scalac and produces the same generated classes the Java annotation processor
does, from Scala declarations.
What Micronaut does, and when
Most dependency injection frameworks decide at startup what to inject, by
loading classes and reading their annotations with reflection. Micronaut decides
at compile time. While your code is being compiled, the plugin reads your
declarations and writes a BeanDefinition class beside each bean — a plain
class holding the answers: what this bean’s dependencies are, how to construct
it, what qualifies it.
Three things follow from that, and they are the reason the rest of this chapter looks the way it does:
-
Startup does no classpath scanning and no reflection, so it is fast and its cost does not grow with the size of the application.
-
Anything the framework needs to know must be visible in the source. A type it cannot see at compile time is a type it cannot inject.
-
Mistakes are compile errors. A missing dependency or an unresolvable qualifier fails the build rather than the first request.
Micronaut’s annotations are Java annotations, and Scala applies a Java
annotation by rules of its own — a single value is not an array, and a
positional argument does not go to value. Each difference is a compile error
rather than a silent change in meaning, but the message names a type mismatch
rather than the rule. Annotations lists them; it is worth
reading before transcribing an example from Micronaut’s Java documentation.
Dependency injection
A bean is an object Micronaut creates and supplies to whatever needs it. You declare one by annotating a class; you never write code that constructs it.
Start with something to inject. A trait describes what is needed, without saying which implementation provides it:
trait Engine:
def start(): String
@Singleton on a class makes it a bean. Here two classes implement the same
trait, so each is labelled with @Named to tell them apart:
@Singleton
@Named("v6")
class V6Engine extends Engine:
def start(): String = "V6 starting"
@Singleton
@Named("v8")
class V8Engine extends Engine:
def start(): String = "V8 starting"
A bean’s constructor parameters are its dependencies. Nothing marks them as
injected — being a bean’s constructor is enough. Where more than one bean could
satisfy a parameter, a qualifier says which, and @Named is the simplest one:
@Singleton
class Vehicle(@Named("v8") engine: Engine):
def start(): String = engine.start()
ApplicationContext.run().getBean(classOf[Vehicle]) returns a Vehicle with
V8Engine supplied. Asking for a Vehicle without having declared V8Engine a
bean is a compile error, not a startup failure.
Constructor injection is the form to prefer, and it is the one that fits
Scala best: the dependency arrives as a constructor parameter, so it can be a
val and the class has no uninitialised state. Micronaut also supports
injecting a field or a setter with @Inject, which is worth knowing when
reading Java examples.
|
Scopes
A scope decides how long a bean lives and how many exist. @Singleton means
one instance for the whole application. @Prototype means a new instance for
every injection point that asks for one:
@Prototype
class Journey:
val id: String = java.util.UUID.randomUUID().toString
@Singleton is the common case. The others — @Prototype, @Context,
@Refreshable, request scope in a server application — are all applied the same
way.
Configuration properties
Applications are configured from outside: a YAML file, environment variables,
system properties. @ConfigurationProperties binds a class to a section of that
configuration, and the bound object is itself a bean, so it can be injected
anywhere:
@ConfigurationProperties("engine")
class EngineConfiguration(
val manufacturer: String,
// An absent property binds to `None` rather than failing the bean.
val nickname: Option[String],
val cylinders: List[Int]
)
With engine.manufacturer set to Ford and engine.cylinders to a list, this
bean is created with those values. The Scala types are the point of the example:
Option[String]-
An absent property binds to
None. Without theOptionan absent property is a missing value and the bean fails to be created, which is the right answer for something genuinely required and the wrong one for something optional. List[Int]-
A Scala collection binds from a list-valued property, converted element by element.
Seq,SetandMapwork the same way.
Both come from micronaut-runtime-scala, which must be on the runtime
classpath — see Quick Start. Remember that List and Map in
Scala are not java.util.List and java.util.Map; both are supported, but a
declaration means whichever one it names.
Bean introspection
An introspection is compile-time reflection: for a type annotated
@Introspected, the plugin generates a class that can read and write that
type’s properties and call its constructor, without java.lang.reflect. It is
what validation, data binding and serialization read.
@Introspected
case class Book(title: String, pages: Int)
BeanIntrospection.getIntrospection(classOf[Book]) then reports title and
pages as properties and can instantiate the type from values. A case class
needs nothing else: its constructor parameters are its properties, and the
compiler-generated accessors are what the introspection reads.
An ordinary class works too. What becomes a property is a val or var, or a
getter and setter pair — the same rules as anywhere else in Scala, since a val
is a private field with a generated accessor.
| An introspection is generated only where you ask for one. This is deliberate: it is the difference between a framework that can see every class on the classpath and one that can see the classes you named. |
Serialization
Serialization is not part of the compiler plugin. It is
Micronaut
Serialization reading the introspection described above — which is why it works
for Scala without anything Scala-specific. @Serdeable is @Introspected plus
the instruction that the type may cross a serialization boundary:
@Serdeable
case class Order(id: String, quantity: Int)
An injected ObjectMapper then writes that as {"id":"A-1","quantity":3} and
reads it back through the constructor the introspection describes. Because the
introspection is generated at compile time, no reflective access to the type is
needed at runtime, and the type works under GraalVM native image without
configuration.
The dependency is io.micronaut.serde:micronaut-serde-jackson, added the same
way as in any other language.
Scala’s own types need one thing more. Option, the Scala collections and a
Scala 3 enum are none of them types Micronaut Serialization ships a serializer
for — a Scala List is not a java.util.List, and a Scala 3 enum is not a
java.lang.Enum — so micronaut-runtime-scala supplies those, and must be on
the runtime classpath. Serialization is the chapter on what
each of them becomes in a document.
Around advice
Aspect-oriented programming (AOP) is how behaviour that is not specific to one method — timing, retry, caching, transactions, logging — is applied without writing it into every method. In Micronaut it is also compile-time: rather than generating a proxy at runtime, the plugin generates a subclass of your bean that overrides the advised methods, and the framework injects that subclass wherever the bean is asked for.
Around advice wraps a method call. It takes two declarations. First an
annotation, marked @Around, that says "methods marked with this are advised":
@Around
@Retention(RetentionPolicy.RUNTIME)
@Target(Array(ElementType.TYPE, ElementType.METHOD))
class Audited extends StaticAnnotation, java.lang.annotation.Annotation:
override def annotationType(): Class[? <: java.lang.annotation.Annotation] =
classOf[Audited]
Then an interceptor — itself a bean — bound to that annotation. It receives the
call, does whatever it does, and calls proceed() to run the original method:
@Singleton
@InterceptorBean(Array(classOf[Audited]))
class AuditInterceptor extends MethodInterceptor[Object, Object]:
private val calls = ListBuffer.empty[String]
def audited: Seq[String] = calls.toSeq
override def intercept(context: MethodInvocationContext[Object, Object]): Object =
calls += context.getMethodName
context.proceed()
Applying it is just the annotation. It can go on a single method or on the class, in which case every method is advised:
@Singleton
class OrderService:
@Audited
def place(item: String): String = s"ordered $item"
def quote(item: String): String = s"quote for $item"
Calling place runs the interceptor and returns ordered a book; calling
quote does not run the interceptor at all.
Two Scala notes. The annotation extends java.lang.annotation.Annotation and
overrides annotationType() because @InterceptorBean takes it as a
classOf[…] argument — see Annotations. And the advised class
must be extensible, because the generated subclass extends it: a final class,
or a final def on an advised method, is a compile error rather than a bean
that quietly loses its advice.
Introduction advice
Introduction advice is the other half of AOP, and the more surprising one. An around interceptor wraps a method that exists; an introduction interceptor provides methods that do not. You declare an abstract type with no implementation anywhere, and the interceptor supplies the behaviour — which is how Micronaut’s declarative HTTP clients and repository interfaces work.
The interceptor answers calls for the whole type, using the invocation context to decide what a given method should do:
@Singleton
class GreeterIntroduction extends MethodInterceptor[AnyRef, Object]:
override def intercept(context: MethodInvocationContext[AnyRef, Object]): Object =
s"${context.getMethodName}(${context.getParameterValues.mkString(", ")})"
The annotation is marked @Introduction and names the interceptor with @Type:
@Introduction
@Type(Array(classOf[GreeterIntroduction]))
@Retention(RetentionPolicy.RUNTIME)
@Target(Array(ElementType.TYPE))
class Stub extends StaticAnnotation
Then an abstract type carrying that annotation becomes a bean, despite having no implementation:
@Stub
trait Greeter:
def greet(name: String): String
getBean(classOf[Greeter]).greet("Scala") returns greet(Scala) — the
interceptor’s answer. A trait or an abstract class both work; an abstract class
may also have concrete methods, which run normally, and dependencies of its own,
which are injected.
Data access, which is all of the above at once
Micronaut Data is worth meeting last, because a repository is introduction advice — the abstract type whose implementation you never write — applied to something you would otherwise write by hand.
An entity is a case class. Its parameters are its columns, and @Id marks the
one the database generates:
/** A book on the shelf.
*
* @param id the generated primary key, zero until the database assigns one
* @param title the book's title
* @param pages how many pages it has
*/
@MappedEntity
case class Book(
@Id @GeneratedValue id: Long,
title: String,
pages: Int
)
A repository is a trait. Extending CrudRepository supplies the usual
operations, and any further method is a query derived from its own name — read
findByTitle as where title = ?:
/** Books, stored in the default data source. */
@JdbcRepository(dialect = Dialect.H2)
trait BookRepository extends CrudRepository[Book, Long]:
/** Finds every book with the given title. */
def findByTitle(title: String): List[Book]
/** Counts the books longer than the given length. */
def countByPagesGreaterThan(pages: Int): Long
/** Finds one book with the given title, if there is one. */
def findFirstByTitle(title: String): java.util.Optional[Book]
There is no implementation of either anywhere, and none to write. At compile
time the plugin generates the entity’s introspection, Data reads it to work out
the table and columns, derives the SQL for every method, and generates the class
that runs it. Injecting BookRepository gives you that class.
Three things from earlier chapters are load-bearing here, which is why this example is worth reading closely:
-
The entity’s annotations are on class parameters. A Scala class parameter is where the property is declared, so that is where
@Idgoes — the same rule as Idiomatic Scala gives for configuration. -
The repository is a trait, so it can be introduced. A class with a body could not be.
-
CrudRepository[Book, Long]is a parameterized type from a library, and what its inherited methods return is what Data generates against.
Both types in the example are Scala’s own — Long for the identifier, scala.List
for the result — and both need a word of explanation, as does the one thing that
has to be wired differently from a Java build. Micronaut Data is the
chapter for that.
These examples live in test-suite-scala and test-suite-scala-data, are
compiled by this repository’s build with the plugin, and are executed by its
tests — the repository above against an in-memory database, saving and querying
real rows. Every behaviour described above is asserted there, so prose that stops
matching the code fails the build.
|
4 Idiomatic Scala
Micronaut’s annotations came from Java, and its documentation is written in
Java, so a Scala developer’s first application tends to read like transcribed
Java: mutable fields, classes where a case class would do, null where there is
an Option.
None of that is necessary. The framework’s preferences and Scala’s happen to agree almost everywhere — Micronaut wants immutable, constructor-shaped beans, which is what Scala wants to write anyway. This chapter is what the Scala form of each idea looks like, and the few places where the two disagree.
Prefer constructor injection
Micronaut can inject a constructor parameter, a field or a setter. Use the constructor.
The reason is stronger in Scala than in Java. A constructor parameter is in
scope for the whole class body, so anything derived from a dependency can be a
val computed once, and the class has no state that is briefly null:
@Singleton
class ReportService(clock: Clock, config: ServerConfig):
// A constructor parameter is in scope for the class body, so state derived
// from a dependency can be a val rather than something set later.
private val label: String = s"${config.host}@${clock.getZone}"
def report(): String = label
Field injection cannot do this. @Inject var clock: Clock = null gives a
mutable field that is null between construction and injection, so any val
derived from it has to become a lazy val or a def, and the type says
Clock while admitting null. There is no upside to recover: constructor
parameters are injected without any annotation at all.
The other reason is testing. A bean whose dependencies are constructor
parameters can be built directly with new in a unit test, with no container
and no reflection.
Case classes for configuration
@ConfigurationProperties binds a class to a section of configuration. Use a
case class:
@ConfigurationProperties("server")
case class ServerConfig(
host: String = "localhost",
port: Int = 8080,
tags: List[String] = Nil,
banner: Option[String] = None
)
Four Scala things are doing work there, and each replaces something that would otherwise be an annotation or a convention:
- Default parameter values
-
A parameter’s default is the configuration default.
hostislocalhostunlessserver.hostsays otherwise. This is Scala’s own mechanism, not a framework one — there is no@Value("${server.host:localhost}")to write and no default duplicated in a string. Option[String]-
An absent property binds to
None. Without it, an absent property means the bean cannot be created — which is the right answer for something genuinely required, and the wrong one for something optional. List[String]-
A Scala collection binds element by element from a list-valued property.
Seq,SetandMapwork the same way. case-
Equality,
copyandtoStringcome for free, which matters most in tests: a configuration object can be compared against an expected value, and printed usefully when it does not match.
One consequence of writing configuration this way is worth knowing: a class
parameter has no field or accessor of its own to document, so its description
goes on the class as a @param tag. That is where the generated configuration
metadata takes it from — see Documentation in Generated
Files.
Option and the Scala collections are bound by micronaut-runtime-scala,
which must be on the runtime classpath. See Quick Start.
|
Repeated configuration
Where the same shape of configuration repeats under different names — data
sources, clients, connection pools — @EachProperty creates one bean per entry,
and @Parameter receives the name it was configured under:
@EachProperty("datasources")
case class DataSourceConfig(@Parameter name: String, url: String)
Configuring datasources.one.url and datasources.two.url produces two
DataSourceConfig beans. Each can be injected by name with @Named, and all of
them together as a collection.
Enums in configuration
This is the one place where the plain Scala form does not work. A value bound
from configuration arrives as a string and has to be converted, and the
conversion to an enum is defined for java.lang.Enum. A Scala 3 enum is not
one unless it says so:
enum Mode extends java.lang.Enum[Mode]:
case Fast, Slow
@ConfigurationProperties("run")
case class RunConfig(mode: Mode = Mode.Slow)
With extends java.lang.Enum[Mode] the binding works and the constructor
default still applies when the property is absent. Without it the value cannot
be converted, and the failure is reported as though the property were missing —
which is a confusing message to debug, so it is worth writing the extends
clause on any enum that a configuration class refers to.
Traits at the boundary between beans
Inject a trait, not a class. The bean asking for a dependency should name what it needs, and the container decides which implementation it gets:
trait Notifier:
def send(message: String): String
@Singleton
class EmailNotifier extends Notifier:
def send(message: String): String = s"email: $message"
@Singleton
class SmsNotifier extends Notifier:
def send(message: String): String = s"sms: $message"
This is ordinary good design, but it also unlocks things in Micronaut that a concrete class cannot do. Where several beans implement the trait, a collection parameter receives all of them — useful for anything plugin-shaped, where the set of implementations is meant to grow:
@Singleton
class Broadcaster(notifiers: List[Notifier]):
def broadcast(message: String): List[String] =
notifiers.map(_.send(message)).sorted
Where the dependency may not be there at all, an Option parameter is None
rather than a failure, so a bean can adapt to an implementation being present
without a null check:
trait Archive:
def store(message: String): Unit
@Singleton
class AuditLog(archive: Option[Archive]):
def describe(): String = archive.fold("not archived")(_ => "archived")
And a trait is what introduction advice needs: an abstract type with no
implementation is the input to @Introduction, which is how declarative clients
work. See Micronaut for Scala Developers.
Where exactly one implementation could serve but you want to choose between
several, qualify with @Named at the injection point. Prefer that over injecting
the concrete class, which removes the choice permanently.
Factories for types you do not own
A bean has to be a class the plugin compiled, which leaves out the JDK, Java
libraries and the Scala standard library. @Factory closes that gap: a method
annotated with a scope produces a bean of whatever it returns.
@Factory
class Clocks:
@Singleton
def systemClock(): Clock = Clock.systemUTC()
java.time.Clock is now injectable anywhere, including into the
ReportService above. This is also the right place for the construction logic
of a type that needs assembling, so the beans that use it are not the ones
building it.
A Scala object is a bean, but it is rarely the right one
@Singleton on an object works:
@Singleton
object HouseStyle:
def prefix: String = "[app]"
@Singleton
class Banner(style: HouseStyle.type):
def render(text: String): String = s"${style.prefix} $text"
The instinct is understandable — Micronaut’s @Singleton and Scala’s object
both mean "one of these" — but they are not the same thing, and the injection
point shows it: the parameter’s type is HouseStyle.type, the singleton type of
that one object, which nothing else can inhabit. There is no substituting a test
double, no alternative implementation, and no qualifier to choose between.
Worse, the object can also be reached by name from anywhere, so the dependency
stops being visible in the constructor.
Prefer a class for anything with behaviour worth replacing, and keep object
for what it is good at: constants and pure functions that nothing would ever
want to inject differently.
Things that are compile errors rather than surprises
Two Scala habits collide with how Micronaut applies AOP, and both are caught by the compiler rather than at runtime:
-
A
final class, or afinal defon an advised method, cannot be advised. The plugin generates a subclass that overrides advised methods, so this fails the build rather than producing a bean that silently loses its advice. -
An annotation is applied by Scala’s rules, not Java’s — a single value is not an array, and a positional argument does not go to
value. See Annotations.
Every example in this chapter is a compiled file in test-suite-scala,
executed by this repository’s tests — including the enum that must extend
java.lang.Enum and the defaults that come from the constructor.
|
5 Build Tools
The compiler plugin is registered with the Scala compiler, not applied as a build plugin. Every build tool has its own way of doing that, and the artifact coordinate always carries the full compiler version.
Gradle
plugins {
scala
}
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
dependencies {
implementation("org.scala-lang:scala3-library_3:3.9.0")
implementation("io.micronaut:micronaut-inject:5.2.0")
scalaCompilerPlugins("io.micronaut.scala:micronaut-inject-scala_3.9.0:1.0.0-SNAPSHOT")
runtimeOnly("io.micronaut.scala:micronaut-runtime-scala:1.0.0-SNAPSHOT")
}
tasks.withType<ScalaCompile>().configureEach {
scalaCompileOptions.additionalParameters.add("-J--add-modules=java.compiler")
}
The -J—add-modules=java.compiler argument is required: the plugin reaches
Micronaut code that references javax.lang.model, and that module is not resolved
by default in a JDK 9+ compiler process.
This is the build the repository’s own functional test generates and runs
(ScalaGradlePluginFunctionalSpec), so it is verified rather than illustrative.
|
Maven
<properties>
<scala.version>3.9.0</scala.version>
<micronaut.scala.version>1.0.0-SNAPSHOT</micronaut.scala.version>
</properties>
<dependencies>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala3-library_3</artifactId>
<version>${scala.version}</version>
</dependency>
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-runtime-scala</artifactId>
<version>${micronaut.scala.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
<configuration>
<args>
<arg>-J--add-modules=java.compiler</arg>
</args>
<compilerPlugins>
<compilerPlugin>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-inject-scala_3.9.0</artifactId>
<version>${micronaut.scala.version}</version>
</compilerPlugin>
</compilerPlugins>
</configuration>
</plugin>
</plugins>
</build>
sbt
CrossVersion.full is required so that the full compiler version participates in
the coordinate; the default binary suffix would resolve _3, which this project
does not publish.
scalaVersion := "3.9.0"
libraryDependencies ++= Seq(
"io.micronaut" % "micronaut-inject" % "5.2.0",
"io.micronaut.scala" % "micronaut-runtime-scala" % "1.0.0-SNAPSHOT" % Runtime
)
addCompilerPlugin(
"io.micronaut.scala" % "micronaut-inject-scala_3.9.0" % "1.0.0-SNAPSHOT"
)
javacOptions ++= Seq("--add-modules", "java.compiler")
Mill
Mill’s full-cross dependency syntax is :::, which plays the same role as sbt’s
CrossVersion.full.
object app extends ScalaModule {
def scalaVersion = "3.9.0"
def ivyDeps = Agg(
ivy"io.micronaut:micronaut-inject:5.2.0",
ivy"io.micronaut.scala:micronaut-runtime-scala:1.0.0-SNAPSHOT"
)
def scalacPluginIvyDeps = Agg(
ivy"io.micronaut.scala:micronaut-inject-scala_3.9.0:1.0.0-SNAPSHOT"
)
def scalacOptions = Seq("-J--add-modules=java.compiler")
}
| Only the Gradle build above is exercised by this repository’s tests. The Maven, sbt and Mill configurations follow each tool’s documented mechanism for registering a compiler plugin, but they are not verified here. See Troubleshooting if a build does not generate bean definitions. |
6 Configuration Options
Options are passed to the plugin with the standard Scala compiler-plugin syntax:
-P:micronaut-scala:<key>=<value>
An option given without = is set to "true".
What the options are
The plugin does not define an option vocabulary of its own. Every option is placed
into VisitorContext.getOptions(), which Micronaut itself and any
TypeElementVisitor on the compilation classpath may read. The set is therefore
open, and an unrecognised key is accepted rather than rejected — the same contract
as javac’s `-A options.
-P:micronaut-scala:help prints this list.
| Key | Meaning |
|---|---|
|
|
|
The Maven group of the module being compiled. |
|
The name of the module being compiled. |
|
The project directory, used to locate generated configuration metadata. |
|
Comma-separated package prefixes to restrict annotation processing to. |
|
|
Setting options
tasks.withType<ScalaCompile>().configureEach {
scalaCompileOptions.additionalParameters.addAll(
listOf(
"-J--add-modules=java.compiler",
"-P:micronaut-scala:micronaut.processing.group=com.example",
"-P:micronaut-scala:micronaut.processing.module=app"
)
)
}
| Because unrecognised keys are accepted silently, a misspelled option is not an error. If an option appears to have no effect, check the spelling against the table above first. |
7 Annotations
Micronaut’s annotations are Java annotations, and Scala applies a Java annotation by different rules to Java’s own. Each difference below is a compile error rather than a silent change in meaning, so nothing gets past the compiler — but the message names a type mismatch, not the rule behind it, which is hard to act on if you are transcribing an example from Micronaut’s Java documentation.
A single value is not an array
Java lets a one-element array member be written without braces. Scala does not:
@Introspected(accessKind = Introspected.AccessKind.METHOD) // does not compile
Found: (io.micronaut.core.annotation.Introspected.AccessKind.METHOD : io.micronaut.core.annotation.Introspected.AccessKind) Required: Array[io.micronaut.core.annotation.Introspected.AccessKind]
Wrap it, whether it holds one element or several:
@Introspected(accessKind = Array(Introspected.AccessKind.METHOD))
class Vehicle(val make: String)
Name the member
In Java, a single unnamed argument goes to the member called value. Scala binds
a positional argument by declaration order instead, which is the order the
members appear in the annotation’s class file and is rarely the one you want. For
jakarta.validation.constraints.Min, whose first declared member is message:
def check(@Min(10) size: Int): Int = size // does not compile
Found: (10 : Int) Required: String
The habit that always works is to name every member, even when there is only one:
@Introspected
class Part:
@Introspected.Property(value = "part_number")
def partNumber: String = "none"
def partNumber_=(value: String): Unit = ()
An annotation whose only member is value accepts a positional argument,
because declaration order and the value convention agree. Relying on that means
the same expression breaks when a member is added ahead of it.
|
Repeatable annotations
Scala has no repeatable annotations. Writing one twice is an error, so the
container Java would generate has to be written out, with new on each entry:
@Retention(RetentionPolicy.RUNTIME)
@Target(Array(ElementType.TYPE, ElementType.METHOD))
@InterceptorBindingDefinitions(Array(
new InterceptorBinding(kind = InterceptorKind.POST_CONSTRUCT),
new InterceptorBinding(kind = InterceptorKind.PRE_DESTROY)
))
class Managed extends StaticAnnotation, java.lang.annotation.Annotation:
override def annotationType(): Class[? <: java.lang.annotation.Annotation] =
classOf[Managed]
Micronaut’s repeatable annotations each name their container in @Repeatable —
@Requires uses @Requirements, @InterceptorBinding uses
@InterceptorBindingDefinitions, @Property uses @PropertySource.
Declaring an annotation
A Scala annotation extends scala.annotation.StaticAnnotation, and its members
are constructor parameters. Give it @Retention(RetentionPolicy.RUNTIME) so
Micronaut can read it, and @Target to say where it may be written:
@Retention(RetentionPolicy.RUNTIME)
@Target(Array(ElementType.TYPE))
class Audited(val category: String = "general") extends StaticAnnotation
@Audited(category = "billing")
class Invoice(val total: Long)
An annotation used as a classOf[…] argument — @InterceptorBean, @Type,
@Introduction(interfaces = …) — must also extend
java.lang.annotation.Annotation and override annotationType(), because those
members are declared as Class[? <: Annotation]. The Managed example above
shows the full form.
Types that are not the Java type of the same name
StringBuilder in Scala is scala.collection.mutable.StringBuilder, a different
type from java.lang.StringBuilder. An injection point or a bean method that
means the Java one has to say so. The same applies to Seq, List and Map,
which are Scala collections rather than java.util ones — both are supported,
and micronaut-runtime-scala binds the Scala forms, but they are not
interchangeable at a type level.
8 Documentation in Generated Files
Several Micronaut features put your own prose into the files they generate. The
descriptions in configuration metadata, the description of a schema in an
OpenAPI definition or a JSON schema, the summary of an operation — none of that
is written in an annotation. It is read from the doc comment above the
declaration.
The compiler plugin reads Scaladoc for this, the way the Java integration reads Javadoc and the Kotlin one reads KDoc.
What you have to configure
Nothing, in the compiler. Doc comments are kept by the Scala compiler without being asked, and the plugin reads them from there, so no flag, plugin option or build change is involved. What matters is how the comment is written.
Use a doc comment, not a line comment. Only /** … */ is a doc comment. A
// comment above a declaration is invisible to the compiler by the time the
plugin runs, however much it reads like documentation.
Document a property where its author can write it. This is the one rule that
is specific to Scala, and it follows from how Scala declares properties. A class
parameter — the usual way to write a @ConfigurationProperties class — has no
field and no accessor of its own to attach a comment to, so its description goes
on the class as a @param tag:
/** Configures the outbound mailer.
*
* @param host the SMTP host to connect to
* @param port the port the SMTP host listens on
*/
@ConfigurationProperties("mailer")
case class MailerConfig(host: String = "localhost", port: Int = 25)
That is the same rule Java applies to a record, and it produces:
{
"groups": [
{ "name": "mailer",
"type": "io.micronaut.docs.documentation.MailerConfig",
"description": "Configures the outbound mailer." }
],
"properties": [
{ "name": "mailer.host", "type": "java.lang.String",
"description": "the SMTP host to connect to" },
{ "name": "mailer.port", "type": "int",
"description": "the port the SMTP host listens on" }
]
}
A member declared in the class body is documented where it is declared, as you would expect:
@ConfigurationProperties("mailer")
class MailerConfig:
/** How many messages to send at once. */
var batchSize: Int = 10
The first paragraphs are the description. Everything before the first block
tag is used; from the first @param, @return, @throws or any other tag
onward is not. So the sentence that should appear in generated documentation
belongs at the top of the comment, not after a tag.
What each feature needs
The plugin’s part is the same for all of them: it makes the documentation available to compile-time processing. What consumes it differs, and each brings its own dependency.
- Configuration metadata
-
Built in. Compiling a
@ConfigurationPropertiesclass writesMETA-INF/spring-configuration-metadata.json, and everydescriptionin it comes from the doc comments. This is what an IDE shows when completing a configuration key, and what the configuration reference in generated documentation is built from. - OpenAPI
-
Add
micronaut-openapi. Schema and property descriptions are taken from the doc comments of the types a controller exposes, so a documented@Serdeabletype produces a documented schema without a single@Schema(description = …). - JSON schema
-
Add the JSON schema generator. It reads the same descriptions through the same mechanism.
In each case the annotation still wins where you write one: an explicit
@Schema(description = …) overrides what the comment says. Documentation is the
default, not an override.
What is not covered
Only what the compiler is compiling. A doc comment is in a source file, and a dependency arrives as a class file with its comments long gone — so a type from another module or a library contributes no descriptions, in Scala exactly as in Java and Kotlin. Where that type’s description matters, write it on the member that exposes it, in the module being compiled.
The MailerConfig example above is compiled by this repository’s build,
and its generated metadata file is asserted by the tests, so the JSON shown here
is what the plugin actually writes.
|
9 Serialization
Serialization is Micronaut
Serialization reading the introspection the compiler plugin generates. A case class needs
nothing Scala-specific for that: its parameters are its properties, its constructor is what the
introspection instantiates, and @Serdeable is @Introspected plus the instruction that the
type may cross a serialization boundary.
/** One line of an order. */
@Serdeable
case class Line(sku: String, quantity: Int)
That writes as {"sku":"sku-1","quantity":2} and reads back through the same constructor. No
reflective access to the type is needed at runtime, so it works under GraalVM native image
without configuration.
Where Scala needs help is its own vocabulary. Option is a presence JSON has no word for, the
Scala collections are not java.util collections, and a Scala 3 enum is not a
java.lang.Enum — so none of the serializers Micronaut Serialization ships apply to any of
them. micronaut-runtime-scala supplies those, so it must be on the runtime classpath for any of
this chapter to hold:
runtimeOnly("io.micronaut.scala:micronaut-runtime-scala")
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-runtime-scala</artifactId>
<scope>runtime</scope>
</dependency>
alongside the serialization implementation itself:
implementation("io.micronaut.serde:micronaut-serde-jackson")
<dependency>
<groupId>io.micronaut.serde</groupId>
<artifactId>micronaut-serde-jackson</artifactId>
</dependency>
A worked example
/** A customer order.
*
* @param reference the order reference
* @param status how far along it is
* @param lines what was ordered
* @param notes free text the customer may not have supplied
* @param labels arbitrary tags, by name
*/
@Serdeable
case class Order(
reference: String,
status: Status,
lines: List[Line],
notes: Option[String] = None,
labels: Map[String, String] = Map.empty
)
With every member supplied, that writes as:
{
"reference": "A-1",
"status": "Placed",
"lines": [{"sku": "sku-1", "quantity": 2}],
"notes": "leave at the door",
"labels": {"priority": "high"}
}
The rest of this chapter is what each of those members is doing.
Option
A Some is written as the value itself, with no trace of the wrapper. A None is absence:
the field is left out of the document rather than written as null, because that is what the
type says.
Reading is where a Scala author has to make one decision. Give the member a default:
notes: Option[String] = None
The default is what a document that omits the field falls back to. Without it the member is
null — not None — which is a value the Scala type says cannot exist.
That is not a limitation of the plugin. Micronaut Serialization decides whether to ask a type
for a default from a fixed list, and the list is java.util.Optional and its primitive
variants; a Deserializer cannot add itself to it. Tracked upstream as
micronaut-serialization#1416.
Write = None regardless of serialization. It is the idiomatic declaration anyway — it
makes the member optional at the call site as well as in the document, so Order("A-2",
Status.Shipped, lines) compiles and the two agree about what "optional" means. The same applies
to a defaulted collection: labels: Map[String, String] = Map.empty.
|
An explicit null in the document reads as None, with no default required. Only an absent
field needs one.
Collections
List, Seq, Set, Vector, IndexedSeq and their mutable counterparts are all written as
JSON arrays, and each is read back as the type the member declares rather than one shape for
all of them. Map is written as a JSON object, keyed by the map’s own keys, the same way
java.util.Map is.
Nothing has to name a concrete implementation. Write Seq[String] and a Seq comes back; write
Vector[Int] and a Vector does.
The element type is read from the declaration, so a collection of an introspected type
round-trips through that type’s own serializer — List[Line] and Map[String, Line] both work,
and so does Option[Line].
|
Reading relies on the collection converters micronaut-runtime-scala registers, which are the
same ones that bind configuration properties and injection points. A collection outside that set
has to be reached through one that is in it.
Scala 3 enums
An enum whose cases take no parameters is written as its case name and read back through the
valueOf the compiler generates on it:
/** How far along an order is. */
enum Status:
case Placed, Shipped, Delivered
A name the enum does not have is rejected where it is read, rather than becoming a null the
caller meets somewhere else.
The enum does not need @Serdeable of its own. An enum whose cases take parameters is a sum
type rather than a set of names, has no valueOf, and is not covered — model it as a sealed
hierarchy of @Serdeable case classes instead.
What is not Scala-specific
Everything else is ordinary Micronaut Serialization, and its own documentation applies
unchanged: @JsonProperty and the other Jackson annotations it understands, naming strategies,
views, @Serdeable.Serializable and @Serdeable.Deserializable for read-only or write-only
types, and custom serializers of your own. Remember only that Scala applies a Java annotation by
rules of its own — see Annotations.
The examples in this chapter live in test-suite-scala-serde, are compiled by this
repository’s build with the plugin, and are round-tripped through a real ObjectMapper by its
tests. Every document shown above is asserted there.
|
10 Micronaut Data
Micronaut Data generates a repository’s implementation at compile time from the shape of its methods, and it reads more of the compile-time model than anything else Micronaut ships to do it. A repository is introduction advice over an abstract type; the queries come from method names and from the resolved return types of a parameterized library supertype; the entity is mapped from a case class whose annotations sit on constructor parameters. All of that works from Scala, and this chapter is what it looks like and what has to be wired for it to happen.
Dependencies
Micronaut Data does its work in a TypeElementVisitor, and the compiler plugin discovers
visitors from the compilation classpath — see Artifacts and Dependencies.
So the processor is a compile-time dependency of the module holding the entities and
repositories:
compileOnly("io.micronaut.data:micronaut-data-processor")
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-processor</artifactId>
<scope>provided</scope>
</dependency>
Not annotationProcessor. That configuration feeds javac, and the Scala compiler
never sees it. The build still succeeds and the repository still injects — it is introduction
advice, and the plugin generates that regardless — but the first call fails with
IllegalStateException: Micronaut Data method is missing compilation time query information.
Ensure that the Micronaut Data annotation processors are declared in your build. That advice
is written for Java; for Scala sources the answer is the compile-time dependency above. A
module holding both Java and Scala entities needs it in both configurations.
|
Then the ordinary runtime pieces — the JDBC implementation, a connection pool and a driver. For the in-memory database this chapter uses:
implementation("io.micronaut.data:micronaut-data-jdbc")
<dependency>
<groupId>io.micronaut.data</groupId>
<artifactId>micronaut-data-jdbc</artifactId>
</dependency>
runtimeOnly("io.micronaut.sql:micronaut-jdbc-hikari")
<dependency>
<groupId>io.micronaut.sql</groupId>
<artifactId>micronaut-jdbc-hikari</artifactId>
<scope>runtime</scope>
</dependency>
runtimeOnly("com.h2database:h2")
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
And micronaut-runtime-scala, which is what lets a repository method return a Scala
collection — more on that below:
runtimeOnly("io.micronaut.scala:micronaut-runtime-scala")
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-runtime-scala</artifactId>
<scope>runtime</scope>
</dependency>
An entity
An entity is a case class. Its parameters are its columns, and @Id marks the one the
database assigns:
/** A book on the shelf.
*
* @param id the generated primary key, zero until the database assigns one
* @param title the book's title
* @param pages how many pages it has
*/
@MappedEntity
case class Book(
@Id @GeneratedValue id: Long,
title: String,
pages: Int
)
The annotations are on the class parameters, because that is where a Scala property is declared. The plugin carries them to the property the introspection exposes, so Micronaut Data sees the same thing it would see on a Java record — the rule Idiomatic Scala gives for configuration applies here unchanged.
The identifier is Scala’s Long, which is the primitive. An entity that has not been saved yet
has id of 0, not null — the same convention a Java entity with a long identifier
follows, and what save returns has the identifier the database assigned.
A repository
A repository is a trait. Extending CrudRepository supplies the usual operations; any further
method is a query derived from its own name — read findByTitle as where title = ? and
countByPagesGreaterThan as select count(*) … where pages > ?:
/** Books, stored in the default data source. */
@JdbcRepository(dialect = Dialect.H2)
trait BookRepository extends CrudRepository[Book, Long]:
/** Finds every book with the given title. */
def findByTitle(title: String): List[Book]
/** Counts the books longer than the given length. */
def countByPagesGreaterThan(pages: Int): Long
/** Finds one book with the given title, if there is one. */
def findFirstByTitle(title: String): java.util.Optional[Book]
There is no implementation of either type anywhere, and none to write. At compile time the
plugin generates the entity’s introspection, Micronaut Data reads it to work out the table and
columns, derives the SQL for every method including the inherited ones, and generates the
class that runs it. Injecting BookRepository gives you that class.
It has to be a trait or an abstract class. A concrete class has a body of its own and cannot be introduced; a trait can have concrete methods too, which run as written.
@JdbcRepository(dialect = Dialect.H2) is written exactly as in Java — a Java annotation with
a named enum argument — and CrudRepository[Book, Long] is the parameterized library
supertype whose inherited methods Micronaut Data resolves against the entity.
Scala types in signatures
findByTitle returns List[Book] — Scala’s List, not java.util.List. Micronaut Data asks
at compile time whether a declared return type stands in for an Iterable so that it knows to
generate a many-row query, and the plugin answers yes for a Scala collection. At runtime the
rows arrive as a Java collection and micronaut-runtime-scala’s converters produce the
declared one, so the caller gets `scala.List back. Write the Scala type and you get the Scala
type.
Long return types are the primitive, as countByPagesGreaterThan shows.
A query that may find nothing is declared as java.util.Optional, as findFirstByTitle
does — not Option. Micronaut Data recognises the single-result containers from a list of its
own (Optional, the reactive types) and rejects anything else at compile time:
Unable to implement Repository method: BookRepository.findFirstByTitle(String title). Query results in a type [Book] whilst method returns an incompatible type: scala.Option
Convert at the call site with .toScala from scala.jdk.OptionConverters if an Option is
wanted.
Configuration and testing
The repository binds to the default data source, so the ordinary Micronaut Data configuration applies. For an in-memory H2 database that creates its own schema:
datasources:
default:
url: jdbc:h2:mem:books;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password: ""
schema-generate: CREATE_DROP
dialect: H2
That is all a test needs: start an ApplicationContext with those properties, inject the
repository, and call it. Nothing about testing is Scala-specific.
The types in this chapter live in test-suite-scala-data, are compiled by this
repository’s build with the plugin, and are executed by its tests against exactly that
configuration — saving rows, deriving queries, counting in SQL. The module also compiles a
Java repository of the same shape with Micronaut Data’s own annotation processor, and what the
two generate is compared; that comparison is how the Scala path was made to match.
|
11 Artifacts and Dependencies
This project publishes three artifacts. They are not interchangeable, and the most common setup problem is using one where another is meant.
| Artifact | Where it goes | Crossed with the compiler version |
|---|---|---|
|
Scala compiler plugin configuration only |
Yes |
|
Application runtime classpath |
No |
|
Test compile classpath of this kind of project only |
Yes |
The compiler plugin
micronaut-inject-scala_3.9.0 runs inside the Scala compiler and generates bean
definitions. It bundles the Micronaut and ASM classes it compiles against, at
their original package paths, because a Scala compiler plugin is loaded in an
isolated classloader that cannot see the compile classpath.
That has two consequences worth knowing:
-
The published POM deliberately declares almost nothing. Its dependencies are inside the jar, and declaring them as well would put every one of those classes on a consumer’s classpath twice.
-
It must not go on an application’s runtime classpath. Doing so places a second copy of Micronaut and ASM into the running application.
The runtime support
micronaut-runtime-scala is an ordinary small library holding the converters that
let Micronaut bind Scala collections and scala.Option. Without it on the runtime
classpath, a bean that injects a List[T], a Map[String, T] or an Option[T]
will fail to bind at runtime even though it compiled.
It is not crossed with the compiler version, because it is not a compiler plugin.
Visitors on the compilation classpath
TypeElementVisitor implementations are discovered with ServiceLoader from the
compilation classpath. A visitor supplied by another Micronaut module — for
example the validation processor — therefore has to be a compile-time dependency
of the module being compiled, not a runtime one.
A visitor and its dependencies are loaded from that classpath, ahead of anything the compiler process itself was started with. The only classes a visitor shares with the plugin are the JDK’s, the compiler’s, and what the plugin jar bundles: Micronaut’s Element API and the annotations it ships. A library that both a visitor and the compiler’s own toolchain depend on — Gradle starts the compiler with scaladoc, and so with scaladoc’s Jackson and snakeyaml — is the version the module declares, never the toolchain’s.
Version relationships
The Scala version in the artifact name is the full compiler version, not the
binary line. _3.9.0 is correct; _3 is not published. See
Supported versions for why.
The project version is independent of the Micronaut Core version.
12 Testing
micronaut-inject-scala-test_<scala version> provides the harness this repository
uses to compile Scala sources in-process and inspect the result: the element model,
the generated bean definitions, or a running ApplicationContext.
It is intended for projects that extend the Scala integration itself. An application does not need it to test its own beans.
Adding it
testImplementation("io.micronaut.scala:micronaut-inject-scala-test_3.9.0")
<dependency>
<groupId>io.micronaut.scala</groupId>
<artifactId>micronaut-inject-scala-test_3.9.0</artifactId>
<scope>test</scope>
</dependency>
The artifact name carries the full Scala compiler version, as every compiler-side artifact here does — see Artifacts and Dependencies.
Required system properties
The harness compiles by invoking the Scala compiler directly, so it has to be told where the plugin and the compilation classpath are. Both are required, and the harness fails immediately if either is missing.
| Property | Value |
|---|---|
|
Absolute path to the compiler plugin jar. |
|
The classpath compiled sources are resolved against, usually the test runtime classpath. |
tasks.withType<Test>().configureEach {
doFirst {
systemProperty("micronaut.scala.plugin.jar", pluginJar.absolutePath)
systemProperty(
"micronaut.scala.test.classpath",
sourceSets.test.get().runtimeClasspath.asPath
)
}
}
The properties are set in doFirst rather than at configuration time so that
resolving the classpath does not happen during configuration.
What the harness offers
AbstractScalaTypeElementSpec exposes the operations the specs in this repository
are written against:
-
buildClassElement(name, source)— compile a source and return itsClassElement -
buildBeanDefinition(name, source)— compile and load the generated definition -
buildBeanIntrospection(name, source)— compile and load the generated introspection -
buildContext(source, properties, includeAllBeans)— compile and start anApplicationContext -
buildClassLoader(name, source)— compile and return a classloader over the output
A visitor can be installed for the duration of one compilation to capture the
VisitorContext, which is how the specs assert against elements the compiler
produced rather than against elements they constructed.
13 Limitations
Known gaps, with the reason for each. Anything not listed here is either supported or has not been established either way.
Incremental compilation
Bean definitions are written into the class output directory by the compiler plugin, not through the compiler’s own output mechanism, so the build tool does not record them as products of the source that produced them.
The visible consequence is that deleting a bean’s source does not remove its bean
definition. The generated $Foo$Definition.class and its
META-INF/micronaut/io.micronaut.inject.BeanDefinitionReference/ marker both
survive the rebuild, so a deleted bean is still a bean at runtime. The same
applies to a definition left behind by a compilation that later failed.
Run a clean build after deleting or renaming a bean.
| A partial recompile does not lose the definitions of sources it did not recompile. Each implementation gets its own empty marker file named for the class, so there is no shared service file for a partial run to truncate. |
Not defects
Listed because they look like gaps and are not:
-
Varargs.
def f(xs: String*)compiles to a single method takingscala.collection.immutable.SeqwithACC_VARARGSunset, so the parameter type andisVarArgs() == falseare both accurate. -
protected. Scalaprotectedandprotected[pkg]compile to public members, and the model reports what the bytecode contains. -
Field visibility. Every Scala
valandvaris a private field behind accessors, so queries that select public fields return nothing. The fields are present; they are not accessible. -
Trait parameters,
inline, and extension methods declared in a class body are all modelled.
14 Troubleshooting
No bean definitions are generated
The compilation succeeds and no $*$Definition classes appear.
The plugin is not being applied. A Scala compiler plugin has to be registered with
the compiler itself, not added as a dependency — check that it is on the compiler
plugin configuration (scalaCompilerPlugins, addCompilerPlugin,
scalacPluginIvyDeps, or <compilerPlugins>) and not on implementation.
Run the compiler with -Xplugin-list to see which plugins actually loaded.
NoClassDefFoundError from inside the compiler
java.lang.NoClassDefFoundError: io/micronaut/inject/processing/ProcessingException
The plugin jar has been stripped of the classes it bundles, or replaced with one that never had them.
A Scala compiler plugin is loaded in a classloader built from its own
-Xplugin argument, and that classloader cannot see the compile classpath. The
published jar therefore carries the Micronaut and ASM classes it needs. Repacking
it, or substituting a jar of only this project’s classes, removes exactly the
classes the plugin loads first.
Declaring the dependencies separately does not help: build tools pass each
resolved file as its own -Xplugin argument, so each gets its own classloader.
Missing plugin.properties warnings
warning: Missing plugin.properties in <some-jar>
Something is passing more jars to -Xplugin than intended, usually transitive
resolution of the compiler plugin configuration. The warnings are harmless — a jar
without a plugin descriptor is skipped — but they indicate the configuration is
resolving more than the one artifact it needs.
Errors mentioning javax.lang.model
The java.compiler module is not resolved in the compiler process. Add:
-J--add-modules=java.compiler
to the Scala compiler arguments. See Build Tools for where it goes in each tool.
Could not find …_3:<version>
The artifact coordinate is using the binary Scala line rather than the full
compiler version. This project publishes micronaut-inject-scala_3.9.0, not
micronaut-inject-scala_3, because compiler plugin APIs can change between
compiler releases.
In sbt use CrossVersion.full; in Mill use the ::: syntax; in Gradle and Maven
write the full version into the artifact ID.
Scala collections or Option fail to bind at runtime
The code compiles, and injecting a List[T], Map[String, T] or Option[T]
fails when the application starts.
micronaut-runtime-scala is missing from the runtime classpath. The converters
live there rather than in the compiler plugin — see Artifacts and Dependencies.
The version of 'scala-library' was changed while using the default Zinc version
Version 3.9.0 is not compatible with org.scala-sbt:zinc_2.13:1.12.0
A dependency-resolution rule is forcing the Scala 3 version onto Gradle’s own Zinc
configuration. Zinc is built for Scala 2.13 and needs the 2.13 scala-library and
scala-reflect; it is Gradle’s toolchain, not the project’s. Exempt the zinc
configuration from any Scala pinning rule.
A deleted bean is still being injected
The source was deleted or renamed, and the application still resolves the old bean.
Bean definitions are not registered with the build tool as products of their source, so it does not clean them when the source goes. Run a clean build. See Limitations.
A -P:micronaut-scala: option appears to do nothing
Options are passed through to VisitorContext.getOptions() and the set is open, so
an unrecognised key is accepted rather than rejected. Check the spelling against
Configuration Options.
15 Repository
The Micronaut Scala source code, issue tracker, and pull requests are hosted in the micronaut-projects/micronaut-scala repository.
The repository is organized into shared implementation/test sources and compiler-versioned projects:
-
inject-scalacontains shared Scala processing implementation sources. -
inject-scala-testcontains shared parity and compiler test sources. -
inject-scala-compilerpublishes the selected Scala compiler plugin. -
inject-scala-test-compilerpublishes the matching compiler test support. -
micronaut-scala-bommanages the published Scala artifacts.
The complete compiler version is intentionally part of each compiler-coupled artifact name. This keeps incompatible compiler-plugin APIs from being silently mixed.
For the Core-side SPI and application documentation, see the Core SPI change and the related Scala documentation follow-up.
16 Release History
Release notes and published versions are available from:
The project uses an independent version line from Micronaut Core. Snapshot
versions are used while the Core SPI and Scala implementation are developed
together. A 1.0.0 release is published only after a released Micronaut Core
version contains the required SPI.
Each Scala compiler-plugin release remains coupled to its full Scala compiler
version. For example, Scala 3.9.0 uses
micronaut-inject-scala_3.9.0; future compiler releases receive parallel
artifacts rather than replacing this coordinate.