A Scala 3 compiler plugin that enforces layered/onion architecture at compile time by validating package dependencies via @dependsOnPackages and @dependsOnLayers 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 @dependsOnPackages and/or @dependsOnLayers.
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 @dependsOnPackages to declare dependencies by package name:
package application
@layers.dependsOnPackages("domain")
object layerThis allows the application package to depend on domain and any subpackage (e.g. domain.user).
package infrastructure
@layers.dependsOnPackages("domain", "application")
object layerYou can also reference another package layer object directly with @dependsOnLayers:
package application
@layers.dependsOnLayers(domain.layer)
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. The layer count is the height of the dependency tree (longest path from root to leaf). Only packages with layer objects are counted; external dependencies (e.g.scala.*,java.*, third-party libraries) are ignored.
@dependsOnPackages/@dependsOnLayersplacement: These annotations may only be placed onobject layer. They fail 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 @dependsOnPackages/@dependsOnLayers 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 layer dependency annotation content. 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 annotations needed if domain has no cross-package deps
User.scala
application/ # Use cases / application services
layer.scala # @dependsOnPackages("domain")
UserService.scala
infrastructure/ # External adapters, repositories
layer.scala # @dependsOnPackages("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