Skip to content
Traqueur-devPublic

About

Type-safe YAML configuration library for Java 21+ with automatic type conversion, polymorphic interfaces, and validation using records and annotations

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

Structura 🏗️

A modern, type-safe YAML configuration library for Java that leverages records and annotations for seamless configuration management.

✨ Features

  • 🎯 Type-safe: Compile-time safety with Java records
  • 🔧 Annotation-driven: Flexible configuration with @Options and 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 a Map, 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-writer module (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 a structura-bom; SnakeYAML is the only runtime dependency

🚀 Quick Start

Installation

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")
}

Basic Usage

  1. 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 {
}
  1. 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"
  1. 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());

📚 Advanced Features

Default Values

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.

Custom Field Names

Override automatic kebab-case conversion:

public record CustomConfig(
    @Options(name = "app-name") String applicationName,
    @Options(name = "db-config") DatabaseConfig databaseConfiguration
) implements Loadable {}

Inline Fields (Field Flattening)

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: 8080

With 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 level

Multiple 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 database

Both 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

Catch-all maps

@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"                    # ─┴ byLocale

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

Inline on an enum field

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.

Key-based Mapping

Use @Options(isKey = true) to create flexible YAML structures where keys become field values or where complex objects can be flattened.

Simple Key Mapping

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: 5432
DatabaseConnection config = Structura.parse(yaml, DatabaseConnection.class);
// config.name() returns "production"
// config.host() returns "prod.db.example.com"
// config.port() returns 5432

Complex Object Flattening

For 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: true
AppConfig 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 true

Polymorphic Interfaces 🎭

NEW! 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.

Setting Up Polymorphic Interfaces

  1. Define your polymorphic interface:
@Polymorphic(key = "type")  // Field name that determines the implementation
public interface DatabaseConfig extends Loadable {
    String getHost();
    int getPort();
}
  1. 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; }
}
  1. 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);
});
  1. Use in your configuration:
public record AppConfig(
    String appName,
    DatabaseConfig database,              // Polymorphic interface
    List<DatabaseConfig> backupDatabases  // Collections work too!
) implements Loadable {}

YAML Configuration

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"

Automatic Type Resolution

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);

Advanced Polymorphic Features

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: localhost

With 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: 3306

This 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-1

Discriminator 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: VEX

For 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

The sub-type is the key present (useKey + inline)

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-players for Toggle(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.

Use Cases

  • 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

Custom Readers 📖

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.

Basic Custom Readers

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 Components

Generic Types with TypeToken

For 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>>()

Common Use Cases

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}

Complete Example with Adventure API

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>"

Priority and Fallback

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

Best Practices

  1. Register once: Register all readers at application startup

  2. Thread-safe: CustomReaderRegistry is thread-safe

  3. 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);
        }
    });
  4. Type safety: Always use specific types in TypeToken, not wildcards:

    // ✅ Good
    new TypeToken<List<String>>() {}
    
    // ❌ Avoid
    new TypeToken<List<?>>() {}

Contextual and generic readers

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.

Registries per processor

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 — External Object References

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.

When to use it

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.

1. Register a provider at startup

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
);

2. Declare Reference<T> in your record

import fr.traqueur.structura.references.Reference;

public record GameConfig(
    Reference<Arena> arena,   // YAML value: "my-arena-id"
    int maxPlayers
) implements Loadable {}

3. Write your YAML

arena: "my-arena-id"
max-players: 20

4. Parse and use

GameConfig config = Structura.parse(yaml, GameConfig.class);

String key  = config.arena().key();      // "my-arena-id"
Arena arena = config.arena().element();  // live Arena object from the manager

Lazy resolution

Reference#element() resolves from the live provider collection at call time. This means:

  • Objects added to the collection after parsing are still resolvable.
  • A stale Reference can reflect updates if the collection is mutable.
  • StructuraException is thrown at element() call time (not parse time) if the key is not found.

Error handling

// 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'"
}

Using @DefaultReference with Reference<T>

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.

Writing Configuration (Serialization) ✍️

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-writer module is not on the classpath, calling these methods throws a StructuraException explaining how to add it. The core module stays write-free.

Writing a record to a file

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);

Generating default config files

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 use write() 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);
}

What the serializer handles

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 a Map — 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 as null)
  • @Options(isKey = true) — both simple (key becomes the map key) and complex (key sub-record flattened)
  • @Polymorphic in all modes — standard, inline = true, fully inline, and useKey = true
  • LocalDate / 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

Rewriting a file a person wrote

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: 20

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

Writing only what differs from the default

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

Custom Writers

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);

Layered Sources (defaults + overrides)

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.

Optional Fields

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 {}

Collections

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: 5432
public record ClusterConfig(
    List<String> servers,
    Set<Integer> ports,
    Map<String, String> environmentVars,
    List<DatabaseConfig> databaseConfigs
) implements Loadable {}

Configuration Enums

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;

Schema (editor autocompletion & documentation) 🗺️

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

🎯 Field Name Mapping

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"

🔧 Configuration Examples

Complete Application Configuration

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"

Plugin System Example

@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

🎮 API Reference

Main API Class: Structura

// 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)

Polymorphic Registry API

// 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");

Custom Reader Registry API

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()

Custom Writer Registry API

Provided by the structura-writer module.

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()

TypeToken API

// 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()

Annotations

@Polymorphic

@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

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

Default Value Annotations

@DefaultString("default value")
@DefaultInt(42)
@DefaultLong(1000L)
@DefaultDouble(3.14)
@DefaultBool(true)
@DefaultEmpty            // an absent List, Set, Collection or Map is empty

A 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).

🚨 Error Handling

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.

All errors at once

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)

Strict keys

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 {}

Validating nested records

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();

🧪 Testing

Structura is extensively tested with JUnit 5. Run tests with:

./gradlew test

🤝 Contributing

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

📋 Requirements

  • 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)

About

Type-safe YAML configuration library for Java 21+ with automatic type conversion, polymorphic interfaces, and validation using records and annotations

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages