Record-based, annotation-driven configuration for Paper plugins.
Declare your configuration as a Java record, annotate it with defaults and
comments, and load it in one call. AutoCfg reads the file, decodes every value
into the right type, validates it, and writes the file back with any missing
keys filled in and your comments applied - so config.yml stays in sync with
the record without you maintaining it by hand.
Declare the shape of your config:
public record MyConfig(
@ConfigComment("The display name.")
@Default.String("name")
String name,
@ConfigComment("The material to place.")
@Default.Material("stone")
Material material,
@ConfigComment("The materials to accept.")
@Default.Material({"stone", "dirt"})
List<Material> materials,
@ConfigComment("An alternate display name.")
Optional<String> nickname,
@ConfigComment("How long to wait, as an ISO-8601 duration.")
@Default.Duration("PT30S")
Duration timeout,
@ConfigComment("How often to retry a failed HTTP request.")
@Default.Integer(3)
int maxHTTPRetries,
@ConfigComment("Database configuration.")
DatabaseConfig database
) {
public record DatabaseConfig(
@ConfigComment("The host of the database.")
@Default.String("localhost")
String host,
@ConfigComment("The port of the database.")
@Default.Integer(4242)
int port
) {
DatabaseConfig {
CommonValidators.notBlank(host, "host")
.portRange(port);
}
}
}Load it:
public final class MyPlugin extends JavaPlugin {
@Override
public void onEnable() {
try {
MyConfig config = ConfigLoader.loadDefaultConfig(this, MyConfig.class);
getLogger().info(config.database().host());
} catch (IOException exception) {
throw new IllegalStateException("Cannot load config.yml", exception);
}
}
}On first start the plugin writes this config.yml:
# The display name.
name: name
# The material to place.
material: stone
# The materials to accept.
materials:
- stone
- dirt
# How long to wait, as an ISO-8601 duration.
timeout: PT30S
# How often to retry a failed HTTP request.
max-http-retries: 3
# Database configuration.
database:
# The host of the database.
host: localhost
# The port of the database.
port: 4242Note that nickname is absent: an empty Optional is not written out.
Replace VERSION with the version in the badge above.
repositories {
mavenCentral()
}
dependencies {
implementation("dev.amraleth:autocfg:VERSION")
}[versions]
autocfg = "VERSION"
[libraries]
autocfg = { module = "dev.amraleth:autocfg", version.ref = "autocfg" }dependencies {
implementation(libs.autocfg)
}<dependency>
<groupId>dev.amraleth</groupId>
<artifactId>autocfg</artifactId>
<version>VERSION</version>
</dependency>AutoCfg has no transitive dependencies - paper-api and jspecify are
compileOnly - but the classes still have to reach the server at runtime.
Two ways:
Let Paper download it. Add it to the libraries list of your
plugin.yml / paper-plugin.yml and the server pulls it from Maven Central
on startup:
libraries:
- dev.amraleth:autocfg:VERSIONWith the plugin-yml Gradle plugin,
use the library configuration and the list is generated for you:
dependencies {
library("dev.amraleth:autocfg:VERSION")
}Or shade it into your plugin jar with the Shadow plugin. Relocating the package is recommended so that two plugins bundling different versions cannot clash:
tasks.shadowJar {
relocate("dev.amraleth.autocfg", "com.example.myplugin.libs.autocfg")
}The test suite runs as ordinary JVM tests and never starts a Paper server. It
uses Paper's YamlConfiguration directly to cover configuration loading,
writing, typed defaults, and validators:
./gradlew test@DefaultValue remains supported but is deprecated. Existing configurations
continue to load unchanged, including defaults for scalar values and lists.
New records should use the matching typed @Default.* annotation: for
example, @DefaultValue("3") becomes @Default.Integer(3), and
@DefaultValue("PT30S") becomes @Default.Duration("PT30S").
| Call | File |
|---|---|
ConfigLoader.loadDefaultConfig(plugin, type) |
config.yml in the plugin's data folder |
ConfigLoader.load(plugin, type) |
Named after the record, kebab-cased - MyConfig becomes my-config.yml |
ConfigLoader.load(plugin, "other.yml", type) |
A named file in the plugin's data folder |
ConfigLoader.load(file, type) |
An arbitrary file |
Every call reads the file, fills in whatever is missing, and saves it back. Missing parent directories are created.
The default loader preserves unknown keys. Use ConfigLoadOptions when a
configuration needs explicit migration or a stricter unknown-key policy:
ConfigLoadOptions options = new ConfigLoadOptions(
UnknownKeyPolicy.WARN,
List.of(config -> config.set("schema-version", 2)),
key -> plugin.getLogger().warning("Unknown config key: " + key)
);
MyConfig config = ConfigLoader.load(file, MyConfig.class, options);PRESERVE keeps unknown root keys, WARN reports them to the listener, and
REMOVE deletes them before saving. These policies apply only to root keys;
nested unknown keys are always preserved. Migration hooks run in list order
after YAML parsing and before AutoCfg maps the document to the record.
For the common cases, use ConfigLoadOptions.defaults(),
ConfigLoadOptions.warnUnknownKeys(listener), or
ConfigLoadOptions.removeUnknownKeys().
| Annotation | Applies to | Effect |
|---|---|---|
@Default.* |
Record component | A typed default used when the key is absent. Value-carrying defaults also accept multiple values for a List. |
@Default.Empty |
List<SomeRecord> component |
Declares an empty record list when the key is absent. |
@DefaultEntry |
List<SomeRecord> component |
Seeds the list with one entry built from the element record's defaults when the key is absent. |
@ConfigComment |
Record component, record type | Comment lines rendered above the key. On a type, it applies to every component of that type that carries no comment of its own. |
@ConfigKey |
Record component | Overrides the generated key. Must not be blank or contain .. |
Keys are derived from component names in kebab-case, splitting acronyms at
their trailing boundary - maxHTTPRetries becomes max-http-retries.
One default annotation is required on every component that is not a record, a
record list, or an Optional. Prefer one matching @Default.* annotation;
the deprecated @DefaultValue is also accepted for migration. A component with
neither a value in the file nor a default raises IllegalStateException.
Use the annotation that matches the component type:
| Component type | Default annotation |
|---|---|
boolean / Boolean through double / Double |
Matching @Default.Boolean through @Default.Double |
char / Character |
@Default.Character |
String |
@Default.String |
| Any enum | @Default.Enum, using its case-insensitive constant name |
Duration, NamespacedKey, Material |
@Default.Duration, @Default.NamespacedKey, @Default.Material |
| Type with a consumer-registered converter | @Default.Text |
List<T> |
The matching value-carrying annotation with one literal per entry |
List<SomeRecord> |
@Default.Empty or @DefaultEntry |
Value-carrying annotations take an array, so a scalar can use the convenient
single-value form such as @Default.Integer(3), while a list uses
@Default.Integer({3, 5, 8}).
- Primitives and their boxes, and
String(@Default.Boolean,@Default.Integer, and so on) - Enums - written lowercase, read case-insensitively
- Nested records, mapped to nested sections
List<T>of any supported scalar, andList<SomeRecord>for repeated sections, optionally seeded with an example entryOptional<T>- an absent key yieldsOptional.empty()and needs no defaultDuration(ISO-8601),NamespacedKey, andMaterial, with corresponding typed annotations- Consumer-registered converters via
@Default.Text
A List<SomeRecord> declares one of two things. @Default.Empty starts the
list empty, leaving the shape of an entry undocumented in the file. @DefaultEntry
seeds a single entry built from the element record's own @Default.* annotations, so a
fresh file shows what an entry looks like:
@ConfigComment("The scheduled backups.")
@DefaultEntry
List<BackupConfig> backups
record BackupConfig(
@Default.String("backup")
String label,
@Default.Duration("PT1H")
Duration interval
) {
}# The scheduled backups.
backups:
- label: backup
interval: PT1HEvery component of a seeded record must be resolvable without a file - a
@Default.*, a record whose components are, or an Optional.
The entry is seeded only when the key is absent. Once written it reads back as
ordinary data, so it can be edited, duplicated or removed, and a list left as
[] stays empty. Note that a bare backups: with no value parses as null,
which counts as absent and seeds again; write backups: [] to keep it empty.
A non-empty @Default.* annotation on a record list is rejected either way - use
@DefaultEntry or declare the entries in the file.
Bukkit attaches comments to keys, and entries inside a list have none, so
@ConfigComment on the components of a list element record does not render. The
comment on the list component itself does.
Register a converter for anything else, before the config that uses it loads:
ConfigConverters.register(
UUID.class,
node -> UUID.fromString(node.toString()),
UUID::toString
);The registry is static, and therefore scoped to the classloader holding the
class: a relocated copy shaded into your plugin has a registry of its own, while
a copy loaded through libraries is shared across every plugin using it.
Validate in the record's compact constructor. Exceptions thrown there propagate
out of the loader unchanged, so a bad value fails the load with your own
message. CommonValidators covers inclusive and exclusive ranges for int,
long, and double; positive and non-negative checks; finite doubles; valid
ports; null, blank, empty, length, and regular-expression checks; collection
size and uniqueness checks; and uniqueness by a derived key.
Every validator returns a stateless CommonValidators.Chain. Each link checks
the argument supplied to that link, allowing independent values to be validated
in one fluent sequence:
CommonValidators.requireUnique(rules)
.nonBlank(name, "name")
.positive(maxRetries, "maxRetries");| Exception | Cause |
|---|---|
IOException |
The file cannot be created, read, parsed or saved |
IllegalStateException |
The record is malformed, or a required key has no value and no default |
IllegalArgumentException |
A value cannot be decoded into its component type, or fails validation |
A file that cannot be parsed raises IOException before anything is written, so
a syntax error in config.yml never causes it to be overwritten with defaults.
- Java 25 or newer
- Paper 26.2 or newer
./gradlew build./gradlew :autocfg-test-plugin:runServer starts a Paper server with the test
plugin installed.
Licensed under the Apache License 2.0.