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:

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")
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 the Option an 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, Set and Map work 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 @Id goes — 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. host is localhost unless server.host says 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, Set and Map work the same way.

case

Equality, copy and toString come 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 a final def on 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

micronaut.processing.incremental

true or false. Whether incremental processing is enabled.

micronaut.processing.group

The Maven group of the module being compiled.

micronaut.processing.module

The name of the module being compiled.

micronaut.processing.project.dir

The project directory, used to locate generated configuration metadata.

micronaut.processing.annotations

Comma-separated package prefixes to restrict annotation processing to.

micronaut.processing.use.context.classloader

true or false. Whether to load visitors from the context classloader.

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 @ConfigurationProperties class writes META-INF/spring-configuration-metadata.json, and every description in 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 @Serdeable type 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

micronaut-inject-scala_<scala version>

Scala compiler plugin configuration only

Yes

micronaut-runtime-scala

Application runtime classpath

No

micronaut-inject-scala-test_<scala version>

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

micronaut.scala.plugin.jar

Absolute path to the compiler plugin jar.

micronaut.scala.test.classpath

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 its ClassElement

  • 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 an ApplicationContext

  • 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 taking scala.collection.immutable.Seq with ACC_VARARGS unset, so the parameter type and isVarArgs() == false are both accurate.

  • protected. Scala protected and protected[pkg] compile to public members, and the model reports what the bytecode contains.

  • Field visibility. Every Scala val and var is 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-scala contains shared Scala processing implementation sources.

  • inject-scala-test contains shared parity and compiler test sources.

  • inject-scala-compiler publishes the selected Scala compiler plugin.

  • inject-scala-test-compiler publishes the matching compiler test support.

  • micronaut-scala-bom manages 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.