
Building a Gradle Plugin for SpiceDB Code Generation
January 13, 2026
I've been using SpiceDB for authorization. It's a Zanzibar-style system where you define relationships between objects in a schema, then query whether a user can do something based on those relationships.
The schema looks like this:
definition user {}
definition document {
relation owner: user
relation viewer: user
permission view = owner + viewer
permission edit = owner
}
The annoying part is checking permissions in code:
CheckPermissionRequest request = CheckPermissionRequest.newBuilder()
.setResource(ObjectReference.newBuilder()
.setObjectType("document")
.setObjectId(docId).build())
.setSubject(SubjectReference.newBuilder()
.setObject(ObjectReference.newBuilder()
.setObjectType("user")
.setObjectId(userId).build()).build())
.setPermission("view")
.build();
Strings everywhere. Rename a permission and your code still compiles. Make a typo and you find out at runtime.
Spicegen
Spicegen generates typed Java code from these schemas:
var allowed = permissionService.checkPermission(
document.checkView(SubjectRef.ofObject(user)));
But it only had a Maven plugin, and I use Gradle.
Writing the plugin
Gradle plugins have three pieces: an extension (the configuration DSL), a task (the actual work), and the plugin class that wires them together.
The extension defines what users can configure:
public abstract class SpicegenExtension {
public abstract RegularFileProperty getSchemaFile();
public abstract Property<String> getPackageName();
public abstract DirectoryProperty getOutputDirectory();
}
Those Property types are Gradle's lazy configuration API. Values aren't resolved until something actually needs them. I found this awkward at first - you can't just call getSchemaFile() and get a File back. But it prevents ordering issues where one plugin tries to read a value before another plugin has set it.
The task does the generation:
public abstract class SpicegenTask extends DefaultTask {
@InputFile
public abstract RegularFileProperty getSchemaFile();
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
@TaskAction
public void generate() {
// Parse schema, generate code
}
}
@InputFile tells Gradle "if this file hasn't changed, you can skip this task." @OutputDirectory tells it where to look for cached outputs. Get these wrong and your incremental builds silently break.
Testing
You can't unit test a Gradle plugin in any meaningful way. The behavior you care about is "does this build configuration produce working code?" - so you need to run actual builds.
Gradle has TestKit for this:
@Test
void generatesSourcesFromSchema() throws IOException {
writeFile("build.gradle.kts", """
plugins {
java
id("com.oviva.spicegen")
}
""");
writeFile("src/main/resources/schema.zed", testSchema);
var result = GradleRunner.create()
.withProjectDir(testProjectDir)
.withPluginClasspath()
.withArguments("generateSpiceDbSources")
.build();
assertThat(result.task(":generateSpiceDbSources").getOutcome())
.isEqualTo(SUCCESS);
}
Slower than unit tests, but it catches real problems.
Cross-build-tool dependencies
Spicegen's core modules are built with Maven. My Gradle plugin needs to depend on them. During development you have to mvn install to your local repo, then point Gradle at mavenLocal(), and hope the versions line up. I documented the workflow in the README.
Cacheability
For your task to participate in Gradle's build cache:
- Annotate all inputs and outputs
- Mark the task
@CacheableTask - Add
@PathSensitiveto file inputs so Gradle knows whether absolute paths matter
@CacheableTask
public abstract class SpicegenTask extends DefaultTask {
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
public abstract RegularFileProperty getSchemaFile();
// ...
}
I forgot @PathSensitive initially. The task worked fine but never cache-hit because Gradle thought moving the project directory changed the inputs.
Using the plugin
PR on the original library: github.com/oviva-ag/spicegen/pull/22
Or my fork with the Gradle plugin included: github.com/gernot-ohner/spicegen
plugins {
java
id("com.oviva.spicegen") version "1.0.0"
}
dependencies {
implementation("com.oviva.spicegen:api:1.0.0")
}
spicegen {
schemaFile.set(file("src/main/resources/schema.zed"))
packageName.set("com.example.permissions")
}
Generated sources get added to the main source set automatically and regenerate whenever the schema changes.