A Scala 3 compiler plugin that enforces layered/onion architecture at compile time by validating package dependencies via @dependsOn annotations.
The plugin ensures that your codebase respects architectural boundaries. By default, no cross-package dependencies are allowed. Each package must declare its allowed dependencies explicitly using a layer object annotated with @dependsOn.
val layersVersion = "x.y.z"
addCompilerPlugin("com.github.lolgab" %%% "layers-plugin" % layersVersion)
libraryDependencies += "com.github.lolgab" %%% "layers" % layersVersionAdd the plugin to your build:
val layersVersion = "x.y.z"
def scalacPluginIvyDeps = Seq(mvn"com.github.lolgab:::layers-plugin:$layersVersion")
def ivyDeps = Seq(mvn"com.github.lolgab::layers:$layersVersion")The object must be named layer. Use @dependsOn to declare which packages this package may depend on:
package application
@layers.dependsOn("domain")
object layerThis allows the application package to depend on domain and any subpackage (e.g. domain.user).
package infrastructure
@layers.dependsOn("domain", "application")
object layer- maxLayers: Limit the maximum number of layers allowed (e.g.
-P:layers:maxLayers=5). Compilation fails if the application has more layers than the limit.
- @dependsOn placement: The annotation may only be placed on
object layer. It fails if placed on a class, trait, or an object with a different name. - Default: No cross-package dependencies are allowed
- Stdlib:
scala.*andjava.*packages are always allowed - Same package: Types within the same package are always allowed
- Subpackages: A package can depend on its own subpackages
- Cycles: The plugin detects and rejects cyclic file dependencies
When you change the @dependsOn annotation on a layer object, Zinc (sbt/mill incremental compiler) must recompile all files in that package. The plugin ensures this by:
- Adding a synthetic
val hash_<hex> = 1to each layer object, where the hash is derived from the@dependsOncontent. When you change dependencies, the hash changes and the layer object is recompiled. - Adding a synthetic
private val _layerRef = layerto the first class/object in each package file (except the layer object file). This creates a dependency so Zinc recompiles those files when the layer object changes. - Adding synthetic
private val _layerRef_<pkg> = <pkg>.layerfor each dependent package the class uses. This ensures Zinc recompiles when any of those layer objects change, avoiding stale layer config during incremental compilation.
A typical layered structure:
domain/ # Core business entities (no dependencies)
layer.scala # No @dependsOn needed if domain has no cross-package deps
User.scala
application/ # Use cases / application services
layer.scala # @dependsOn("domain")
UserService.scala
infrastructure/ # External adapters, repositories
layer.scala # @dependsOn("domain", "application")
UserRepository.scala
This project uses Mill.
# Compile
./mill layers.plugin.compile
# Run tests
./mill layers.plugin.test
# Build the plugin JAR
./mill layers.plugin.assembly