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 {}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
- 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<?>>() {}
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
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.
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 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").
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)
// --- 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
)@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)Structura provides clear error messages for common configuration issues:
try {
AppConfig config = Structura.load(configPath, AppConfig.class);
} catch (StructuraException e) {
// Handle configuration errors
logger.error("Configuration error: " + e.getMessage());
}Common error scenarios:
- Missing required fields
- Type conversion failures
- Invalid YAML syntax
- File not found
- Invalid enum values
- Unknown polymorphic types with available options listed
- Missing polymorphic type keys
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)