This tutorial walks you through creating a complete data migration system with schemas, fixes, and best practices.
You're building a game that saves player data. Over time, the data format evolves:
Version 1.0.0 (ID: 100) - Initial release
{
"playerName": "Steve",
"xp": 1500,
"x": 100.5,
"y": 64.0,
"z": -200.25,
"gameMode": 0
}Version 1.1.0 (ID: 110) - Restructured
{
"name": "Steve",
"experience": 1500,
"position": {
"x": 100.5,
"y": 64.0,
"z": -200.25
},
"gameMode": "survival"
}We'll build the migration from v1.0.0 to v1.1.0.
Create a central class for all type identifiers:
package com.example.game;
import de.splatgames.aether.datafixers.api.TypeReference;
/**
* Type references for all data types in the game.
*/
public final class TypeReferences {
/** Player save data */
public static final TypeReference PLAYER = new TypeReference("player");
/** World/level data */
public static final TypeReference WORLD = new TypeReference("world");
private TypeReferences() {} // Prevent instantiation
}Define the data structure at version 100:
package com.example.game.schema;
import de.splatgames.aether.datafixers.api.dsl.DSL;
import de.splatgames.aether.datafixers.api.schema.Schema;
import de.splatgames.aether.datafixers.api.type.TypeRegistry;
import de.splatgames.aether.datafixers.api.type.template.TypeTemplate;
import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry;
import com.example.game.TypeReferences;
/**
* Schema for Version 1.0.0 (ID: 100)
*
* Player structure:
* - playerName: string
* - xp: int
* - x, y, z: double (flat coordinates)
* - gameMode: int (0=survival, 1=creative, etc.)
*/
public class Schema100 extends Schema {
public Schema100() {
super(100, null); // No parent - this is the first version
}
@Override
protected TypeRegistry createTypeRegistry() {
return new SimpleTypeRegistry();
}
@Override
protected void registerTypes() {
registerType(TypeReferences.PLAYER, player());
}
/** Player type template for v1.0.0 */
public static TypeTemplate player() {
return DSL.and(
DSL.field("playerName", DSL.string()),
DSL.field("xp", DSL.intType()),
DSL.field("x", DSL.doubleType()),
DSL.field("y", DSL.doubleType()),
DSL.field("z", DSL.doubleType()),
DSL.field("gameMode", DSL.intType()),
DSL.remainder()
);
}
}Define the updated structure:
package com.example.game.schema;
import de.splatgames.aether.datafixers.api.dsl.DSL;
import de.splatgames.aether.datafixers.api.schema.Schema;
import de.splatgames.aether.datafixers.api.type.TypeRegistry;
import de.splatgames.aether.datafixers.api.type.template.TypeTemplate;
import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry;
import com.example.game.TypeReferences;
/**
* Schema for Version 1.1.0 (ID: 110)
*
* Changes from 100:
* - playerName → name
* - xp → experience
* - x, y, z → nested position object
* - gameMode: int → string
*/
public class Schema110 extends Schema {
public Schema110() {
super(110, new Schema100()); // Extends from v1.0.0
}
@Override
protected TypeRegistry createTypeRegistry() {
return new SimpleTypeRegistry();
}
@Override
protected void registerTypes() {
registerType(TypeReferences.PLAYER, player());
}
/** Player type template for v1.1.0 */
public static TypeTemplate player() {
return DSL.and(
DSL.field("name", DSL.string()),
DSL.field("experience", DSL.intType()),
DSL.field("position", position()),
DSL.field("gameMode", DSL.string()),
DSL.remainder()
);
}
/** Position type template */
public static TypeTemplate position() {
return DSL.and(
DSL.field("x", DSL.doubleType()),
DSL.field("y", DSL.doubleType()),
DSL.field("z", DSL.doubleType())
);
}
}Implement the migration logic:
package com.example.game.fix;
import de.splatgames.aether.datafixers.api.DataVersion;
import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
import de.splatgames.aether.datafixers.api.rewrite.Rules;
import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule;
import de.splatgames.aether.datafixers.api.schema.Schema;
import de.splatgames.aether.datafixers.api.schema.SchemaRegistry;
import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
import de.splatgames.aether.datafixers.core.fix.SchemaDataFix;
import org.jetbrains.annotations.NotNull;
/**
* Migrates player data from v1.0.0 (100) to v1.1.0 (110).
*/
public class PlayerV100ToV110Fix extends SchemaDataFix {
public PlayerV100ToV110Fix(SchemaRegistry schemas) {
super(
"player_v100_to_v110",
new DataVersion(100),
new DataVersion(110),
schemas
);
}
@Override
@NotNull
protected TypeRewriteRule makeRule(@NotNull Schema inputSchema,
@NotNull Schema outputSchema) {
return Rules.seq(
// 1. Rename fields
Rules.renameField(GsonOps.INSTANCE, "playerName", "name"),
Rules.renameField(GsonOps.INSTANCE, "xp", "experience"),
// 2. Transform gameMode from int to string
Rules.transformField(GsonOps.INSTANCE, "gameMode",
PlayerV100ToV110Fix::gameModeToString),
// 3. Group coordinates into position object
Rules.groupFields(GsonOps.INSTANCE, "position", "x", "y", "z")
);
}
@NotNull
private static Dynamic<?> gameModeToString(@NotNull Dynamic<?> value) {
int mode = value.asInt().result().orElse(0);
String modeName = switch (mode) {
case 0 -> "survival";
case 1 -> "creative";
case 2 -> "adventure";
case 3 -> "spectator";
default -> "survival";
};
return value.createString(modeName);
}
}Wire schemas and fixes together:
package com.example.game;
import de.splatgames.aether.datafixers.api.DataVersion;
import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap;
import de.splatgames.aether.datafixers.api.fix.FixRegistrar;
import de.splatgames.aether.datafixers.api.schema.SchemaRegistry;
import com.example.game.fix.PlayerV1ToV2Fix;
import com.example.game.schema.Schema100;
import com.example.game.schema.Schema110;
import org.jetbrains.annotations.NotNull;
/**
* Bootstrap for the game data fixer.
*/
public class GameDataBootstrap implements DataFixerBootstrap {
/** Current (latest) version */
public static final DataVersion CURRENT_VERSION = new DataVersion(110);
private SchemaRegistry schemas;
@Override
public void registerSchemas(@NotNull SchemaRegistry schemas) {
this.schemas = schemas;
// Register schemas in version order
schemas.register(new Schema100());
schemas.register(new Schema110());
}
@Override
public void registerFixes(@NotNull FixRegistrar fixes) {
// Register fixes
fixes.register(TypeReferences.PLAYER, new PlayerV100ToV110Fix(schemas));
}
}package com.example.game;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import de.splatgames.aether.datafixers.api.DataVersion;
import de.splatgames.aether.datafixers.api.dynamic.Dynamic;
import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic;
import de.splatgames.aether.datafixers.codec.json.gson.GsonOps;
import de.splatgames.aether.datafixers.core.AetherDataFixer;
import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory;
public class GameExample {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
public static void main(String[] args) {
// 1. Create the data fixer
AetherDataFixer fixer = new DataFixerRuntimeFactory()
.create(GameDataBootstrap.CURRENT_VERSION, new GameDataBootstrap());
// 2. Simulate old v1.0.0 save data
JsonObject oldSave = new JsonObject();
oldSave.addProperty("playerName", "Steve");
oldSave.addProperty("xp", 1500);
oldSave.addProperty("x", 100.5);
oldSave.addProperty("y", 64.0);
oldSave.addProperty("z", -200.25);
oldSave.addProperty("gameMode", 0);
System.out.println("=== Old Data (v1.0.0) ===");
System.out.println(GSON.toJson(oldSave));
// 3. Wrap in Dynamic
Dynamic<JsonElement> dynamic = new Dynamic<>(GsonOps.INSTANCE, oldSave);
TaggedDynamic tagged = new TaggedDynamic(TypeReferences.PLAYER, dynamic);
// 4. Migrate from v1.0.0 to v1.1.0
TaggedDynamic migrated = fixer.update(
tagged,
new DataVersion(100),
fixer.currentVersion()
);
// 5. Print result
System.out.println("\n=== Migrated Data (v1.1.0) ===");
Dynamic<JsonElement> result = (Dynamic<JsonElement>) migrated.value();
System.out.println(GSON.toJson(result.value()));
}
}=== Old Data (v1.0.0) ===
{
"playerName": "Steve",
"xp": 1500,
"x": 100.5,
"y": 64.0,
"z": -200.25,
"gameMode": 0
}
=== Migrated Data (v1.1.0) ===
{
"name": "Steve",
"experience": 1500,
"position": {
"x": 100.5,
"y": 64.0,
"z": -200.25
},
"gameMode": "survival"
}
Use a consistent scheme. Recommended: SemVer encoded as integers.
| SemVer | ID |
|---|---|
| 1.0.0 | 100 |
| 1.1.0 | 110 |
| 2.0.0 | 200 |
Create separate fix classes for each migration step:
PlayerV1ToV2Fix(100 → 110)PlayerV2ToV3Fix(110 → 200)
Define all type references in one class for easy discovery.
Each schema creates its own parent internally: Schema110 extends Schema100 via super(110, new Schema100()).
Write unit tests for each fix with sample data.
Congratulations! You've built your first complete migration system.
Continue learning:
- Schema System - Deep dive into schemas
- DataFix System - Understanding fixes
- Rewrite Rules - Rule combinators
- Multi-Version Migration - Chain multiple fixes