A modern, type-safe YAML configuration library for Java that leverages records and annotations for seamless configuration management.
- 🎯 Type-safe: Compile-time safety with Java records
- 🔧 Annotation-driven: Flexible configuration with
@Optionsand default value annotations - 🔑 Key-based mapping: Flexible YAML structures with
@Options(isKey = true)for both simple and complex object flattening - 📦 Inline fields: Flatten nested record fields with
@Options(inline = true)for cleaner YAML structure — on aMap, it becomes the catch-all of its node and absorbs every unclaimed key - 🏗️ Nested configurations: Support for complex, hierarchical settings
- 📋 Collections support: Lists, Sets, and Maps with generic type safety
- 🔄 Enum integration: Special support for configuration enums
- 🎭 Polymorphic interfaces: Automatic type resolution based on YAML keys for plugin systems (with inline discriminator support)
- 📖 Custom readers: TypeToken-based custom type conversion for external libraries (Adventure API, etc.)
- ✍️ YAML serialization: Write records back to YAML with the optional
structura-writermodule (full read/write round-trips) - 🔀 Automatic type conversion: Smart conversion between compatible types
- 🎨 Kebab-case mapping: Automatic camelCase ↔ kebab-case field name conversion
- 📦 Modular: Split into
structura-core,structura-writer, and astructura-bom; SnakeYAML is the only runtime dependency
Add Structura to your project:
repositories {
maven { url = "https://repo.groupez.dev/releases" } // Add Structura repository, replace releases with snapshots if needed
}
dependencies {
// Core (reading YAML). SnakeYAML is pulled in transitively.
implementation("fr.traqueur.structura:structura-core:<VERSION>") // Replace <VERSION> with the latest release
// Optional: reverse serialization (writing records back to YAML)
implementation("fr.traqueur.structura:structura-writer:<VERSION>")
}Or use the BOM to align module versions:
dependencies {
implementation(platform("fr.traqueur.structura:structura-bom:<VERSION>"))
implementation("fr.traqueur.structura:structura-core")
implementation("fr.traqueur.structura:structura-writer")
}- Define your configuration record:
import fr.traqueur.structura.api.Loadable;
public record AppConfig(
@DefaultString("MyApp") String appName,
@DefaultInt(8080) int port,
@DefaultBool(false) boolean enableSsl,
DatabaseConfig database
) implements Loadable {
}
public record DatabaseConfig(
String host,
@DefaultInt(5432) int port,
String database,
@DefaultString("postgres") String username
) implements Loadable {
}- Create your YAML configuration:
# config.yml
app-name: "Production App"
port: 9000
enable-ssl: true
database:
host: "db.production.com"
port: 5432
database: "myapp_prod"
username: "app_user"- Load and use your configuration:
import fr.traqueur.structura.api.Structura;
// Load from file
AppConfig config = Structura.load(Path.of("config.yml"), AppConfig.class);
// Load from resources
AppConfig config = Structura.loadFromResource("/config.yml", AppConfig.class);
// Parse from string
String yamlContent = "app-name: Test\nport: 3000";
AppConfig config = Structura.parse(yamlContent, AppConfig.class);
// Use your configuration
System.out.println("Starting " + config.appName() + " on port " + config.port());Use annotation-based default values for optional configuration:
public record ServerConfig(
@DefaultString("localhost") String host,
@DefaultInt(8080) int port,
@DefaultBool(false) boolean ssl,
@DefaultLong(30000L) long timeout,
@DefaultDouble(1.5) double retryMultiplier
) implements Loadable {}@DefaultEmpty makes an absent List, Set, Collection or Map empty (and immutable), instead of missing
or null:
public record Dialog(@DefaultEmpty List<Body> body, @DefaultEmpty Map<String, Input> inputs) implements Loadable {}On any other type it is an error: @DefaultEmpty on Dialog.title: @DefaultEmpty applies to a List, Set, Collection or Map, not String.
Override automatic kebab-case conversion:
public record CustomConfig(
@Options(name = "app-name") String applicationName,
@Options(name = "db-config") DatabaseConfig databaseConfiguration
) implements Loadable {}Use @Options(inline = true) to flatten nested record fields to the parent level, avoiding unnecessary nesting:
Without inline (default behavior):
public record ServerInfo(
String host,
@DefaultInt(8080) int port
) implements Loadable {}
public record AppConfig(
String appName,
ServerInfo server // NOT inline
) implements Loadable {}app-name: MyApp
server: # Nested under "server" key
host: localhost
port: 8080With inline:
public record AppConfig(
String appName,
@Options(inline = true) ServerInfo server // Fields flattened to root
) implements Loadable {}app-name: MyApp
host: localhost # server.host is at root level
port: 8080 # server.port is at root levelMultiple inline fields:
public record ServerInfo(
String host,
@DefaultInt(8080) int port
) implements Loadable {}
public record DatabaseInfo(
String host,
@DefaultInt(5432) int port,
String database
) implements Loadable {}
public record AppConfig(
String appName,
@Options(inline = true) ServerInfo server,
@Options(inline = true) DatabaseInfo database
) implements Loadable {}app-name: MyApp
host: api.example.com # Shared by both server and database
port: 9000 # Shared by both server and database
database: production_db # Specific to databaseBoth server and database will use the same host and port values from the flattened structure.
Mixing inline and nested fields:
public record AppConfig(
String appName,
@Options(inline = true) ServerInfo server, // Inline
DatabaseInfo database // Nested
) implements Loadable {}app-name: MyApp
host: api.example.com # server.host (inline)
port: 8443 # server.port (inline)
database: # database is nested
host: db.example.com
port: 5432
database: app_db@Options(inline = true) on a Map turns it into the catch-all of its node: it absorbs every key that no sibling component claims. A component with free-form keys therefore no longer needs an envelope key of its own.
public record Greeting(
@Options(optional = true) String comment,
@Options(inline = true) Map<String, String> byLocale
) implements Loadable {}comment: "greeting shown on join" # the declared component
fr_FR: "Bienvenue" # ─┐
en_US: "Welcome" # ─┴ byLocaleA sibling claims its effective name, so @Options(name = "...") is honoured — rename comment to note and comment becomes an ordinary data key for the map. The computation is recursive: an inline sibling record reads its own fields at that same level, so those names are claimed too.
Values go through the map's generic value type, exactly as anywhere else, so Map<String, List<String>> and Map<String, SomeRecord> both work. A catch-all map is never null: with nothing left to absorb it is simply empty. It can be declared anywhere in the record.
Two shapes are rejected at load time, because they would produce a wrong but plausible result:
- two inline maps in the same record — nothing says how to split the remaining keys between them;
- an inline map next to a fully inline polymorphic field (
@Options(inline = true)+@Polymorphic(inline = true)) — the keys of that field are only known once its discriminator has been read, so the map would silently swallow them.
The same annotation on a field of a Loadable enum means "the whole node of the constant is this field's value": a scalar goes through the custom readers, a map through the converter — minus the keys the sibling fields claim, exactly like an inline component of a record. This is what lets one key accept both a short and a long form:
public enum Messages implements Loadable {
GREETING, BYE;
@Options(inline = true) public LocalizedText value; // a record with an inline Map<String, String>
@Options(optional = true) public String comment; // a sibling: keeps its own key
}greeting: "Bonjour" # short form: the reader registered for LocalizedText builds it
bye: # long form: the node minus `comment` becomes the record
fr_FR: "Au revoir"
en_US: "Goodbye"
comment: "sent on quit"Without the annotation a map under a constant is read as the container of the fields (bye: { value: … }): a long form would be silently ignored and the Java literal kept. Two inline fields in the same enum are rejected at load time.
Use @Options(isKey = true) to create flexible YAML structures where keys become field values or where complex objects can be flattened.
For simple types (String, int, etc.), the YAML key becomes the field value:
public record DatabaseConnection(
@Options(isKey = true) String name,
String host,
@DefaultInt(5432) int port
) implements Loadable {}# The key "production" becomes the value of the "name" field
production:
host: "prod.db.example.com"
port: 5432DatabaseConnection config = Structura.parse(yaml, DatabaseConnection.class);
// config.name() returns "production"
// config.host() returns "prod.db.example.com"
// config.port() returns 5432For complex types (records implementing Loadable), fields are flattened to the same level:
public record ServerInfo(
String host,
@DefaultInt(8080) int port,
@DefaultString("http") String protocol
) implements Loadable {}
public record AppConfig(
@Options(isKey = true) ServerInfo server, // Complex object as key
String appName,
@DefaultBool(false) boolean debugMode
) implements Loadable {}# Flattened structure - server fields at root level
host: "api.example.com"
port: 9000
protocol: "https"
app-name: "MyApp"
debug-mode: trueAppConfig config = Structura.parse(yaml, AppConfig.class);
// config.server().host() returns "api.example.com"
// config.server().port() returns 9000
// config.server().protocol() returns "https"
// config.appName() returns "MyApp"
// config.debugMode() returns trueNEW! Structura supports polymorphic configuration through interfaces and registries, perfect for plugin systems, database drivers, or any scenario where you need different implementations based on YAML content.
- Define your polymorphic interface:
@Polymorphic(key = "type") // Field name that determines the implementation
public interface DatabaseConfig extends Loadable {
String getHost();
int getPort();
}- Create concrete implementations:
public record MySQLConfig(
@DefaultString("localhost") String host,
@DefaultInt(3306) int port,
@DefaultString("mysql") String driver,
@DefaultBool(true) boolean useSSL
) implements DatabaseConfig {
@Override public String getHost() { return host; }
@Override public int getPort() { return port; }
}
public record PostgreSQLConfig(
@DefaultString("localhost") String host,
@DefaultInt(5432) int port,
@DefaultString("postgresql") String driver,
@DefaultBool(false) boolean ssl
) implements DatabaseConfig {
@Override public String getHost() { return host; }
@Override public int getPort() { return port; }
}
public record MongoConfig(
@DefaultString("localhost") String host,
@DefaultInt(27017) int port,
@DefaultString("admin") String authDatabase
) implements DatabaseConfig {
@Override public String getHost() { return host; }
@Override public int getPort() { return port; }
}- Register implementations:
// One-time setup (typically in a static block or startup code)
PolymorphicRegistry.create(DatabaseConfig.class, registry -> {
registry.register("mysql", MySQLConfig.class);
registry.register("postgresql", PostgreSQLConfig.class);
registry.register("mongodb", MongoConfig.class);
});- Use in your configuration:
public record AppConfig(
String appName,
DatabaseConfig database, // Polymorphic interface
List<DatabaseConfig> backupDatabases // Collections work too!
) implements Loadable {}app-name: "MyApp"
database:
type: "mysql" # This determines the implementation
host: "prod.mysql.com"
port: 3306
use-ssl: true
driver: "com.mysql.cj.jdbc.Driver"
backup-databases:
- type: "postgresql" # Different implementation
host: "backup.postgres.com"
port: 5432
ssl: true
- type: "mongodb" # Another implementation
host: "backup.mongo.com"
port: 27017
auth-database: "backup"AppConfig config = Structura.parse(yaml, AppConfig.class);
// config.database() is automatically a MySQLConfig instance
MySQLConfig mysql = (MySQLConfig) config.database();
System.out.println("MySQL driver: " + mysql.driver());
// config.backupDatabases() contains PostgreSQLConfig and MongoConfig instances
PostgreSQLConfig postgres = (PostgreSQLConfig) config.backupDatabases().get(0);
MongoConfig mongo = (MongoConfig) config.backupDatabases().get(1);Inline Discriminator Keys
By default, the discriminator key (e.g., type) is placed inside the polymorphic field:
database:
type: mysql # type is inside database
host: localhostWith inline = true, the discriminator key is placed at the same level as the field:
@Polymorphic(key = "type", inline = true) // Enable inline mode
public interface DatabaseConfig extends Loadable {
String getHost();
int getPort();
}
public record AppConfig(
String appName,
DatabaseConfig database
) implements Loadable {}type: mysql # type is at the same level as database
database:
host: localhost
port: 3306This makes the configuration clearer by avoiding repetition and improving readability. The inline key can also have a custom name:
@Polymorphic(key = "provider", inline = true)
public interface StorageConfig extends Loadable { /* ... */ }provider: s3 # Custom key name at root level
storage:
bucket: my-bucket
region: us-east-1Discriminator as the YAML Key
With useKey = true, the discriminator value itself becomes the YAML key instead of being written as a separate field. This produces a more compact structure, especially for collections and maps:
@Polymorphic(key = "type", useKey = true)
public interface ItemMeta extends Loadable {}For a single field, the discriminator value replaces the field name as the key:
trim: # "trim" is the discriminator value
material: DIAMOND
pattern: VEXFor a List<ItemMeta>, the list is serialized as a map keyed by discriminator value:
metadata:
food: # "food" is the discriminator value
nutrition: 8
potion: # "potion" is the discriminator value
color: "#FF0000"For a Map<String, ItemMeta>, the existing map key doubles as the discriminator value (no redundant type field is written).
Other Advanced Features:
- Custom key names: Use
@Polymorphic(key = "provider")for different field names - Auto-naming:
registry.register(MySQLConfig.class)uses lowercased class name - Type safety: Compile-time guarantees that implementations match the interface
- Error handling: Clear messages showing available types when resolution fails
- Default values: All
@Default*annotations work in polymorphic implementations
With @Polymorphic(useKey = true, inline = true), there is no discriminator key: exactly one of the registered
names is present in the section, and it selects the sub-type.
@Polymorphic(useKey = true, inline = true)
public sealed interface Control extends Loadable permits Toggle, Select, Step {}
public record Toggle(String target) implements Control {}
public record Select(@Options(inline = true) Map<String, String> choice) implements Control {}
public record Step(@Options(inline = true) Map<String, Integer> by) implements Control {}
PolymorphicRegistry.create(Control.class, r -> {
r.register("toggle", Toggle.class); r.register("select", Select.class); r.register("step", Step.class);
});
public record Button(String label, @Options(optional = true) String when,
@Options(inline = true, optional = true) Control control) implements Loadable {}buttons:
- { label: "Players", when: tab=chat, toggle: view.chat-players } # Toggle[target=view.chat-players]
- { label: "Chat", select: { tab: chat } } # Select[choice={tab=chat}]
- { label: "+1", step: { quantity: 1 } } # Step[by={quantity=1}]
- { label: "Decor" } # control = null (optional)Which node the sub-type reads:
-
a sub-type with a component named like the key reads the whole section — handy for a map value:
@Polymorphic(useKey = true, inline = true) public sealed interface StateDef extends Loadable permits Choice, Range, Flag {} public record Choice(List<String> choice, @Options(optional = true) String initial) implements StateDef {} public record Range(List<Integer> range, @DefaultInt(0) int initial) implements StateDef {} public record Flag(boolean flag) implements StateDef {} public record Menu(Map<String, StateDef> state) implements Loadable {}
state: tab: { choice: [chat, notifications], initial: chat } # Choice quantity: { range: [1, 64], initial: 1 } # Range preview: { flag: false } # Flag
-
any other sub-type reads the value of the key: a section (
select: { tab: chat }), or a single value read into its only component (toggle: view.chat-playersforToggle(String target)). A sub-type with several components, or with an inline one, refuses a single value.
On an inline component the key sits next to the parent's keys; anywhere else (a map value, a list element, a plain component) the value's own section holds it. The errors:
1:1: 'control' is required but not provided: set one of toggle, select, step (control)
2:44: 'toggle' and 'select' cannot both be set under 'buttons[0]': a Control is one of toggle, select, step
2:8: 'creak' cannot be read as a Sound, which reads name, volume: write a section under 'sound' (sound)
2:25: unknown key 'toogle' under 'buttons[0]' (did you mean 'toggle'?) # strict mode
An optional inline component with no registered key is null. It cannot live next to an inline catch-all map.
The writer writes the sub-type's key, then its value; the schema offers exactly one of the keys.
- Database drivers: Switch between MySQL, PostgreSQL, MongoDB based on configuration
- Payment providers: Different implementations for Stripe, PayPal, etc.
- Logging backends: File, console, remote logging with different configurations
- Plugin systems: Load different plugin implementations based on type
- Cloud providers: AWS, Azure, GCP with provider-specific settings
NEW! Structura supports custom type readers for external libraries and complex custom types. This is perfect for integrating with third-party libraries like Adventure API, or implementing custom deserialization logic.
For simple types, register a reader using the class:
import fr.traqueur.structura.registries.CustomReaderRegistry;
// Example with Adventure API Component
CustomReaderRegistry.getInstance().register(
Component.class,
str -> MiniMessage.miniMessage().deserialize(str)
);
public record MessageConfig(
Component welcomeMessage,
Component errorMessage
) implements Loadable {}welcome-message: "<green>Welcome to the server!</green>"
error-message: "<red>An error occurred!</red>"MessageConfig config = Structura.parse(yaml, MessageConfig.class);
// welcomeMessage and errorMessage are automatically converted to ComponentsFor generic types like List<Component>, Optional<String>, or Map<String, Integer>, use TypeToken to preserve full type information:
import fr.traqueur.structura.types.TypeToken;
// Register reader specifically for List<Component>
CustomReaderRegistry.getInstance().register(
new TypeToken<List<Component>>() {}, // Note the {} - creates anonymous class
str -> parseComponentList(str)
);
// Register different reader for List<String>
CustomReaderRegistry.getInstance().register(
new TypeToken<List<String>>() {},
str -> Arrays.asList(str.split(","))
);Important: Always instantiate TypeToken with {} to create an anonymous class that captures type information:
// ✅ Correct - captures generic type
new TypeToken<List<String>>() {}
// ❌ Wrong - will throw StructuraException
new TypeToken<List<String>>()Comma-separated lists:
CustomReaderRegistry.getInstance().register(
new TypeToken<List<String>>() {},
str -> Arrays.stream(str.split(","))
.map(String::trim)
.toList()
);tags: "java, yaml, configuration" # Becomes ["java", "yaml", "configuration"]Optional values:
CustomReaderRegistry.getInstance().register(
new TypeToken<Optional<String>>() {},
str -> str.isEmpty() ? Optional.empty() : Optional.of(str)
);Complex parsing:
CustomReaderRegistry.getInstance().register(
new TypeToken<Map<String, Integer>>() {},
str -> Arrays.stream(str.split(","))
.map(pair -> pair.split(":"))
.collect(Collectors.toMap(
parts -> parts[0].trim(),
parts -> Integer.parseInt(parts[1].trim())
))
);scores: "alice:100, bob:95, charlie:87" # Becomes {"alice": 100, "bob": 95, "charlie": 87}public class ServerSetup {
static {
// Register once at startup
CustomReaderRegistry.getInstance().register(
Component.class,
str -> MiniMessage.miniMessage().deserialize(str)
);
}
}
public record ServerConfig(
Component motd,
Component joinMessage,
List<Component> rules,
Map<String, Component> customMessages
) implements Loadable {}motd: "<gradient:green:blue>Welcome to My Server</gradient>"
join-message: "<green>Welcome, {player}!</green>"
rules:
- "<yellow>1. Be respectful</yellow>"
- "<yellow>2. No cheating</yellow>"
- "<yellow>3. Have fun!</yellow>"
custom-messages:
afk: "<gray>{player} is now AFK</gray>"
back: "<green>{player} is back!</green>"When both generic and non-generic readers are registered, Structura prioritizes the most specific one:
// Fallback for all Box types
registry.register(Box.class, str -> new Box<>("DEFAULT:" + str));
// Specific for Box<String>
registry.register(new TypeToken<Box<String>>() {}, str -> new Box<>("SPECIFIC:" + str));
// When parsing Box<String>, uses the TypeToken reader
// When parsing raw Box, uses the Class reader-
Register once: Register all readers at application startup
-
Thread-safe: CustomReaderRegistry is thread-safe
-
Error handling: Throw StructuraException for clear error messages:
registry.register(Component.class, str -> { try { return MiniMessage.miniMessage().deserialize(str); } catch (Exception e) { throw new StructuraException("Failed to parse: " + str, e); } });
-
Type safety: Always use specific types in TypeToken, not wildcards:
// ✅ Good new TypeToken<List<String>>() {} // ❌ Avoid new TypeToken<List<?>>() {}
A plain Reader<T> receives text. During a read it now also receives the text of a number or a boolean,
so amount: 100 and amount: "100" reach the same reader. A ContextualReader<T> sees more: the value
as YAML typed it, the full type requested and the path of the value.
CustomReaderRegistry registry = CustomReaderRegistry.getInstance();
registry.registerContextual(Amount.class, ctx -> {
ctx.raw(); // 100 (Integer) or "100" (String), as YAML typed it
ctx.text(); // "100"
ctx.target(); // TypeToken{Amount}
ctx.path(); // "shop.offers[2].amount"
return new Amount(Long.parseLong(ctx.text()));
});A generic reader serves every parameterization of a type, instead of one reader per
Resolvable<Integer>, Resolvable<Team>… It reads the type argument with Structura's own rules
through ctx.convert(...):
registry.registerGeneric(Resolvable.class, ctx ->
ctx.raw() instanceof String text && text.startsWith("%")
? Resolvable.placeholder(text) // "%server_pvp%"
: Resolvable.of(ctx.convert(ctx.typeArgument(0)))); // 20, true, Team...A reader registered for an exact type (register(new TypeToken<Resolvable<Boolean>>() {}, ...)) wins over
the generic one. An exception thrown by a reader becomes a READER error with the path and line of the value.
The registries above are global. A processor can be given its own, looked up before the global ones, so that two plugins of the same server, or two tests running in parallel, do not share their readers or sub-types:
CustomReaderRegistry readers = CustomReaderRegistry.create();
readers.register(Component.class, MiniMessage.miniMessage()::deserialize);
PolymorphicRegistries types = PolymorphicRegistries.create();
types.register(Reward.class, registry -> registry.register("money", MoneyReward.class));
StructuraProcessor processor = Structura.builder()
.readers(readers)
.polymorphics(types)
.build();Reference<T> lets you declare a field in a Loadable record whose YAML value is a plain string key that maps to a live, externally managed object. Structura resolves it automatically without any CustomReaderRegistry setup.
Use Reference<T> when your config needs to point to an object that your application manages (e.g., a loaded arena, a plugin's manager instance) rather than a nested YAML section.
import fr.traqueur.structura.references.ReferenceRegistry;
// Register once — typically in your plugin's onEnable() or startup class
ReferenceRegistry.getInstance().install(
Arena.class,
Arena::id, // key extractor: how to get the string key from an Arena
() -> plugin.getArenaManager().getArenas() // live collection supplier
);import fr.traqueur.structura.references.Reference;
public record GameConfig(
Reference<Arena> arena, // YAML value: "my-arena-id"
int maxPlayers
) implements Loadable {}arena: "my-arena-id"
max-players: 20GameConfig config = Structura.parse(yaml, GameConfig.class);
String key = config.arena().key(); // "my-arena-id"
Arena arena = config.arena().element(); // live Arena object from the managerReference#element() resolves from the live provider collection at call time. This means:
- Objects added to the collection after parsing are still resolvable.
- A stale
Referencecan reflect updates if the collection is mutable. StructuraExceptionis thrown atelement()call time (not parse time) if the key is not found.
// No provider installed → StructuraException at parse time
// Key not found in the collection → StructuraException at element() call time
try {
Arena arena = config.arena().element();
} catch (StructuraException e) {
// "No Arena found for key 'my-arena-id'"
}public record GameConfig(
@DefaultReference(type=Arena.class, key="default-arena") Reference<Arena> arena,
int maxPlayers
) implements Loadable {}If the arena key is absent from YAML, the string "default-arena" is used as the reference key.
NEW in 2.0! Structura can serialize records back to YAML, enabling full read/write round-trips. This lives in the optional structura-writer module — add it to your dependencies (see Installation) to unlock Structura.write() and Structura.saveDefault().
If the
structura-writermodule is not on the classpath, calling these methods throws aStructuraExceptionexplaining how to add it. The core module stays write-free.
Structura.write(Path, Loadable) serializes any Loadable record to YAML and writes it to disk. Parent directories are created automatically, and the file is overwritten if it already exists.
import fr.traqueur.structura.api.Structura;
AppConfig config = new AppConfig("MyApp", 9000, true, database);
Structura.write(Path.of("plugins/myplugin/config.yml"), config);A typical round-trip — load, modify, save:
AppConfig config = Structura.load(path, AppConfig.class);
// Records are immutable, so build an updated copy
AppConfig updated = new AppConfig(
config.appName(),
config.port(),
true, // enable debug
config.database()
);
Structura.write(path, updated);
// Save the current values of every enum constant
Structura.writeEnum(Path.of("database-types.yml"), DatabaseType.class);Structura.saveDefault(Path, Class) builds an instance from a record's @Default* annotations (and zero-values for unannotated fields), then writes it. Perfect for generating a starter config on first run.
// Generate config.yml from the defaults declared on AppConfig
if (!Files.exists(configFile)) {
Structura.saveDefault(configFile, AppConfig.class);
}Note:
saveDefault()does not check whether the file already exists — guard it yourself as shown above. It also cannot pick a concrete type for polymorphic interface fields (there is no default implementation to choose). For configs containing polymorphic fields, build an explicit default instance and usewrite()instead:StorageConfig defaultStorage = new StorageConfig(new LocalBackend("./data", 100)); Structura.write(storageFile, defaultStorage);
For configurable enums, Structura.saveDefaultEnum(Path, Class) is the enum counterpart: it serializes every constant like writeEnum(), but fills any null field from its @Default* annotation first — producing a complete, editable default template.
if (!Files.exists(databasesFile)) {
Structura.saveDefaultEnum(databasesFile, DatabaseType.class);
}The writer is symmetric with the reader — it understands the same feature set:
- camelCase → kebab-case key conversion (and
@Options(name = "...")overrides) @Options(inline = true)— flattens a sub-record's fields into the parent map@Options(inline = true)on aMap— catch-all map: entries are written at the parent level, the field name is dropped@Options(optional = true)— null fields are silently omitted (not written asnull)@Options(isKey = true)— both simple (key becomes the map key) and complex (key sub-record flattened)@Polymorphicin all modes — standard,inline = true, fully inline, anduseKey = trueLocalDate/LocalDateTime— written as ISO-8601 strings- Enums — constant name converted to kebab-case
Reference<T>— serialized back to its key string- Lists, Sets, and Maps of any of the above
Structura.write produces a fresh file. To change a file a person edits — from an in-game editor, for
instance — use update, which keeps their comments, the order of their keys and the quoting of the values
that did not change:
Structura.update(file, edited);# Limits per player
limits:
per-day: 8 # resets at midnight <- only the value changed
# weekly cap
per-week: 20A key the configuration no longer has is removed (with its comments), a new key goes after the existing ones. SnakeYAML reduces the spaces before an end-of-line comment to one.
A server file read over a default shipped in the jar (Layered Sources)
only needs what the server changed. writeOverrides writes exactly that, keeping the comments already in the
file — reading the file over the default gives the configuration back:
ShopSettings defaults = Structura.parse(List.of(Source.ofResource("/config.yml")), ShopSettings.class);
Structura.writeOverrides(dataFolder.resolve("config.yml"), edited, defaults);Sections are compared key by key, lists and values as a whole — the same rules as layered reading. Keys are
written with the same names the reader reads (Structura.keyOf).
For types the serializer doesn't know natively (e.g. Adventure's Component), register a Writer<T> — the symmetric counterpart to a custom reader:
import fr.traqueur.structura.writers.registries.CustomWriterRegistry;
CustomWriterRegistry.getInstance().register(
Component.class,
component -> MiniMessage.miniMessage().serialize(component)
);A Writer<T> converts a Java value to its YAML-serializable form (a String, Number, Map, List, etc.). Pair it with a matching CustomReaderRegistry entry to keep your custom type fully round-trippable.
A writer registered for a class or an interface also serves its subclasses and implementations — the value of
a Component field is an internal implementation of Component, and the closest registered type wins.
A contextual writer also knows the type declared for the value and its path, and hands the values it holds
back to Structura — the symmetric of a contextual reader. One writer for Resolvable.class serves every
Resolvable<T>:
CustomWriterRegistry.getInstance().registerContextual(Resolvable.class, (resolvable, ctx) ->
resolvable.isPlaceholder()
? resolvable.placeholder() // "%server_pvp%"
: ctx.write(resolvable.value(), ctx.typeArgument(0))); // Team record, enum, list... by Structura's rules
// ctx.target() → Resolvable<Team>, ctx.path() → "rules.owner"A failing writer names the path of the value: Writer failed for Resolvable at rules.owner: ....
A writer can have registries of its own, looked up before the global ones:
CustomWriterRegistry writers = CustomWriterRegistry.create();
writers.register(Component.class, MiniMessage.miniMessage()::serialize);
StructuraWriter writer = YamlStructuraWriter.with(writers, polymorphicRegistries);
writer.update(file, config);Read a default shipped in the jar and the server's file on top of it, in one call and without rewriting any text:
ShopSettings settings = Structura.parse(List.of(
Source.ofResource("/config.yml"), // shown as "config.yml (jar)"
Source.of(dataFolder.resolve("config.yml")) // the server's file
), ShopSettings.class);
// or with explicit names
processor.parse(List.of(Source.of("config.yml (jar)", defaults), Source.of("config.yml", server)), ShopSettings.class);- sections are merged key by key, at every depth;
- a list or a single value of an upper layer replaces the one below;
- each error names the layer, and the line in that layer, of the faulty value:
config.yml (jar):4:13: 'lots' is not a number (limits.per-week); - strict mode checks the keys of every layer.
Mark fields as optional to avoid exceptions when missing:
public record FlexibleConfig(
String required,
@Options(optional = true) String optional,
@Options(optional = true) @DefaultString("fallback") String optionalWithDefault
) implements Loadable {}Structura supports all common collection types:
# collections.yml
servers:
- "server1.example.com"
- "server2.example.com"
ports:
- 8080
- 8443
- 9000
environment-vars:
JAVA_HOME: "/usr/lib/jvm/java-21"
PATH: "/usr/local/bin:/usr/bin"
database-configs:
- host: "primary.db"
port: 5432
- host: "secondary.db"
port: 5432public record ClusterConfig(
List<String> servers,
Set<Integer> ports,
Map<String, String> environmentVars,
List<DatabaseConfig> databaseConfigs
) implements Loadable {}Create enums that can be populated with configuration data:
public enum DatabaseType implements Loadable {
MYSQL, POSTGRESQL, MONGODB;
@Options(optional = true) public String driver;
@DefaultInt(0) public int defaultPort;
@Options(optional = true) public Map<String, String> properties;
}# database-types.yml
mysql:
driver: "com.mysql.cj.jdbc.Driver"
default-port: 3306
properties:
useSSL: "true"
serverTimezone: "UTC"
postgresql:
driver: "org.postgresql.Driver"
default-port: 5432
properties:
ssl: "false"// Populate enum constants
Structura.parseEnum(yamlContent, DatabaseType.class);
// Use populated enum
String mysqlDriver = DatabaseType.MYSQL.driver;
int postgresPort = DatabaseType.POSTGRESQL.defaultPort;Structura.schema(Class) describes what a record reads with Structura's own rules — keys as written in the
document, types, required keys, defaults, descriptions, polymorphic sub-types, list elements and map
values — so a documentation or an editor schema never reimplements the walk:
public record ShopSettings(
@Description("Items shown on one page") @DefaultInt(27) int maxItems,
@Options(optional = true, description = "Message of the day") String motd,
Reward reward
) implements Loadable {}
Schema schema = Structura.schema(ShopSettings.class);
for (Schema property : schema.properties()) {
property.key(); // "max-items"
property.shape(); // INTEGER, SECTION, LIST, MAP, POLYMORPHIC, ENUM, ...
property.required(); // false: it has a default
property.defaultValue(); // Optional[27]
property.description(); // Optional[Items shown on one page]
property.subtypes(); // for a polymorphic value: {"item": ..., "money": ...}
}
// JSON Schema for the YAML extension of your configurators' editor
Files.writeString(Path.of("shop.schema.json"), schema.toJsonSchema());The JSON Schema flags keys that no component reads, lists one branch per registered polymorphic sub-type,
and offers enum values in both cases. Polymorphic sub-types are taken from the registries, so register them
before asking for the schema (processor.schema(Class) uses the processor's own registries).
Structura automatically converts between Java camelCase and YAML kebab-case:
| Java Field | YAML Key |
|---|---|
appName |
app-name |
databaseUrl |
database-url |
enableHttps |
enable-https |
maxRetryCount |
max-retry-count |
Override this behavior with @Options(name = "custom-name").
The exact rule: a hyphen goes between a lowercase letter and the uppercase letter that follows it, then the whole name is lowercased. Nothing else splits a name — not a digit, not a run of capitals:
| Java Field | YAML Key |
|---|---|
httpURL |
http-url |
HTTPServer |
httpserver |
myHTTPServer |
my-httpserver |
level2Cap |
level2cap |
Enum constants are converted from snake_case: MYSQL_DATABASE → mysql-database.
Tools that must land on the same keys as Structura (a key checker, a schema generator) use the rule itself:
String key = Structura.keyOf(ShopSettings.class.getRecordComponents()[0]); // "max-items"
String constantKey = Structura.keyOf(DatabaseType.MYSQL_DATABASE); // "mysql-database"public record ApplicationConfig(
@DefaultString("MyApplication") String appName,
@DefaultString("1.0.0") String version,
ServerConfig server,
DatabaseConfig database, // Polymorphic interface
@Options(optional = true) List<String> features,
@Options(optional = true) Map<String, String> customProperties
) implements Loadable {}
public record ServerConfig(
@DefaultString("localhost") String host,
@DefaultInt(8080) int port,
@DefaultBool(false) boolean enableSsl,
@DefaultString("/") String contextPath
) implements Loadable {}
// DatabaseConfig is the polymorphic interface from examples above# application.yml
app-name: "Production Service"
version: "2.1.0"
server:
host: "0.0.0.0"
port: 9090
enable-ssl: true
context-path: "/api/v1"
database:
type: "postgresql" # Polymorphic resolution
host: "db.example.com"
port: 5432
database: "myapp"
username: "app_user"
password: "secure_password"
features:
- "feature-a"
- "feature-b"
- "experimental-feature"
custom-properties:
cache.ttl: "3600"
logging.level: "INFO"@Polymorphic(key = "provider")
public interface PaymentProvider extends Loadable {
String getName();
boolean processPayment(BigDecimal amount);
}
public record StripeProvider(
@DefaultString("Stripe") String name,
String apiKey,
@DefaultBool(false) boolean testMode
) implements PaymentProvider {
@Override public String getName() { return name; }
@Override public boolean processPayment(BigDecimal amount) { /* implementation */ }
}
public record PayPalProvider(
@DefaultString("PayPal") String name,
String clientId,
String clientSecret
) implements PaymentProvider {
@Override public String getName() { return name; }
@Override public boolean processPayment(BigDecimal amount) { /* implementation */ }
}
// Setup
PolymorphicRegistry.create(PaymentProvider.class, registry -> {
registry.register("stripe", StripeProvider.class);
registry.register("paypal", PayPalProvider.class);
});
// Configuration
public record PaymentConfig(
PaymentProvider primaryProvider,
List<PaymentProvider> fallbackProviders
) implements Loadable {}# payment.yml
primary-provider:
provider: "stripe"
api-key: "sk_live_..."
test-mode: false
fallback-providers:
- provider: "paypal"
client-id: "paypal_client_..."
client-secret: "paypal_secret_..."
- provider: "stripe"
api-key: "sk_test_..."
test-mode: true// Parse from string
<T extends Loadable> T parse(String yamlContent, Class<T> configClass)
// Load from file path
<T extends Loadable> T load(Path filePath, Class<T> configClass)
// Load from File object
<T extends Loadable> T load(File file, Class<T> configClass)
// Load from resources
<T extends Loadable> T loadFromResource(String resourcePath, Class<T> configClass)
// Parse enum configuration
<E extends Enum<E> & Loadable> void parseEnum(String yamlContent, Class<E> enumClass)
// Load enum from file
<E extends Enum<E> & Loadable> void loadEnum(Path filePath, Class<E> enumClass)
// Load enum from resources
<E extends Enum<E> & Loadable> void loadEnumFromResource(String resourcePath, Class<E> enumClass)
// --- Processor (Structura.builder()...build()) ---
// Name the source in error positions ("shop.yml:3:12: ...")
<T extends Loadable> T parse(String yamlContent, Class<T> configClass, String sourceName)
// --- Writing (requires the structura-writer module) ---
// Serialize a record to YAML and write it (creates parent dirs, overwrites)
void write(Path file, Loadable config)
// Build a default instance from @Default* annotations and write it
<T extends Loadable> void saveDefault(Path file, Class<T> configClass)
// Serialize every constant of a configurable enum to YAML
<E extends Enum<E> & Loadable> void writeEnum(Path file, Class<E> enumClass)
// Serialize every enum constant, filling null fields from @Default* annotations
<E extends Enum<E> & Loadable> void saveDefaultEnum(Path file, Class<E> enumClass)// Create and configure a registry
PolymorphicRegistry.create(InterfaceClass.class, registry -> {
registry.register("key", ImplementationClass.class);
registry.register(AutoNamedClass.class); // Uses lowercased class name
});
// Retrieve an existing registry
PolymorphicRegistry<InterfaceClass> registry = PolymorphicRegistry.get(InterfaceClass.class);
// Check available implementations
Set<String> availableTypes = registry.availableNames();
Optional<Class<? extends InterfaceClass>> impl = registry.get("key");CustomReaderRegistry registry = CustomReaderRegistry.getInstance();
// Register with Class (for non-generic types)
<T> void register(Class<T> targetClass, Reader<T> reader)
// Register with TypeToken (for generic types)
<T> void register(TypeToken<T> typeToken, Reader<T> reader)
// Check if reader exists
boolean hasReader(Class<?> targetClass)
boolean hasReader(TypeToken<?> typeToken)
// Unregister
boolean unregister(Class<?> targetClass)
boolean unregister(TypeToken<?> typeToken)
// Utility
void clear()
int size()Provided by the
structura-writermodule.
CustomWriterRegistry registry = CustomWriterRegistry.getInstance();
// Register a writer for a type
<T> void register(Class<T> type, Writer<T> writer)
// Check / remove
boolean hasWriter(Class<?> type)
boolean unregister(Class<?> type)
// Utility
void clear()// Create TypeToken for generic types (requires {})
TypeToken<List<String>> token = new TypeToken<List<String>>() {};
// Factory method from Class
TypeToken<String> token = TypeToken.of(String.class);
// Factory method from Type
Type type = ... // from reflection
TypeToken<?> token = TypeToken.of(type);
// Get type information
Type getType()
Class<? super T> getRawType()@Polymorphic(
key = "type", // Default: "type" - discriminator field name
inline = false, // Default: false - discriminator inside field value
// Set to true to place discriminator at parent level
useKey = false // Default: false - when true, the discriminator value
// becomes the YAML key instead of a separate field;
// with inline = true, the sub-type is the registered key present
)@Options(
name = "custom-field-name", // Override field name
optional = true, // Mark as optional
isKey = true, // Use for key-based mapping
inline = false // Default: false - flatten record fields to parent level;
// on a Map, absorb every key no sibling claims
)@DefaultString("default value")
@DefaultInt(42)
@DefaultLong(1000L)
@DefaultDouble(3.14)
@DefaultBool(true)
@DefaultEmpty // an absent List, Set, Collection or Map is emptyA record can be generic: a component whose type is a type variable is read as its bound
(record Menu<S extends Surface>(String id, @Options(inline = true) S surface) reads surface as a Surface).
Every error raised while reading a document says where: the path of the key, its line and column, the file it comes from, and a stable kind.
config.yml:3:12: 'many' is not a number (limits.per-day)
config.yml:8:12: 'cheap' is not a number (offers[1].price)
config.yml:6:5: 'symbol' is required but not provided (currencies.gems.symbol)
try {
AppConfig config = Structura.load(configPath, AppConfig.class);
} catch (StructuraException e) {
logger.error(e.getMessage()); // config.yml:3:12: 'many' is not a number (limits.per-day)
e.kind(); // UNKNOWN_KEY, MISSING, WRONG_TYPE, INVALID, READER, SYNTAX (OTHER for API misuse)
e.path(); // Optional[limits.per-day]
e.line(); // OptionalInt[3] (1-based)
e.column(); // OptionalInt[12] (1-based)
e.source(); // Optional[config.yml]
e.detail(); // 'many' is not a number
}The kind is stable and meant for tools; the wording of a message may improve. An error raised outside of a document (a registry misuse, a direct call to the validator) keeps its plain message.
By default a read stops at the first error. A processor built with collectErrors(true) carries on and
reports every error of the document together, so a whole file is fixed in one go:
StructuraProcessor processor = Structura.builder().collectErrors(true).build();
try {
processor.parse(yaml, ShopSettings.class, "shop.yml");
} catch (StructuraErrors e) { // extends StructuraException
e.errors().forEach(error -> logger.error(error.getMessage()));
}3 errors in shop.yml:
shop.yml:3:12: 'many' is not a number (limits.per-day)
shop.yml:8:5: 'item' is required but not provided (offers[1].item)
shop.yml:12:8: 'lots' is not a number (slots)
Structura ignores keys that no component reads, so a typo such as max-item: 54 goes unnoticed and the
default value applies. In strict mode an unknown key is an error, with the closest known key suggested:
StructuraProcessor processor = Structura.builder().strictKeys(true).build();2:1: unknown key 'max-item' (did you mean 'max-items'?)
3:3: unknown key 'per-dya' under 'limits' (did you mean 'per-day'?)
Structura knows exactly which keys each record reads: inline components, the isKey component,
@Options(name), polymorphic discriminators and the keys of the selected sub-type, map values and list
elements. The keys of a Map stay free, and an inline Map absorbs every remaining key.
Configurable enums are checked too: a key that is no constant, and a key of a constant that none of its
fields reads, are both errors (unknown key 'gold-pluss' (did you mean 'gold-plus'?)).
A single record (and everything it reads), or an enum, can be made strict without the option:
@Strict
public record ShopSettings(int maxItems, Limits limits) implements Loadable {}Validation descends into every record held by a list or a set, a map value or a polymorphic component, at
any depth: a @Min on the price of each element of a List<Offer> is enforced.
5:12: Value must be at least 1 (offers[1].price)
8:13: Value cannot be empty (currencies.gems.symbol)
To validate only the components declared as records, as before 2.8.0:
StructuraProcessor processor = Structura.builder().validateNested(false).build();Structura is extensively tested with JUnit 5. Run tests with:
./gradlew test- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Java 21 or higher
- Gradle 8.10 or higher (the project builds with the Gradle 9.5.1 wrapper)
- SnakeYAML 2.6 (pulled in transitively for YAML parsing)