Have you ever shipped a new version of your application, only to realize your users' saved data is now incompatible? 😱 Or found yourself writing brittle migration scripts that break when the data format changes unexpectedly?
Data schema evolution is one of the most underestimated challenges in software development. Whether you're building a game with save files, a desktop application with user configurations, or a service that stores structured data — at some point, your data format will change.
Today, I'm excited to introduce Aether Datafixers 🔧 — a lightweight, type-safe data migration framework for the JVM that makes evolving your data schemas painless.
// Migrate player data from v1.0.0 to v2.0.0 in one line
TaggedDynamic migrated = fixer.update(oldData, fromVersion, toVersion);
TL;DR: Aether Datafixers lets you define versioned schemas and sequential fixes that automatically migrate your data forward. It's format-agnostic (works with JSON, Jackson, or custom formats) and inspired by Minecraft's DataFixer Upper — but much simpler to use.
🔧 What is Aether Datafixers?
Aether Datafixers is an open-source Java library that implements the forward patching pattern for data migrations. Instead of writing complex bidirectional converters, you define a chain of sequential fixes that transform data from old versions to new ones.
The Core Philosophy
v1.0.0 → v1.1.0 → v2.0.0 → v2.1.0
Fix₁ Fix₂ Fix₃
Each version has a Schema that defines the data structure. Each transition has a DataFix that transforms the data. When you need to migrate from v1.0.0 to v2.1.0, the framework automatically chains Fix₁ → Fix₂ → Fix₃.
Why Forward Patching?
- ✅ Simpler reasoning: Each fix only needs to know about two versions
- ✅ Composable: Fixes chain automatically, no manual coordination
- ✅ Maintainable: Adding a new version means adding one new fix
- ✅ Testable: Each fix can be tested in isolation
🎮 Inspired by Minecraft
If you've ever wondered how Minecraft handles world saves from 2011 that still load in 2024 — the answer is DataFixer Upper (DFU). Aether Datafixers takes the same battle-tested approach but with:
- Cleaner API: No deep type theory knowledge required
- Better documentation: 75+ pages of guides and examples
- Focused scope: Just data migration, no kitchen sink
📦 Module Overview
| Module | Purpose |
|---|---|
aether-datafixers-api |
Core interfaces — no implementation logic |
aether-datafixers-core |
Default implementations |
aether-datafixers-codec |
GsonOps, JacksonOps for JSON handling |
aether-datafixers-examples |
Complete runnable examples |
aether-datafixers-bom |
Bill of Materials for version management |
🚀 Quick Start: Your First Migration in 5 Minutes
Let's build a simple migration that renames a field. We'll take player data and rename playerName to name.
Step 1: Add the Dependency
Maven:
<dependency>
<groupId>de.splatgames.aether</groupId>
<artifactId>aether-datafixers-core</artifactId>
<version>0.1.0</version>
</dependency>
Gradle (Kotlin):
implementation("de.splatgames.aether:aether-datafixers-core:0.1.0")
Use the BOM (
aether-datafixers-bom) for coordinated version management across all modules.
Step 2: Define a TypeReference
TypeReferences identify your data types for routing:
public final class TypeReferences {
public static final TypeReference PLAYER = new TypeReference("player");
private TypeReferences() {}
}
Step 3: Create Your DataFix
public class RenamePlayerNameFix extends SchemaDataFix {
public RenamePlayerNameFix(SchemaRegistry schemas) {
super(
"rename_player_name", // Fix identifier
new DataVersion(100), // From version
new DataVersion(110), // To version
schemas
);
}
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
return Rules.renameField(GsonOps.INSTANCE, "playerName", "name");
}
}
Step 4: Wire It Together with a Bootstrap
public class MyBootstrap implements DataFixerBootstrap {
public static final DataVersion CURRENT_VERSION = new DataVersion(110);
private SchemaRegistry schemas;
@Override
public void registerSchemas(SchemaRegistry schemas) {
this.schemas = schemas;
schemas.register(new Schema100()); // v1.0.0
schemas.register(new Schema110()); // v1.1.0
}
@Override
public void registerFixes(FixRegistrar fixes) {
fixes.register(TypeReferences.PLAYER, new RenamePlayerNameFix(schemas));
}
}
Step 5: Use It!
// Create the DataFixer
AetherDataFixer fixer = new DataFixerRuntimeFactory()
.create(MyBootstrap.CURRENT_VERSION, new MyBootstrap());
// Your old data
JsonObject oldData = new JsonObject();
oldData.addProperty("playerName", "Steve");
oldData.addProperty("score", 100);
// Wrap in Dynamic
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, oldData);
TaggedDynamic tagged = new TaggedDynamic(TypeReferences.PLAYER, dynamic);
// Migrate!
TaggedDynamic result = fixer.update(tagged, new DataVersion(100), new DataVersion(110));
Before:
{ "playerName": "Steve", "score": 100 }
After:
{ "name": "Steve", "score": 100 }
That's it! 🎉 Your data is migrated, and any unknown fields (like score) are preserved automatically.
🧩 Core Concepts
Let's dive deeper into the building blocks of Aether Datafixers.
TypeReference: Routing Your Data
A TypeReference is a string-based identifier that routes data to the correct schemas and fixes:
public static final TypeReference PLAYER = new TypeReference("player");
public static final TypeReference WORLD = new TypeReference("world");
public static final TypeReference INVENTORY = new TypeReference("inventory");
When you call fixer.update(), the framework uses the TypeReference to find which fixes apply.
DataVersion: Versioning Strategy
DataVersion is a simple integer-based version identifier. You can use any numbering scheme:
// Sequential
new DataVersion(1);
new DataVersion(2);
new DataVersion(3);
// SemVer-inspired (recommended)
new DataVersion(100); // v1.0.0
new DataVersion(110); // v1.1.0
new DataVersion(200); // v2.0.0
The SemVer-inspired approach (100, 110, 200) leaves room for patch versions and makes version relationships clearer at a glance.
Schema: Defining Data Structure
Each version has a Schema that defines what types exist at that version:
public class Schema100 extends Schema {
public Schema100() {
super(100, null); // No parent (first version)
}
@Override
protected void registerTypes() {
registerType(new SimpleType<>(
TypeReferences.PLAYER,
dynamicPassthroughCodec()
));
}
}
Schemas support inheritance — a child schema only needs to register types that changed:
public class Schema110 extends Schema {
public Schema110() {
super(110, new Schema100()); // Inherits from v1.0.0
}
@Override
protected void registerTypes() {
// Only register changed types
// Unchanged types are inherited from parent
}
}
DataFix: The Migration Logic
A DataFix transforms data from one version to the next. Extend SchemaDataFix for access to input/output schemas:
public class MyFix extends SchemaDataFix {
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
return Rules.seq(
Rules.renameField(ops, "oldName", "newName"),
Rules.transformField(ops, "gameMode", this::convertGameMode),
Rules.addField(ops, "newField", defaultValue)
);
}
}
Dynamic: Format-Agnostic Data Wrapper
The Dynamic<T> class wraps your data and provides format-agnostic operations:
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, jsonObject);
// Read
String name = dynamic.get("name").asString().orElse("Unknown");
int level = dynamic.get("level").asInt().orElse(1);
// Write (returns new instance — immutable!)
Dynamic<JsonElement> updated = dynamic
.set("name", dynamic.createString("NewName"))
.set("level", dynamic.createInt(5))
.remove("obsoleteField");
All operations return new Dynamic instances. The original is never modified.
DynamicOps: The Format Adapter
DynamicOps<T> is an interface that defines how to work with a specific format:
// For Gson
DynamicOps<JsonElement> gsonOps = GsonOps.INSTANCE;
// For Jackson
DynamicOps<JsonNode> jacksonOps = JacksonOps.INSTANCE;
// Same migration code works with both!
🎮 Complete Example: Game Save Data Migration
Let's build a real-world example: migrating game player data through three versions.
The Scenario
| Version | ID | Structure |
|---|---|---|
| v1.0.0 | 100 | Flat structure: playerName, xp, x, y, z, gameMode (int) |
| v1.1.0 | 110 | Renamed fields, nested position object, gameMode as string |
| v2.0.0 | 200 | Added health, maxHealth, computed level from experience
|
Version 1.0.0 Data
{
"playerName": "Steve",
"xp": 2500,
"x": 100.5,
"y": 64.0,
"z": -200.25,
"gameMode": 0
}
Version 2.0.0 Data (Target)
{
"name": "Steve",
"experience": 2500,
"level": 5,
"health": 20.0,
"maxHealth": 20.0,
"position": {
"x": 100.5,
"y": 64.0,
"z": -200.25
},
"gameMode": "survival"
}
Fix 1: v1.0.0 → v1.1.0
This fix handles field renaming, type conversion, and restructuring:
public final class PlayerV1ToV2Fix extends SchemaDataFix {
public PlayerV1ToV2Fix(SchemaRegistry schemas) {
super("player_v100_to_v110", new DataVersion(100), new DataVersion(110), schemas);
}
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
return Rules.seq(
// Rename fields
Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
Rules.renameField(GsonOps.INSTANCE, "xp", "experience"),
// Convert gameMode from int to string
Rules.transformField(GsonOps.INSTANCE, "gameMode", this::gameModeToString),
// Group x/y/z into position object
groupPositionFields()
);
}
private Dynamic<?> gameModeToString(Dynamic<?> dynamic) {
int mode = dynamic.asInt().result().orElse(0);
String modeString = switch (mode) {
case 1 -> "creative";
case 2 -> "adventure";
case 3 -> "spectator";
default -> "survival";
};
return dynamic.createString(modeString);
}
private TypeRewriteRule groupPositionFields() {
return Rules.dynamicTransform("groupPosition", dynamic -> {
double x = dynamic.get("x").asDouble().result().orElse(0.0);
double y = dynamic.get("y").asDouble().result().orElse(0.0);
double z = dynamic.get("z").asDouble().result().orElse(0.0);
Dynamic<Object> position = dynamic.emptyMap()
.set("x", dynamic.createDouble(x))
.set("y", dynamic.createDouble(y))
.set("z", dynamic.createDouble(z));
return dynamic
.remove("x").remove("y").remove("z")
.set("position", position);
});
}
}
After Fix 1:
{
"name": "Steve",
"experience": 2500,
"position": { "x": 100.5, "y": 64.0, "z": -200.25 },
"gameMode": "survival"
}
Fix 2: v1.1.0 → v2.0.0
This fix adds new fields and computes derived values:
public final class PlayerV2ToV3Fix extends SchemaDataFix {
public PlayerV2ToV3Fix(SchemaRegistry schemas) {
super("player_v110_to_v200", new DataVersion(110), new DataVersion(200), schemas);
}
@Override
protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) {
return Rules.seq(
// Add health fields with defaults
Rules.addField(GsonOps.INSTANCE, "health",
new Dynamic<>(GsonOps.INSTANCE, new JsonPrimitive(20.0f))),
Rules.addField(GsonOps.INSTANCE, "maxHealth",
new Dynamic<>(GsonOps.INSTANCE, new JsonPrimitive(20.0f))),
// Compute level from experience
computeLevel()
);
}
private TypeRewriteRule computeLevel() {
return Rules.dynamicTransform("computeLevel", dynamic -> {
int experience = dynamic.get("experience").asInt().result().orElse(0);
// Level = sqrt(experience / 100), minimum 1
int level = Math.max(1, (int) Math.sqrt(experience / 100.0));
return dynamic.set("level", dynamic.createInt(level));
});
}
}
After Fix 2 (Final v2.0.0):
{
"name": "Steve",
"experience": 2500,
"level": 5,
"health": 20.0,
"maxHealth": 20.0,
"position": { "x": 100.5, "y": 64.0, "z": -200.25 },
"gameMode": "survival"
}
The Bootstrap
public final class GameDataBootstrap implements DataFixerBootstrap {
public static final DataVersion CURRENT_VERSION = new DataVersion(200);
private SchemaRegistry schemas;
@Override
public void registerSchemas(SchemaRegistry schemas) {
this.schemas = schemas;
schemas.register(new Schema100());
schemas.register(new Schema110());
schemas.register(new Schema200());
}
@Override
public void registerFixes(FixRegistrar fixes) {
fixes.register(TypeReferences.PLAYER, new PlayerV1ToV2Fix(schemas));
fixes.register(TypeReferences.PLAYER, new PlayerV2ToV3Fix(schemas));
}
}
Running the Migration
public static void main(String[] args) {
// Create the fixer
AetherDataFixer fixer = new DataFixerRuntimeFactory()
.create(GameDataBootstrap.CURRENT_VERSION, new GameDataBootstrap());
// Load old v1.0.0 save data
JsonObject v1Data = loadSaveFile("player.json");
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, v1Data);
TaggedDynamic tagged = new TaggedDynamic(TypeReferences.PLAYER, dynamic);
// Migrate to current version
TaggedDynamic result = fixer.update(
tagged,
new DataVersion(100), // From v1.0.0
fixer.currentVersion() // To v2.0.0
);
// The framework automatically chains: Fix1 → Fix2
saveMigratedData(result);
}
You can run this example yourself! Clone the repository and execute:
mvn exec:java -pl aether-datafixers-examples
🔄 Format Agnosticism: One Migration, Any Format
One of the most powerful features of Aether Datafixers is format agnosticism. The same migration code works with any serialization format.
Using Gson
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, jsonElement);
Using Jackson
Dynamic<JsonNode> dynamic = new Dynamic<>(JacksonOps.INSTANCE, jsonNode);
Same Operations, Different Formats
// This code works identically with Gson OR Jackson
String name = dynamic.get("name").asString().orElse("Unknown");
Dynamic<?> updated = dynamic.set("level", dynamic.createInt(5));
Custom Formats
Need to support YAML, TOML, or a binary format? Implement DynamicOps<T>:
public class YamlOps implements DynamicOps<YamlNode> {
@Override
public YamlNode createString(String value) { /* ... */ }
@Override
public YamlNode createInt(int value) { /* ... */ }
@Override
public DataResult<String> getStringValue(YamlNode input) { /* ... */ }
// ... other operations
}
Once implemented, all your existing migrations work with the new format automatically.
💡 Why Choose Aether Datafixers?
vs. Manual Migrations
| Aspect | Manual | Aether Datafixers |
|---|---|---|
| Version chaining | Manual coordination | Automatic |
| Format support | One format per migration | Any format |
| Type safety | Runtime errors | Compile-time checks |
| Unknown fields | Often lost | Preserved by default |
| Testing | Complex setup | Each fix testable in isolation |
vs. Minecraft's DFU
| Aspect | DFU | Aether Datafixers |
|---|---|---|
| Learning curve | Steep | Moderate |
| Documentation | Limited | Comprehensive (75+ pages) |
| Dependencies | Heavy | Minimal |
| Use case | Minecraft-specific | General purpose |
| API clarity | Complex type system | Clean inheritance |
✨ Key Advantages
- 🔒 Immutable by design: All
Dynamicoperations return new instances — thread-safe by default - 🔗 Composable rules: Chain multiple transformations with
Rules.seq() - 🛡️ Fail-safe:
DataResultprovides explicit error handling without exceptions - 🧱 Extensible: Custom
DynamicOps, custom rules, custom everything - 🎯 Well-tested: Built on patterns proven in one of the world's most-played games
📚 Getting Started
🔗 Links
- GitHub: github.com/aether-framework/aether-datafixers
-
Maven Central:
de.splatgames.aether:aether-datafixers-core:0.1.0
Quick Install
<dependency>
<groupId>de.splatgames.aether</groupId>
<artifactId>aether-datafixers-core</artifactId>
<version>0.1.0</version>
</dependency>
🗺️ What's Next?
- v0.2.0: Additional codec implementations, extended rewrite rules, performance optimizations
- v1.0.0: Stable API surface, production-ready release
🎉 Wrapping Up
Data migration doesn't have to be painful. With Aether Datafixers, you get:
- ✅ Forward patching that scales with your application
- ✅ Format agnosticism that future-proofs your code
- ✅ Type safety that catches mistakes at compile time
- ✅ Battle-tested patterns from one of gaming's most successful franchises
If you're building any application that persists structured data, give Aether Datafixers a try. Your future self (and your users) will thank you. 🚀
Aether Datafixers is MIT licensed and open for contributions. Found a bug? Have a feature request? Open an issue on GitHub!
Top comments (0)