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. 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.
- @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