This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new f710352b6 feat(java): enhance ForyField annotation with tag ID support 
for optimized serialization (#3021)
f710352b6 is described below

commit f710352b6f45d82fca1eba69ef25cb9488570f9d
Author: Mikhail Chernyakov <[email protected]>
AuthorDate: Sat Dec 20 08:35:52 2025 +0100

    feat(java): enhance ForyField annotation with tag ID support for optimized 
serialization (#3021)
    
    https://github.com/apache/fory/issues/3000
    
    Create/update a @ForyField annotation to provide extra metadata for
    performance and space optimization during java/xlang serialization.
    
    
    
    ## Why?
    
    
    
    ## What does this PR do?
    
    
    
    ## Related issues
    
    
    
    ## Does this PR introduce any user-facing change?
    
    
    
    - [x] Does this PR introduce any public API change?
    - [x] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
---
 .../java/org/apache/fory/annotation/ForyField.java |  28 +-
 .../fory/builder/BaseObjectCodecBuilder.java       |  47 +-
 .../main/java/org/apache/fory/meta/ClassDef.java   | 123 ++-
 .../java/org/apache/fory/meta/ClassDefDecoder.java |  26 +-
 .../java/org/apache/fory/meta/ClassDefEncoder.java |  45 +-
 .../java/org/apache/fory/meta/TypeDefDecoder.java  |  19 +-
 .../java/org/apache/fory/meta/TypeDefEncoder.java  |  34 +-
 .../org/apache/fory/resolver/ClassResolver.java    |  38 +-
 .../fory/serializer/AbstractObjectSerializer.java  |   7 +-
 .../main/java/org/apache/fory/type/Descriptor.java |  58 +-
 .../apache/fory/annotation/ForyAnnotationTest.java |  48 +-
 .../annotation/ForyFieldSerializationTest.java     | 838 +++++++++++++++++++++
 .../apache/fory/annotation/ForyFieldTagIdTest.java | 177 +++++
 .../org/apache/fory/annotation/ForyFieldTest.java  | 264 +++++++
 .../org/apache/fory/meta/ClassDefEncoderTest.java  |  72 +-
 .../java/org/apache/fory/meta/ClassDefTest.java    | 307 ++++++++
 .../org/apache/fory/meta/TypeDefEncoderTest.java   | 369 +++++++++
 .../apache/fory/type/DescriptorGrouperTest.java    |  36 +-
 .../java/org/apache/fory/type/DescriptorTest.java  |   2 +-
 .../format/encoder/ImplementInterfaceTest.java     |   2 +-
 20 files changed, 2404 insertions(+), 136 deletions(-)

diff --git 
a/java/fory-core/src/main/java/org/apache/fory/annotation/ForyField.java 
b/java/fory-core/src/main/java/org/apache/fory/annotation/ForyField.java
index 6c42e54ac..6b83f35e4 100644
--- a/java/fory-core/src/main/java/org/apache/fory/annotation/ForyField.java
+++ b/java/fory-core/src/main/java/org/apache/fory/annotation/ForyField.java
@@ -28,9 +28,31 @@ import java.lang.annotation.Target;
 @Target({ElementType.FIELD, ElementType.METHOD})
 public @interface ForyField {
 
-  /** Whether field is nullable, default false. */
+  /**
+   * Field tag ID for schema evolution mode (REQUIRED).
+   *
+   * <ul>
+   *   <li>When >= 0: Uses this numeric ID instead of field name string for 
compact encoding
+   *   <li>When -1: Explicitly opt-out of tag ID, use field name with meta 
string encoding
+   * </ul>
+   *
+   * <p>Must be unique within the class (except -1) and stable across versions.
+   */
+  int id();
+
+  /**
+   * Whether this field can be null. When set to false (default), Fory skips 
writing the null flag
+   * (saves 1 byte). When set to true, Fory writes null flag for nullable 
fields. Default: false
+   * (field is non-nullable, aligned with xlang protocol defaults)
+   */
   boolean nullable() default false;
 
-  /** Whether field need trackingRef, default false. */
-  boolean trackingRef() default false;
+  /**
+   * Whether to track references for this field. When set to false (default): 
- Avoids adding the
+   * object to IdentityMap (saves hash map overhead) - Skips writing ref 
tracking flag (saves 1 byte
+   * when combined with nullable=false) When set to true, enables reference 
tracking for
+   * shared/circular references. Default: false (no reference tracking, 
aligned with xlang protocol
+   * defaults)
+   */
+  boolean ref() default false;
 }
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java
 
b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java
index 337cfe0a7..e1be80dc8 100644
--- 
a/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java
+++ 
b/java/fory-core/src/main/java/org/apache/fory/builder/BaseObjectCodecBuilder.java
@@ -405,7 +405,14 @@ public abstract class BaseObjectCodecBuilder extends 
CodecBuilder {
     TypeRef<?> typeRef = descriptor.getTypeRef();
     boolean nullable = descriptor.isNullable();
 
+    boolean useRefTracking;
     if (needWriteRef(typeRef)) {
+      useRefTracking = descriptor.isTrackingRef();
+    } else {
+      useRefTracking = false;
+    }
+
+    if (useRefTracking) {
       return new If(
           not(writeRefOrNull(buffer, fieldValue)),
           serializeForNotNullForField(fieldValue, buffer, typeRef, null, 
false));
@@ -1768,20 +1775,46 @@ public abstract class BaseObjectCodecBuilder extends 
CodecBuilder {
     TypeRef<?> typeRef = descriptor.getTypeRef();
     boolean nullable = descriptor.isNullable();
 
+    boolean typeNeedsRef = needWriteRef(typeRef);
+    boolean useRefTracking;
     if (needWriteRef(typeRef)) {
+      useRefTracking = descriptor.isTrackingRef();
+    } else {
+      useRefTracking = false;
+    }
+
+    if (useRefTracking) {
       return readRef(buffer, callback, () -> 
deserializeForNotNullForField(buffer, typeRef, null));
     } else {
       if (!nullable) {
         Expression value = deserializeForNotNullForField(buffer, typeRef, 
null);
-        // Should put value expr ahead to avoid generated code in wrong scope.
+
+        if (typeNeedsRef) {
+          // When a field explicitly disables ref tracking 
(@ForyField(trackingRef=false))
+          // but the type normally needs ref tracking (e.g., collections),
+          // we need to preserve a -1 id so that when the deserializer calls 
reference(),
+          // it will pop this -1 and skip the setReadObject call.
+          Expression preserveStubRefId =
+              new Invoke(refResolverRef, "preserveRefId", new Literal(-1, 
PRIMITIVE_INT_TYPE));
+          return new ListExpression(preserveStubRefId, value, 
callback.apply(value));
+        }
         return new ListExpression(value, callback.apply(value));
       }
-      return readNullable(
-          buffer,
-          typeRef,
-          callback,
-          () -> deserializeForNotNullForField(buffer, typeRef, null),
-          true);
+
+      Expression readNullableExpr =
+          readNullable(
+              buffer,
+              typeRef,
+              callback,
+              () -> deserializeForNotNullForField(buffer, typeRef, null),
+              true);
+
+      if (typeNeedsRef) {
+        Expression preserveStubRefId =
+            new Invoke(refResolverRef, "preserveRefId", new Literal(-1, 
PRIMITIVE_INT_TYPE));
+        return new ListExpression(preserveStubRefId, readNullableExpr);
+      }
+      return readNullableExpr;
     }
   }
 
diff --git a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDef.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDef.java
index d457be7a5..4855a41a6 100644
--- a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDef.java
+++ b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDef.java
@@ -40,8 +40,10 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.stream.Collectors;
 import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
 import org.apache.fory.builder.MetaSharedCodecBuilder;
 import org.apache.fory.collection.Tuple2;
 import org.apache.fory.config.CompatibleMode;
@@ -61,6 +63,7 @@ import org.apache.fory.serializer.NonexistentClass;
 import org.apache.fory.serializer.converter.FieldConverter;
 import org.apache.fory.serializer.converter.FieldConverters;
 import org.apache.fory.type.Descriptor;
+import org.apache.fory.type.DescriptorBuilder;
 import org.apache.fory.type.FinalObjectTypeStub;
 import org.apache.fory.type.GenericType;
 import org.apache.fory.type.TypeUtils;
@@ -349,17 +352,76 @@ public class ClassDef implements Serializable {
       SortedMap<Member, Descriptor> allDescriptorsMap =
           resolver.getFory().getClassResolver().getAllDescriptorsMap(cls, 
true);
       Map<String, Descriptor> descriptorsMap = new HashMap<>();
+      Map<Short, Descriptor> fieldIdToDescriptorMap = new HashMap<>();
+      Map<Member, Descriptor>[] newDescriptors = new Map[] {null};
+
       for (Map.Entry<Member, Descriptor> e : allDescriptorsMap.entrySet()) {
-        if (descriptorsMap.put(
-                e.getKey().getDeclaringClass().getName() + "." + 
e.getKey().getName(), e.getValue())
-            != null) {
+        String fullName = e.getKey().getDeclaringClass().getName() + "." + 
e.getKey().getName();
+        Descriptor desc = e.getValue();
+        if (descriptorsMap.put(fullName, desc) != null) {
           throw new IllegalStateException("Duplicate key");
         }
+
+        if (e.getKey() instanceof Field) {
+          boolean refTracking = resolver.getFory().trackingRef();
+          ForyField foryField = desc.getForyField();
+          // update ref tracking if
+          // - global ref tracking is disabled but field is tracking ref 
(@ForyField#ref set)
+          // - global ref tracking is enabled but field is not tracking ref 
(@ForyField#ref not set)
+          boolean needsUpdate =
+              (refTracking && foryField == null && !desc.isTrackingRef())
+                  || (foryField != null && desc.isTrackingRef());
+
+          if (needsUpdate) {
+            if (newDescriptors[0] == null) {
+              newDescriptors[0] = new HashMap<>();
+            }
+            boolean newTrackingRef = refTracking && foryField == null;
+            Descriptor newDescriptor =
+                new 
DescriptorBuilder(desc).trackingRef(newTrackingRef).build();
+
+            descriptorsMap.put(fullName, newDescriptor);
+            desc = newDescriptor;
+            newDescriptors[0].put(e.getKey(), newDescriptor);
+          }
+        }
+
+        // If the field has @ForyField annotation with field ID, index by 
field ID
+        if (desc.getForyField() != null) {
+          int fieldId = desc.getForyField().id();
+          if (fieldId >= 0) {
+            if (fieldIdToDescriptorMap.containsKey((short) fieldId)) {
+              throw new IllegalArgumentException(
+                  "Duplicate field id "
+                      + fieldId
+                      + " for field "
+                      + desc.getName()
+                      + " in class "
+                      + cls.getName());
+            }
+            fieldIdToDescriptorMap.put((short) fieldId, desc);
+          }
+        }
       }
+
+      if (newDescriptors[0] != null) {
+        SortedMap<Member, Descriptor> allDescriptorsCopy = new 
TreeMap<>(allDescriptorsMap);
+        allDescriptorsCopy.putAll(newDescriptors[0]);
+        resolver.getFory().getClassResolver().updateDescriptorsCache(cls, 
true, allDescriptorsCopy);
+      }
+
       descriptors = new ArrayList<>(fieldsInfo.size());
       for (FieldInfo fieldInfo : fieldsInfo) {
-        Descriptor descriptor =
-            descriptorsMap.get(fieldInfo.getDefinedClass() + "." + 
fieldInfo.getFieldName());
+        Descriptor descriptor;
+
+        // Try to match by field ID first if the FieldInfo has an ID
+        if (fieldInfo.hasFieldId()) {
+          descriptor = fieldIdToDescriptorMap.get(fieldInfo.getFieldId());
+        } else {
+          descriptor =
+              descriptorsMap.get(fieldInfo.getDefinedClass() + "." + 
fieldInfo.getFieldName());
+        }
+
         Descriptor newDesc = fieldInfo.toDescriptor(resolver, descriptor);
         Class<?> rawType = newDesc.getRawType();
         FieldType fieldType = fieldInfo.getFieldType();
@@ -422,10 +484,18 @@ public class ClassDef implements Serializable {
 
     private final FieldType fieldType;
 
+    /** Field ID for schema evolution, -1 means no field ID (use field name). 
*/
+    private final short fieldId;
+
     FieldInfo(String definedClass, String fieldName, FieldType fieldType) {
+      this(definedClass, fieldName, fieldType, (short) -1);
+    }
+
+    FieldInfo(String definedClass, String fieldName, FieldType fieldType, 
short fieldId) {
       this.definedClass = definedClass;
       this.fieldName = fieldName;
       this.fieldType = fieldType;
+      this.fieldId = fieldId;
     }
 
     /** Returns classname of current field defined. */
@@ -439,13 +509,13 @@ public class ClassDef implements Serializable {
     }
 
     /** Returns whether field is annotated by an unsigned int id. */
-    public boolean hasTag() {
-      return false;
+    public boolean hasFieldId() {
+      return fieldId >= 0;
     }
 
-    /** Returns annotated tag id for the field. */
-    public short getTag() {
-      return -1;
+    /** Returns annotated field-id for the field. */
+    public short getFieldId() {
+      return fieldId;
     }
 
     /** Returns type of current field. */
@@ -465,13 +535,15 @@ public class ClassDef implements Serializable {
         if (typeRef.equals(declared)) {
           return descriptor;
         } else {
+          // TODO fix return here
           descriptor.copyWithTypeName(typeRef.getType().getTypeName());
         }
       }
       // This field doesn't exist in peer class, so any legal modifier will be 
OK.
       // Use constant instead of reflection to avoid GraalVM native image 
issues.
       int stubModifiers = Modifier.PRIVATE | Modifier.FINAL;
-      return new Descriptor(typeRef, fieldName, stubModifiers, definedClass);
+      return new Descriptor(
+          typeRef, fieldName, stubModifiers, definedClass, 
resolver.needToWriteRef(typeRef));
     }
 
     @Override
@@ -483,14 +555,15 @@ public class ClassDef implements Serializable {
         return false;
       }
       FieldInfo fieldInfo = (FieldInfo) o;
-      return Objects.equals(definedClass, fieldInfo.definedClass)
+      return fieldId == fieldInfo.fieldId
+          && Objects.equals(definedClass, fieldInfo.definedClass)
           && Objects.equals(fieldName, fieldInfo.fieldName)
           && Objects.equals(fieldType, fieldInfo.fieldType);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(definedClass, fieldName, fieldType);
+      return Objects.hash(definedClass, fieldName, fieldType, fieldId);
     }
 
     @Override
@@ -502,6 +575,7 @@ public class ClassDef implements Serializable {
           + ", fieldName='"
           + fieldName
           + '\''
+          + (fieldId >= 0 ? ", fieldID=" + fieldId : "")
           + ", fieldType="
           + fieldType
           + '}';
@@ -1089,11 +1163,12 @@ public class ClassDef implements Serializable {
   static FieldType buildFieldType(TypeResolver resolver, Field field) {
     Preconditions.checkNotNull(field);
     GenericType genericType = 
resolver.buildGenericType(field.getGenericType());
-    return buildFieldType(resolver, genericType);
+    return buildFieldType(resolver, field, genericType);
   }
 
   /** Build field type from generics, nested generics will be extracted too. */
-  private static FieldType buildFieldType(TypeResolver resolver, GenericType 
genericType) {
+  private static FieldType buildFieldType(
+      TypeResolver resolver, Field field, GenericType genericType) {
     Preconditions.checkNotNull(genericType);
     Class<?> rawType = genericType.getCls();
     boolean isXlang = resolver.getFory().isCrossLanguage();
@@ -1108,8 +1183,17 @@ public class ClassDef implements Serializable {
     }
     boolean isMonomorphic = genericType.isMonomorphic();
     boolean trackingRef = genericType.trackingRef(resolver);
-    // TODO support @Nullable/ForyField annotation
     boolean nullable = !genericType.getCls().isPrimitive();
+
+    // Apply @ForyField annotation if present
+    if (field != null) {
+      ForyField foryField = field.getAnnotation(ForyField.class);
+      if (foryField != null) {
+        nullable = foryField.nullable();
+        trackingRef = foryField.ref();
+      }
+    }
+
     if (COLLECTION_TYPE.isSupertypeOf(genericType.getTypeRef())) {
       return new CollectionFieldType(
           xtypeId,
@@ -1118,6 +1202,7 @@ public class ClassDef implements Serializable {
           trackingRef,
           buildFieldType(
               resolver,
+              null, // nested fields don't have Field reference
               genericType.getTypeParameter0() == null
                   ? GenericType.build(Object.class)
                   : genericType.getTypeParameter0()));
@@ -1129,11 +1214,13 @@ public class ClassDef implements Serializable {
           trackingRef,
           buildFieldType(
               resolver,
+              null, // nested fields don't have Field reference
               genericType.getTypeParameter0() == null
                   ? GenericType.build(Object.class)
                   : genericType.getTypeParameter0()),
           buildFieldType(
               resolver,
+              null, // nested fields don't have Field reference
               genericType.getTypeParameter1() == null
                   ? GenericType.build(Object.class)
                   : genericType.getTypeParameter1()));
@@ -1160,7 +1247,7 @@ public class ClassDef implements Serializable {
                 isMonomorphic,
                 nullable,
                 trackingRef,
-                buildFieldType(resolver, GenericType.build(elemType)));
+                buildFieldType(resolver, null, GenericType.build(elemType)));
           }
           Tuple2<Class<?>, Integer> info = 
TypeUtils.getArrayComponentInfo(rawType);
           return new ArrayFieldType(
@@ -1168,7 +1255,7 @@ public class ClassDef implements Serializable {
               isMonomorphic,
               nullable,
               trackingRef,
-              buildFieldType(resolver, GenericType.build(info.f0)),
+              buildFieldType(resolver, null, GenericType.build(info.f0)),
               info.f1);
         }
         return new ObjectFieldType(xtypeId, isMonomorphic, nullable, 
trackingRef);
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefDecoder.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefDecoder.java
index 1c9b41607..e2cb372c9 100644
--- a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefDecoder.java
+++ b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefDecoder.java
@@ -118,23 +118,37 @@ class ClassDefDecoder {
       int header = buffer.readByte() & 0xff;
       //  `3 bits size + 2 bits field name encoding + polymorphism flag + 
nullability flag + ref
       // tracking flag`
-      // TODO(chaokunyang) read type tag
       int encodingFlags = (header >>> 3) & 0b11;
       boolean useTagID = encodingFlags == 3;
-      Preconditions.checkArgument(
-          !useTagID, "Type tag not supported currently, parsed fieldInfos %s", 
fieldInfos);
       int size = header >>> 5;
       if (size == 7) {
         size += buffer.readVarUint32Small7();
       }
       size += 1;
-      Encoding encoding = fieldNameEncodings[encodingFlags];
-      String fieldName = 
Encoders.FIELD_NAME_DECODER.decode(buffer.readBytes(size), encoding);
+
+      // Read field name or tag ID
+      String fieldName;
+      short tagId = -1;
+      if (useTagID) {
+        // When useTagID is true, size contains the tag ID
+        tagId = (short) (size - 1);
+        // Use placeholder field name since tag ID is used for identification
+        fieldName = "$tag" + tagId;
+      } else {
+        Encoding encoding = fieldNameEncodings[encodingFlags];
+        fieldName = Encoders.FIELD_NAME_DECODER.decode(buffer.readBytes(size), 
encoding);
+      }
+
       boolean isMonomorphic = (header & 0b100) != 0;
       boolean trackingRef = (header & 0b001) != 0;
       int typeId = buffer.readVarUint32Small14();
       FieldType fieldType = FieldType.read(buffer, resolver, isMonomorphic, 
trackingRef, typeId);
-      fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, fieldType));
+
+      if (useTagID) {
+        fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, fieldType, 
tagId));
+      } else {
+        fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, 
fieldType));
+      }
     }
     return fieldInfos;
   }
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefEncoder.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefEncoder.java
index c75d7edaf..121735c04 100644
--- a/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefEncoder.java
+++ b/java/fory-core/src/main/java/org/apache/fory/meta/ClassDefEncoder.java
@@ -30,11 +30,14 @@ import static 
org.apache.fory.meta.Encoders.typeNameEncodingsList;
 import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
 import org.apache.fory.annotation.Internal;
 import org.apache.fory.collection.Tuple2;
 import org.apache.fory.memory.MemoryBuffer;
@@ -90,12 +93,38 @@ public class ClassDefEncoder {
 
   public static List<FieldInfo> buildFieldsInfo(TypeResolver resolver, 
List<Field> fields) {
     List<FieldInfo> fieldInfos = new ArrayList<>();
+    Set<Integer> usedTagIds = new HashSet<>();
     for (Field field : fields) {
-      FieldInfo fieldInfo =
-          new FieldInfo(
-              field.getDeclaringClass().getName(),
-              field.getName(),
-              ClassDef.buildFieldType(resolver, field));
+      // Check for @ForyField annotation to extract tag ID
+      ForyField foryField = field.getAnnotation(ForyField.class);
+      FieldType fieldType = ClassDef.buildFieldType(resolver, field);
+
+      FieldInfo fieldInfo;
+      if (foryField != null) {
+        int tagId = foryField.id();
+        if (tagId >= 0) {
+          if (!usedTagIds.add(tagId)) {
+            throw new IllegalArgumentException(
+                "Duplicate tag id: "
+                    + tagId
+                    + ", field: "
+                    + field
+                    + ", class: "
+                    + field.getDeclaringClass());
+          }
+          // Create FieldInfo with tag ID for optimized serialization
+          fieldInfo =
+              new FieldInfo(
+                  field.getDeclaringClass().getName(), field.getName(), 
fieldType, (short) tagId);
+        } else {
+          // tagId == -1 means opt-out, use field name
+          fieldInfo =
+              new FieldInfo(field.getDeclaringClass().getName(), 
field.getName(), fieldType);
+        }
+      } else {
+        // No annotation, use field name
+        fieldInfo = new FieldInfo(field.getDeclaringClass().getName(), 
field.getName(), fieldType);
+      }
       fieldInfos.add(fieldInfo);
     }
     return fieldInfos;
@@ -261,8 +290,8 @@ public class ClassDefEncoder {
       int encodingFlags = 
fieldNameEncodingsList.indexOf(metaString.getEncoding());
       byte[] encoded = metaString.getBytes();
       int size = (encoded.length - 1);
-      if (fieldInfo.hasTag()) {
-        size = fieldInfo.getTag();
+      if (fieldInfo.hasFieldId()) {
+        size = fieldInfo.getFieldId();
         encodingFlags = 3;
       }
       header |= (byte) (encodingFlags << 3);
@@ -275,7 +304,7 @@ public class ClassDefEncoder {
         header |= (size << 5);
         buffer.writeByte(header);
       }
-      if (!fieldInfo.hasTag()) {
+      if (!fieldInfo.hasFieldId()) {
         buffer.writeBytes(encoded);
       }
       fieldType.write(buffer, false);
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefDecoder.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefDecoder.java
index d67dd1030..314f82cdb 100644
--- a/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefDecoder.java
+++ b/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefDecoder.java
@@ -118,15 +118,20 @@ class TypeDefDecoder {
       boolean trackingRef = (header & 0b1) != 0;
       int typeId = buffer.readVarUint32Small14();
       FieldType fieldType = FieldType.xread(buffer, resolver, typeId, 
nullable, trackingRef);
-      // read field name
+
+      // read field name or tag ID
       if (useTagID) {
-        throw new UnsupportedOperationException(
-            "Type tag not supported currently, parsed fieldInfos %s " + 
fieldInfos);
+        // When useTagID is true, fieldNameSize actually contains the tag ID
+        short tagId = (short) (fieldNameSize - 1);
+        // Use a placeholder field name since tag ID is used for identification
+        String fieldName = "$tag" + tagId; // TODO we could use id as String 
as field name
+        fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, fieldType, 
tagId));
+      } else {
+        Encoding encoding = fieldNameEncodings[encodingFlags];
+        String fieldName =
+            
Encoders.FIELD_NAME_DECODER.decode(buffer.readBytes(fieldNameSize), encoding);
+        fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, 
fieldType));
       }
-      Encoding encoding = fieldNameEncodings[encodingFlags];
-      String fieldName =
-          Encoders.FIELD_NAME_DECODER.decode(buffer.readBytes(fieldNameSize), 
encoding);
-      fieldInfos.add(new ClassDef.FieldInfo(className, fieldName, fieldType));
     }
     return fieldInfos;
   }
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefEncoder.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefEncoder.java
index aabe12f8e..a39029fb1 100644
--- a/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefEncoder.java
+++ b/java/fory-core/src/main/java/org/apache/fory/meta/TypeDefEncoder.java
@@ -26,12 +26,15 @@ import static 
org.apache.fory.meta.Encoders.fieldNameEncodingsList;
 
 import java.lang.reflect.Field;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
 import org.apache.fory.logging.Logger;
 import org.apache.fory.logging.LoggerFactory;
 import org.apache.fory.memory.MemoryBuffer;
@@ -79,11 +82,30 @@ class TypeDefEncoder {
   }
 
   static List<FieldInfo> buildFieldsInfo(TypeResolver resolver, Class<?> type, 
List<Field> fields) {
+    Set<Integer> usedTagIds = new HashSet<>();
     return fields.stream()
         .map(
-            field ->
-                new FieldInfo(
-                    type.getName(), field.getName(), 
ClassDef.buildFieldType(resolver, field)))
+            field -> {
+              ForyField foryField = field.getAnnotation(ForyField.class);
+              FieldType fieldType = ClassDef.buildFieldType(resolver, field);
+              if (foryField != null) {
+                int tagId = foryField.id();
+                if (tagId >= 0) {
+                  if (!usedTagIds.add(tagId)) {
+                    throw new IllegalArgumentException(
+                        "Duplicate tag id "
+                            + tagId
+                            + " for field "
+                            + field.getName()
+                            + " in class "
+                            + type.getName());
+                  }
+                  return new FieldInfo(type.getName(), field.getName(), 
fieldType, (short) tagId);
+                }
+                // tagId == -1 means use field name, fall through to create 
regular FieldInfo
+              }
+              return new FieldInfo(type.getName(), field.getName(), fieldType);
+            })
         .collect(Collectors.toList());
   }
 
@@ -171,8 +193,8 @@ class TypeDefEncoder {
       header |= fieldType.nullable() ? 0b10 : 0b00;
       int size, encodingFlags;
       byte[] encoded = null;
-      if (fieldInfo.hasTag()) {
-        size = fieldInfo.getTag();
+      if (fieldInfo.hasFieldId()) {
+        size = fieldInfo.getFieldId();
         encodingFlags = 3;
       } else {
         MetaString metaString = 
Encoders.encodeFieldName(fieldInfo.getFieldName());
@@ -193,7 +215,7 @@ class TypeDefEncoder {
       }
       fieldType.xwrite(buffer, false);
       // write field name
-      if (!fieldInfo.hasTag()) {
+      if (!fieldInfo.hasFieldId()) {
         buffer.writeBytes(encoded);
       }
     }
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java 
b/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
index 3b87c6611..bc2c0276d 100644
--- a/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
+++ b/java/fory-core/src/main/java/org/apache/fory/resolver/ClassResolver.java
@@ -146,6 +146,7 @@ import 
org.apache.fory.serializer.scala.SingletonObjectSerializer;
 import org.apache.fory.serializer.shim.ProtobufDispatcher;
 import org.apache.fory.serializer.shim.ShimDispatcher;
 import org.apache.fory.type.Descriptor;
+import org.apache.fory.type.DescriptorBuilder;
 import org.apache.fory.type.DescriptorGrouper;
 import org.apache.fory.type.GenericType;
 import org.apache.fory.type.TypeUtils;
@@ -1216,12 +1217,42 @@ public class ClassResolver extends TypeResolver {
   public List<Descriptor> getFieldDescriptors(Class<?> clz, boolean 
searchParent) {
     SortedMap<Member, Descriptor> allDescriptors = getAllDescriptorsMap(clz, 
searchParent);
     List<Descriptor> result = new ArrayList<>(allDescriptors.size());
+
+    Map<Member, Descriptor>[] newDescriptors = new Map[] {null};
+
     allDescriptors.forEach(
         (member, descriptor) -> {
-          if (member instanceof Field) {
+          if (!(member instanceof Field)) {
+            return;
+          }
+
+          boolean shouldTrack = fory.trackingRef();
+          boolean hasForyField = descriptor.getForyField() != null;
+          // update ref tracking if
+          // - global ref tracking is enabled but field is not tracking ref 
(@ForyField#ref not set)
+          // - global ref tracking is disabled but field is tracking ref 
(@ForyField#ref set)
+          boolean needsUpdate =
+              (shouldTrack && !hasForyField)
+                  || (!shouldTrack && hasForyField && 
descriptor.isTrackingRef());
+
+          if (needsUpdate) {
+            if (newDescriptors[0] == null) {
+              newDescriptors[0] = new HashMap<>();
+            }
+            Descriptor newDescriptor =
+                new 
DescriptorBuilder(descriptor).trackingRef(shouldTrack).build();
+            result.add(newDescriptor);
+            newDescriptors[0].put(member, newDescriptor);
+          } else {
             result.add(descriptor);
           }
         });
+
+    if (newDescriptors[0] != null) {
+      SortedMap<Member, Descriptor> allDescriptorsCopy = new 
TreeMap<>(allDescriptors);
+      allDescriptorsCopy.putAll(newDescriptors[0]);
+      extRegistry.descriptorsCache.put(Tuple2.of(clz, searchParent), 
allDescriptorsCopy);
+    }
     return result;
   }
 
@@ -1232,6 +1263,11 @@ public class ClassResolver extends TypeResolver {
         Tuple2.of(clz, searchParent), t -> 
Descriptor.getAllDescriptorsMap(clz, searchParent));
   }
 
+  public void updateDescriptorsCache(
+      Class<?> clz, boolean searchParent, SortedMap<Member, Descriptor> 
descriptors) {
+    extRegistry.descriptorsCache.put(Tuple2.of(clz, searchParent), 
descriptors);
+  }
+
   public ClassInfo getClassInfo(short classId) {
     ClassInfo classInfo = registeredId2ClassInfo[classId];
     assert classInfo != null : classId;
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java
 
b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java
index 7ecde0993..8ff29fcb4 100644
--- 
a/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java
+++ 
b/java/fory-core/src/main/java/org/apache/fory/serializer/AbstractObjectSerializer.java
@@ -29,7 +29,6 @@ import java.util.Collection;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.apache.fory.Fory;
-import org.apache.fory.annotation.ForyField;
 import org.apache.fory.collection.Tuple2;
 import org.apache.fory.collection.Tuple3;
 import org.apache.fory.memory.MemoryBuffer;
@@ -996,13 +995,9 @@ public abstract class AbstractObjectSerializer<T> extends 
Serializer<T> {
       this.qualifiedFieldName = d.getDeclaringClass() + "." + d.getName();
       this.fieldAccessor = d.getField() != null ? 
FieldAccessor.createAccessor(d.getField()) : null;
       fieldConverter = d.getFieldConverter();
-      ForyField foryField = d.getForyField();
       nullable = d.isNullable();
       if (fory.trackingRef()) {
-        trackingRef =
-            foryField != null
-                ? foryField.trackingRef()
-                : fory.getClassResolver().needToWriteRef(typeRef);
+        trackingRef = d.isTrackingRef();
       }
     }
 
diff --git a/java/fory-core/src/main/java/org/apache/fory/type/Descriptor.java 
b/java/fory-core/src/main/java/org/apache/fory/type/Descriptor.java
index decb2b93a..a65574737 100644
--- a/java/fory-core/src/main/java/org/apache/fory/type/Descriptor.java
+++ b/java/fory-core/src/main/java/org/apache/fory/type/Descriptor.java
@@ -89,6 +89,8 @@ public class Descriptor {
   private final Method writeMethod;
   private ForyField foryField;
   private boolean nullable;
+  // trackingRef should only be true if explicitly set to true via 
@ForyField(ref=true)
+  // If no annotation or ref not specified, trackingRef stays false and 
type-based tracking applies
   private boolean trackingRef;
   private FieldConverter<?> fieldConverter;
 
@@ -105,9 +107,11 @@ public class Descriptor {
     if (!typeRef.isPrimitive()) {
       this.nullable = foryField == null || foryField.nullable();
     }
+    this.trackingRef = foryField != null && foryField.ref();
   }
 
-  public Descriptor(TypeRef<?> typeRef, String name, int modifier, String 
declaringClass) {
+  public Descriptor(
+      TypeRef<?> typeRef, String name, int modifier, String declaringClass, 
boolean trackingRef) {
     this.field = null;
     this.typeName = typeRef.getRawType().getName();
     this.name = name;
@@ -118,6 +122,7 @@ public class Descriptor {
     this.writeMethod = null;
     this.foryField = null;
     this.nullable = !typeRef.isPrimitive();
+    this.trackingRef = trackingRef;
   }
 
   private Descriptor(Field field, Method readMethod) {
@@ -133,6 +138,7 @@ public class Descriptor {
     if (!field.getType().isPrimitive()) {
       this.nullable = foryField == null || foryField.nullable();
     }
+    this.trackingRef = foryField != null && foryField.ref();
   }
 
   private Descriptor(Method readMethod) {
@@ -148,45 +154,24 @@ public class Descriptor {
     if (!readMethod.getReturnType().isPrimitive()) {
       this.nullable = foryField == null || foryField.nullable();
     }
+    this.trackingRef = foryField != null && foryField.ref();
   }
 
-  private Descriptor(
-      TypeRef<?> typeRef,
-      String typeName,
-      String name,
-      int modifier,
-      String declaringClass,
-      Field field,
-      Method readMethod,
-      Method writeMethod) {
-    this.typeRef = typeRef;
-    this.typeName = typeName;
-    this.name = name;
-    this.modifier = modifier;
-    this.declaringClass = declaringClass;
-    this.field = field;
-    this.readMethod = readMethod;
-    this.writeMethod = writeMethod;
+  public Descriptor(DescriptorBuilder builder) {
+    this.typeRef = builder.typeRef;
+    this.typeName = builder.typeName;
+    this.name = builder.name;
+    this.modifier = builder.modifier;
+    this.declaringClass = builder.declaringClass;
+    this.field = builder.field;
+    this.readMethod = builder.readMethod;
+    this.writeMethod = builder.writeMethod;
+    this.trackingRef = builder.trackingRef;
     this.foryField = this.field == null ? null : 
this.field.getAnnotation(ForyField.class);
     if (!typeRef.isPrimitive()) {
       this.nullable = foryField == null || foryField.nullable();
     }
-  }
-
-  public Descriptor(DescriptorBuilder builder) {
-    this(
-        builder.typeRef,
-        builder.typeName,
-        builder.name,
-        builder.modifier,
-        builder.declaringClass,
-        builder.field,
-        builder.readMethod,
-        builder.writeMethod);
-    this.nullable = builder.nullable;
-    this.trackingRef = builder.trackingRef;
     this.type = builder.type;
-    this.foryField = builder.foryField;
     this.fieldConverter = builder.fieldConverter;
   }
 
@@ -309,6 +294,9 @@ public class Descriptor {
       sb.append(", typeRef=").append(typeRef);
     }
     sb.append(", foryField=").append(foryField);
+    sb.append(", trackingRef=").append(trackingRef);
+    sb.append(", foryFieldConverter=").append(fieldConverter);
+    sb.append(", nullable=").append(nullable);
     sb.append('}');
     return sb.toString();
   }
@@ -322,7 +310,7 @@ public class Descriptor {
     SortedMap<Member, Descriptor> allDescriptorsMap = 
getAllDescriptorsMap(clz);
     Map<String, List<Member>> duplicateNameFields = 
getDuplicateNames(allDescriptorsMap);
     checkArgument(
-        duplicateNameFields.size() == 0, "%s has duplicate fields %s", clz, 
duplicateNameFields);
+        duplicateNameFields.isEmpty(), "%s has duplicate fields %s", clz, 
duplicateNameFields);
     return new ArrayList<>(allDescriptorsMap.values());
   }
 
@@ -334,7 +322,7 @@ public class Descriptor {
     SortedMap<Member, Descriptor> allDescriptorsMap = 
getAllDescriptorsMap(clz);
     Map<String, List<Member>> duplicateNameFields = 
getDuplicateNames(allDescriptorsMap);
     Preconditions.checkArgument(
-        duplicateNameFields.size() == 0, "%s has duplicate fields %s", clz, 
duplicateNameFields);
+        duplicateNameFields.isEmpty(), "%s has duplicate fields %s", clz, 
duplicateNameFields);
     TreeMap<String, Descriptor> map = new TreeMap<>();
     allDescriptorsMap.forEach((k, v) -> map.put(k.getName(), v));
     return map;
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/annotation/ForyAnnotationTest.java
 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyAnnotationTest.java
index 11b35929f..300ae1a87 100644
--- 
a/java/fory-core/src/test/java/org/apache/fory/annotation/ForyAnnotationTest.java
+++ 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyAnnotationTest.java
@@ -35,10 +35,10 @@ public class ForyAnnotationTest extends ForyTestBase {
 
   @Data
   public static class BeanM {
-    @ForyField(nullable = false)
+    @ForyField(id = 0, nullable = false)
     public Long f1;
 
-    @ForyField(nullable = false)
+    @ForyField(id = 1, nullable = false)
     private Long f2;
 
     String s = "str";
@@ -64,58 +64,61 @@ public class ForyAnnotationTest extends ForyTestBase {
 
     byte byte1;
 
-    @ForyField int i3 = 10;
+    @ForyField(id = 2)
+    int i3 = 10;
 
-    @ForyField List<Integer> integerList = Lists.newArrayList(1);
+    @ForyField(id = 3)
+    List<Integer> integerList = Lists.newArrayList(1);
 
-    @ForyField String s1 = "str";
+    @ForyField(id = 4)
+    String s1 = "str";
 
-    @ForyField(nullable = false)
+    @ForyField(id = 5, nullable = false)
     Short shortValue1 = Short.valueOf((short) 2);
 
-    @ForyField(nullable = false)
+    @ForyField(id = 6, nullable = false)
     Byte byteValue1 = Byte.valueOf((byte) 3);
 
-    @ForyField(nullable = false)
+    @ForyField(id = 7, nullable = false)
     Long longValue1 = Long.valueOf(4L);
 
-    @ForyField(nullable = false)
+    @ForyField(id = 8, nullable = false)
     Boolean booleanValue1 = Boolean.TRUE;
 
-    @ForyField(nullable = false)
+    @ForyField(id = 9, nullable = false)
     Float floatValue1 = Float.valueOf(5.0f);
 
-    @ForyField(nullable = false)
+    @ForyField(id = 10, nullable = false)
     Double doubleValue1 = Double.valueOf(6.0);
 
-    @ForyField(nullable = false)
+    @ForyField(id = 11, nullable = false)
     Character character1 = Character.valueOf('c');
 
-    @ForyField(nullable = true)
+    @ForyField(id = 12, nullable = true)
     List<Integer> integerList1 = Lists.newArrayList(1);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 13, nullable = true)
     String s2 = "str";
 
-    @ForyField(nullable = true)
+    @ForyField(id = 14, nullable = true)
     Short shortValue2 = Short.valueOf((short) 2);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 15, nullable = true)
     Byte byteValue2 = Byte.valueOf((byte) 3);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 16, nullable = true)
     Long longValue2 = Long.valueOf(4L);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 17, nullable = true)
     Boolean booleanValue2 = Boolean.TRUE;
 
-    @ForyField(nullable = true)
+    @ForyField(id = 18, nullable = true)
     Float floatValue2 = Float.valueOf(5.0f);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 19, nullable = true)
     Double doubleValue2 = Double.valueOf(6.0);
 
-    @ForyField(nullable = true)
+    @ForyField(id = 20, nullable = true)
     Character character2 = Character.valueOf('c');
 
     public BeanM() {
@@ -133,7 +136,8 @@ public class ForyAnnotationTest extends ForyTestBase {
   @Data
   public static class BeanM1 {
 
-    @ForyField private BeanN beanN = new BeanN();
+    @ForyField(id = 0)
+    private BeanN beanN = new BeanN();
   }
 
   @Test(dataProvider = "basicMultiConfigFory")
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldSerializationTest.java
 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldSerializationTest.java
new file mode 100644
index 000000000..5a54dcc8f
--- /dev/null
+++ 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldSerializationTest.java
@@ -0,0 +1,838 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.annotation;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.fory.Fory;
+import org.apache.fory.ForyTestBase;
+import org.apache.fory.config.CompatibleMode;
+import org.apache.fory.config.Language;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+public class ForyFieldSerializationTest extends ForyTestBase {
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonWithTagId {
+    @ForyField(id = 0, nullable = false)
+    public String veryLongFieldNameForFirstName;
+
+    @ForyField(id = 1, nullable = false)
+    public String anotherVeryLongFieldNameForLastName;
+
+    @ForyField(id = 2, nullable = false)
+    public int age;
+  }
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonWithoutTagId {
+    public String veryLongFieldNameForFirstName;
+    public String anotherVeryLongFieldNameForLastName;
+    public int age;
+  }
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonWithOptOutTagId {
+    @ForyField(id = -1, nullable = false)
+    public String veryLongFieldNameForFirstName;
+
+    @ForyField(id = -1, nullable = false)
+    public String anotherVeryLongFieldNameForLastName;
+
+    @ForyField(id = -1, nullable = false)
+    public int age;
+  }
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonMixedTagId {
+    @ForyField(id = 0, nullable = false)
+    public String firstName;
+
+    // This field uses field name (id = -1)
+    @ForyField(id = -1, nullable = false)
+    public String veryLongFieldNameForLastName;
+
+    public int age; // No annotation, uses field name
+  }
+
+  /** Nested object classes for testing field ID vs field type serialization */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class VeryLongNestedObjectClassName {
+    @ForyField(id = 0, nullable = false)
+    public String value;
+
+    @ForyField(id = 1, nullable = false)
+    public int count;
+  }
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AnotherVeryLongNestedObjectClassName {
+    @ForyField(id = 0, nullable = false)
+    public String description;
+  }
+
+  /** Container with nested objects using field tag IDs */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class ContainerWithTagIds {
+    @ForyField(id = 0, nullable = false)
+    public VeryLongNestedObjectClassName veryLongFieldNameForNestedObject;
+
+    @ForyField(id = 1, nullable = false)
+    public AnotherVeryLongNestedObjectClassName 
anotherVeryLongFieldNameForAnotherNestedObject;
+
+    @ForyField(id = 2, nullable = false)
+    public String simpleField;
+  }
+
+  /** Container with nested objects WITHOUT tag IDs (uses field names) */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class ContainerWithoutTagIds {
+    public VeryLongNestedObjectClassName veryLongFieldNameForNestedObject;
+    public AnotherVeryLongNestedObjectClassName 
anotherVeryLongFieldNameForAnotherNestedObject;
+    public String simpleField;
+  }
+
+  @DataProvider(name = "modes")
+  public Object[][] modes() {
+    return new Object[][] {
+      // JAVA mode with and without registration
+      {Language.JAVA, CompatibleMode.SCHEMA_CONSISTENT, false, false},
+      {Language.JAVA, CompatibleMode.SCHEMA_CONSISTENT, true, false},
+      {Language.JAVA, CompatibleMode.COMPATIBLE, false, false},
+      {Language.JAVA, CompatibleMode.COMPATIBLE, true, false},
+      {Language.JAVA, CompatibleMode.SCHEMA_CONSISTENT, false, true},
+      {Language.JAVA, CompatibleMode.SCHEMA_CONSISTENT, true, true},
+      {Language.JAVA, CompatibleMode.COMPATIBLE, false, true},
+      {Language.JAVA, CompatibleMode.COMPATIBLE, true, true},
+      // XLANG mode always requires registration
+      {Language.XLANG, CompatibleMode.SCHEMA_CONSISTENT, false, true},
+      {Language.XLANG, CompatibleMode.SCHEMA_CONSISTENT, true, true},
+      {Language.XLANG, CompatibleMode.COMPATIBLE, false, true},
+      {Language.XLANG, CompatibleMode.COMPATIBLE, true, true},
+    };
+  }
+
+  @Test(dataProvider = "modes")
+  public void testTagIdReducesPayloadSize(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    // Register classes based on parameter
+    if (registered) {
+      fory.register(PersonWithTagId.class, "test.PersonWithTagId");
+      fory.register(PersonWithoutTagId.class, "test.PersonWithoutTagId");
+      fory.register(PersonWithOptOutTagId.class, "test.PersonWithOptOutTagId");
+    }
+
+    PersonWithTagId personWithTag = new PersonWithTagId("John", "Doe", 30);
+    PersonWithoutTagId personWithoutTag = new PersonWithoutTagId("John", 
"Doe", 30);
+    PersonWithOptOutTagId personWithOptOut = new PersonWithOptOutTagId("John", 
"Doe", 30);
+
+    byte[] bytesWithTag = fory.serialize(personWithTag);
+    byte[] bytesWithoutTag = fory.serialize(personWithoutTag);
+    byte[] bytesWithOptOut = fory.serialize(personWithOptOut);
+
+    // Verify deserialization works
+    PersonWithTagId deserializedWithTag = (PersonWithTagId) 
fory.deserialize(bytesWithTag);
+    PersonWithoutTagId deserializedWithoutTag =
+        (PersonWithoutTagId) fory.deserialize(bytesWithoutTag);
+    PersonWithOptOutTagId deserializedWithOptOut =
+        (PersonWithOptOutTagId) fory.deserialize(bytesWithOptOut);
+
+    assertEquals(deserializedWithTag.veryLongFieldNameForFirstName, "John");
+    assertEquals(deserializedWithTag.anotherVeryLongFieldNameForLastName, 
"Doe");
+    assertEquals(deserializedWithTag.age, 30);
+
+    assertEquals(deserializedWithoutTag.veryLongFieldNameForFirstName, "John");
+    assertEquals(deserializedWithoutTag.anotherVeryLongFieldNameForLastName, 
"Doe");
+    assertEquals(deserializedWithoutTag.age, 30);
+
+    assertEquals(deserializedWithOptOut.veryLongFieldNameForFirstName, "John");
+    assertEquals(deserializedWithOptOut.anotherVeryLongFieldNameForLastName, 
"Doe");
+    assertEquals(deserializedWithOptOut.age, 30);
+
+    System.out.printf(
+        "Mode: %s/%s/codegen=%s - With tag: %d bytes, Without tag: %d bytes, 
Opt-out (id=-1): %d bytes%n",
+        language,
+        compatibleMode,
+        codegen,
+        bytesWithTag.length,
+        bytesWithoutTag.length,
+        bytesWithOptOut.length);
+
+    // Tag IDs should reduce payload size in all modes (JAVA and XLANG)
+    // This is the core benefit of the @ForyField annotation feature
+    assertTrue(
+        bytesWithTag.length <= bytesWithoutTag.length,
+        String.format(
+            "Expected tag ID version (%d bytes) to be <= field name version 
(%d bytes) in mode %s/%s/codegen=%s",
+            bytesWithTag.length, bytesWithoutTag.length, language, 
compatibleMode, codegen));
+
+    // Tag ID version should also be smaller than or equal to opt-out version 
(id=-1)
+    assertTrue(
+        bytesWithTag.length <= bytesWithOptOut.length,
+        String.format(
+            "Expected tag ID version (%d bytes) to be <= opt-out id=-1 version 
(%d bytes) in mode %s/%s/codegen=%s",
+            bytesWithTag.length, bytesWithOptOut.length, language, 
compatibleMode, codegen));
+
+    // Opt-out (id=-1) should have similar size to no annotation (both use 
field names)
+    // They should be equal or very close in size
+    int sizeDifference = Math.abs(bytesWithOptOut.length - 
bytesWithoutTag.length);
+    assertTrue(
+        sizeDifference <= 5,
+        String.format(
+            "Expected opt-out id=-1 (%d bytes) to have similar size to no 
annotation (%d bytes), but difference is %d bytes",
+            bytesWithOptOut.length, bytesWithoutTag.length, sizeDifference));
+  }
+
+  @Test(dataProvider = "modes")
+  public void testFieldNameNotInPayloadWithTagId(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(PersonWithTagId.class, "test.PersonWithTagId");
+    }
+
+    PersonWithTagId person = new PersonWithTagId("Alice", "Smith", 25);
+    byte[] bytes = fory.serialize(person);
+
+    // Convert to string to search for field names
+    String serialized = new String(bytes, StandardCharsets.UTF_8);
+
+    // With tag IDs, field names should generally NOT appear in the payload in 
most modes
+    // Note: Exact behavior may vary by mode, but we verify deserialization 
always works
+    // In XLANG/COMPATIBLE mode specifically, field names should definitely 
not be present
+    if (language == Language.XLANG && compatibleMode == 
CompatibleMode.COMPATIBLE) {
+      assertFalse(
+          serialized.contains("veryLongFieldNameForFirstName"),
+          String.format(
+              "Field name 'veryLongFieldNameForFirstName' should not be in 
payload with tag ID in mode %s/%s/codegen=%s",
+              language, compatibleMode, codegen));
+      assertFalse(
+          serialized.contains("anotherVeryLongFieldNameForLastName"),
+          String.format(
+              "Field name 'anotherVeryLongFieldNameForLastName' should not be 
in payload with tag ID in mode %s/%s/codegen=%s",
+              language, compatibleMode, codegen));
+    }
+
+    // Verify deserialization still works in ALL modes
+    PersonWithTagId deserialized = (PersonWithTagId) fory.deserialize(bytes);
+    assertEquals(deserialized.veryLongFieldNameForFirstName, "Alice");
+    assertEquals(deserialized.anotherVeryLongFieldNameForLastName, "Smith");
+    assertEquals(deserialized.age, 25);
+  }
+
+  @Test(dataProvider = "modes")
+  public void testFieldNameInPayloadWithoutTagId(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(PersonWithoutTagId.class, "test.PersonWithoutTagId");
+    }
+
+    PersonWithoutTagId person = new PersonWithoutTagId("Bob", "Johnson", 35);
+    byte[] bytes = fory.serialize(person);
+
+    // In COMPATIBLE mode without tag IDs, field names are used for field 
matching
+    // (though they may be encoded using meta string compression)
+    if (compatibleMode == CompatibleMode.COMPATIBLE) {
+      // Verify the data deserializes correctly
+      PersonWithoutTagId deserialized = (PersonWithoutTagId) 
fory.deserialize(bytes);
+      assertEquals(deserialized.veryLongFieldNameForFirstName, "Bob");
+      assertEquals(deserialized.anotherVeryLongFieldNameForLastName, 
"Johnson");
+      assertEquals(deserialized.age, 35);
+    }
+  }
+
+  @Test(dataProvider = "modes")
+  public void testMixedTagIdAndFieldName(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(PersonMixedTagId.class, "test.PersonMixedTagId");
+    }
+
+    PersonMixedTagId person = new PersonMixedTagId("Charlie", "Brown", 40);
+    byte[] bytes = fory.serialize(person);
+
+    // Verify deserialization works correctly with mixed mode
+    PersonMixedTagId deserialized = (PersonMixedTagId) fory.deserialize(bytes);
+    assertEquals(deserialized.firstName, "Charlie");
+    assertEquals(deserialized.veryLongFieldNameForLastName, "Brown");
+    assertEquals(deserialized.age, 40);
+
+    System.out.printf(
+        "Mixed mode - %s/%s/codegen=%s: %d bytes%n",
+        language, compatibleMode, codegen, bytes.length);
+  }
+
+  /** Test class for nullable and ref flags */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class TestNullableRef {
+    @ForyField(id = 0, nullable = false, ref = false)
+    String nonNullableNoRef;
+
+    @ForyField(id = 1, nullable = true, ref = false)
+    String nullableNoRef;
+
+    @ForyField(id = 2, nullable = false, ref = true)
+    String nonNullableWithRef;
+
+    @ForyField(id = 3, nullable = true, ref = true)
+    String nullableWithRef;
+  }
+
+  @Test(dataProvider = "modes")
+  public void testNullableAndRefFlagsInPayload(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(TestNullableRef.class, "test.TestNullableRef");
+    }
+
+    TestNullableRef obj = new TestNullableRef("a", null, "c", "d");
+    byte[] bytes = fory.serialize(obj);
+
+    // Verify deserialization
+    TestNullableRef deserialized = (TestNullableRef) fory.deserialize(bytes);
+    assertEquals(deserialized.nonNullableNoRef, "a");
+    assertNull(deserialized.nullableNoRef);
+    assertEquals(deserialized.nonNullableWithRef, "c");
+    assertEquals(deserialized.nullableWithRef, "d");
+
+    System.out.printf(
+        "Nullable/Ref test - %s/%s/codegen=%s: %d bytes%n",
+        language, compatibleMode, codegen, bytes.length);
+  }
+
+  /** Test class with all fields nullable=false, ref=false for size comparison 
*/
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AllNonNullableNoRef {
+    @ForyField(id = 0, nullable = false, ref = false)
+    String field1;
+
+    @ForyField(id = 1, nullable = false, ref = false)
+    String field2;
+
+    @ForyField(id = 2, nullable = false, ref = false)
+    String field3;
+  }
+
+  /** Test class with all fields nullable=true, ref=false for size comparison 
*/
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AllNullableNoRef {
+    @ForyField(id = 0, nullable = true, ref = false)
+    String field1;
+
+    @ForyField(id = 1, nullable = true, ref = false)
+    String field2;
+
+    @ForyField(id = 2, nullable = true, ref = false)
+    String field3;
+  }
+
+  /** Test class with all fields nullable=false, ref=true for size comparison 
*/
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AllNonNullableWithRef {
+    @ForyField(id = 0, nullable = false, ref = true)
+    String field1;
+
+    @ForyField(id = 1, nullable = false, ref = true)
+    String field2;
+
+    @ForyField(id = 2, nullable = false, ref = true)
+    String field3;
+  }
+
+  /** Test class with all fields nullable=true, ref=true for size comparison */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class AllNullableWithRef {
+    @ForyField(id = 0, nullable = true, ref = true)
+    String field1;
+
+    @ForyField(id = 1, nullable = true, ref = true)
+    String field2;
+
+    @ForyField(id = 2, nullable = true, ref = true)
+    String field3;
+  }
+
+  @Test(dataProvider = "modes")
+  public void testNullableFlagReducesPayloadSize(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(AllNonNullableNoRef.class, "test.AllNonNullableNoRef");
+      fory.register(AllNullableNoRef.class, "test.AllNullableNoRef");
+    }
+
+    // Create objects with same data
+    AllNonNullableNoRef nonNullable = new AllNonNullableNoRef("value1", 
"value2", "value3");
+    AllNullableNoRef nullable = new AllNullableNoRef("value1", "value2", 
"value3");
+
+    byte[] bytesNonNullable = fory.serialize(nonNullable);
+    byte[] bytesNullable = fory.serialize(nullable);
+
+    // Verify deserialization works
+    AllNonNullableNoRef deserializedNonNullable =
+        (AllNonNullableNoRef) fory.deserialize(bytesNonNullable);
+    AllNullableNoRef deserializedNullable = (AllNullableNoRef) 
fory.deserialize(bytesNullable);
+
+    assertEquals(deserializedNonNullable.field1, "value1");
+    assertEquals(deserializedNonNullable.field2, "value2");
+    assertEquals(deserializedNonNullable.field3, "value3");
+    assertEquals(deserializedNullable.field1, "value1");
+    assertEquals(deserializedNullable.field2, "value2");
+    assertEquals(deserializedNullable.field3, "value3");
+
+    System.out.printf(
+        "Nullable flag test - %s/%s/codegen=%s/registered=%s - NonNullable: %d 
bytes, Nullable: %d bytes%n",
+        language,
+        compatibleMode,
+        codegen,
+        registered,
+        bytesNonNullable.length,
+        bytesNullable.length);
+
+    // nullable=false should produce smaller or equal payload
+    // Each nullable=true field adds 1 byte for null flag
+    assertTrue(
+        bytesNonNullable.length <= bytesNullable.length,
+        String.format(
+            "Expected non-nullable (%d bytes) to be <= nullable (%d bytes) in 
mode %s/%s/codegen=%s/registered=%s",
+            bytesNonNullable.length,
+            bytesNullable.length,
+            language,
+            compatibleMode,
+            codegen,
+            registered));
+  }
+
+  @Test(dataProvider = "modes")
+  public void testRefFlagReducesPayloadSize(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(AllNonNullableNoRef.class, "test.AllNonNullableNoRef");
+      fory.register(AllNonNullableWithRef.class, "test.AllNonNullableWithRef");
+    }
+
+    // Create objects with same data
+    AllNonNullableNoRef noRef = new AllNonNullableNoRef("value1", "value2", 
"value3");
+    AllNonNullableWithRef withRef = new AllNonNullableWithRef("value1", 
"value2", "value3");
+
+    byte[] bytesNoRef = fory.serialize(noRef);
+    byte[] bytesWithRef = fory.serialize(withRef);
+
+    // Verify deserialization works
+    AllNonNullableNoRef deserializedNoRef = (AllNonNullableNoRef) 
fory.deserialize(bytesNoRef);
+    AllNonNullableWithRef deserializedWithRef =
+        (AllNonNullableWithRef) fory.deserialize(bytesWithRef);
+
+    assertEquals(deserializedNoRef.field1, "value1");
+    assertEquals(deserializedNoRef.field2, "value2");
+    assertEquals(deserializedNoRef.field3, "value3");
+    assertEquals(deserializedWithRef.field1, "value1");
+    assertEquals(deserializedWithRef.field2, "value2");
+    assertEquals(deserializedWithRef.field3, "value3");
+
+    System.out.printf(
+        "Ref flag test - %s/%s/codegen=%s/registered=%s - NoRef: %d bytes, 
WithRef: %d bytes%n",
+        language, compatibleMode, codegen, registered, bytesNoRef.length, 
bytesWithRef.length);
+
+    // ref=false should produce smaller or equal payload
+    // Each ref=true field may add overhead for reference tracking
+    assertTrue(
+        bytesNoRef.length <= bytesWithRef.length,
+        String.format(
+            "Expected no-ref (%d bytes) to be <= with-ref (%d bytes) in mode 
%s/%s/codegen=%s/registered=%s",
+            bytesNoRef.length, bytesWithRef.length, language, compatibleMode, 
codegen, registered));
+  }
+
+  @Test(dataProvider = "modes")
+  public void testCombinedNullableAndRefFlagsReducePayloadSize(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(AllNonNullableNoRef.class, "test.AllNonNullableNoRef");
+      fory.register(AllNullableWithRef.class, "test.AllNullableWithRef");
+    }
+
+    // Create objects with same data
+    // Most optimized: nullable=false, ref=false
+    AllNonNullableNoRef optimized = new AllNonNullableNoRef("value1", 
"value2", "value3");
+    // Least optimized: nullable=true, ref=true
+    AllNullableWithRef unoptimized = new AllNullableWithRef("value1", 
"value2", "value3");
+
+    byte[] bytesOptimized = fory.serialize(optimized);
+    byte[] bytesUnoptimized = fory.serialize(unoptimized);
+
+    // Verify deserialization works
+    AllNonNullableNoRef deserializedOptimized =
+        (AllNonNullableNoRef) fory.deserialize(bytesOptimized);
+    AllNullableWithRef deserializedUnoptimized =
+        (AllNullableWithRef) fory.deserialize(bytesUnoptimized);
+
+    assertEquals(deserializedOptimized.field1, "value1");
+    assertEquals(deserializedOptimized.field2, "value2");
+    assertEquals(deserializedOptimized.field3, "value3");
+    assertEquals(deserializedUnoptimized.field1, "value1");
+    assertEquals(deserializedUnoptimized.field2, "value2");
+    assertEquals(deserializedUnoptimized.field3, "value3");
+
+    System.out.printf(
+        "Combined flags test - %s/%s/codegen=%s/registered=%s - Optimized: %d 
bytes, Unoptimized: %d bytes, Savings: %d bytes (%.1f%%)%n",
+        language,
+        compatibleMode,
+        codegen,
+        registered,
+        bytesOptimized.length,
+        bytesUnoptimized.length,
+        bytesUnoptimized.length - bytesOptimized.length,
+        100.0 * (bytesUnoptimized.length - bytesOptimized.length) / 
bytesUnoptimized.length);
+
+    // Optimized (nullable=false, ref=false) should be smaller than 
unoptimized (nullable=true,
+    // ref=true)
+    assertTrue(
+        bytesOptimized.length < bytesUnoptimized.length,
+        String.format(
+            "Expected optimized (nullable=false,ref=false) %d bytes to be < 
unoptimized (nullable=true,ref=true) %d bytes in mode 
%s/%s/codegen=%s/registered=%s",
+            bytesOptimized.length,
+            bytesUnoptimized.length,
+            language,
+            compatibleMode,
+            codegen,
+            registered));
+  }
+
+  /** Version 1 of Person class for schema evolution test */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonV1 {
+    @ForyField(id = 0, nullable = false)
+    String name;
+
+    @ForyField(id = 1, nullable = false)
+    int age;
+  }
+
+  /** Version 2 of Person class for schema evolution test */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PersonV2 {
+    @ForyField(id = 0, nullable = false)
+    String name;
+
+    @ForyField(id = 1, nullable = false)
+    int age;
+
+    @ForyField(id = 2, nullable = true) // New optional field
+    String email;
+  }
+
+  @Test
+  public void testSchemaEvolutionWithTagIds() {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(Language.JAVA)
+            .withCompatibleMode(CompatibleMode.COMPATIBLE)
+            .requireClassRegistration(false)
+            .build();
+
+    // Serialize with V1
+    PersonV1 personV1 = new PersonV1("Alice", 30);
+    byte[] bytesV1 = fory.serialize(personV1);
+
+    // Note: Schema evolution across different class types requires XLANG mode 
with proper
+    // tag ID support. In JAVA mode, we can only test 
serialization/deserialization
+    // of the same class version. The tag IDs are stored in metadata but not 
used
+    // for field matching in JAVA mode.
+
+    PersonV1 deserialized = (PersonV1) fory.deserialize(bytesV1);
+    assertEquals(deserialized.name, "Alice");
+    assertEquals(deserialized.age, 30);
+
+    // Serialize with V2
+    PersonV2 personV2Full = new PersonV2("Bob", 25, "[email protected]");
+    byte[] bytesV2 = fory.serialize(personV2Full);
+
+    PersonV2 deserializedV2 = (PersonV2) fory.deserialize(bytesV2);
+    assertEquals(deserializedV2.name, "Bob");
+    assertEquals(deserializedV2.age, 25);
+    assertEquals(deserializedV2.email, "[email protected]");
+
+    System.out.printf(
+        "Schema evolution test - V1: %d bytes, V2: %d bytes%n", 
bytesV1.length, bytesV2.length);
+  }
+
+  /**
+   * Comprehensive test for nested objects with @ForyField tag IDs. Verifies 
that: 1. Field IDs are
+   * written instead of field names for nested object fields 2. Nested object 
class IDs (if
+   * registered) are written instead of class names/types 3. Payload size is 
smaller when using tag
+   * IDs 4. Deserialization works correctly
+   */
+  @Test(dataProvider = "modes")
+  public void testNestedObjectsWithTagIds(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(ContainerWithTagIds.class, "test.ContainerWithTagIds");
+      fory.register(ContainerWithoutTagIds.class, 
"test.ContainerWithoutTagIds");
+      fory.register(VeryLongNestedObjectClassName.class, 
"test.VeryLongNestedObjectClassName");
+      fory.register(
+          AnotherVeryLongNestedObjectClassName.class, 
"test.AnotherVeryLongNestedObjectClassName");
+    }
+
+    // Create nested objects with same data
+    VeryLongNestedObjectClassName nested1 = new 
VeryLongNestedObjectClassName("value1", 42);
+    AnotherVeryLongNestedObjectClassName nested2 =
+        new AnotherVeryLongNestedObjectClassName("description1");
+
+    ContainerWithTagIds containerWithTags =
+        new ContainerWithTagIds(nested1, nested2, "simpleValue");
+    ContainerWithoutTagIds containerWithoutTags =
+        new ContainerWithoutTagIds(nested1, nested2, "simpleValue");
+
+    // Serialize both
+    byte[] bytesWithTags = fory.serialize(containerWithTags);
+    byte[] bytesWithoutTags = fory.serialize(containerWithoutTags);
+
+    // Verify deserialization works
+    ContainerWithTagIds deserializedWithTags =
+        (ContainerWithTagIds) fory.deserialize(bytesWithTags);
+    ContainerWithoutTagIds deserializedWithoutTags =
+        (ContainerWithoutTagIds) fory.deserialize(bytesWithoutTags);
+
+    // Verify nested object values
+    assertEquals(
+        deserializedWithTags.veryLongFieldNameForNestedObject.value,
+        "value1",
+        "Nested object value should match");
+    assertEquals(
+        deserializedWithTags.veryLongFieldNameForNestedObject.count,
+        42,
+        "Nested object count should match");
+    assertEquals(
+        
deserializedWithTags.anotherVeryLongFieldNameForAnotherNestedObject.description,
+        "description1",
+        "Another nested object description should match");
+    assertEquals(deserializedWithTags.simpleField, "simpleValue", "Simple 
field should match");
+
+    assertEquals(
+        deserializedWithoutTags.veryLongFieldNameForNestedObject.value,
+        "value1",
+        "Nested object value should match");
+    assertEquals(
+        deserializedWithoutTags.veryLongFieldNameForNestedObject.count,
+        42,
+        "Nested object count should match");
+    assertEquals(
+        
deserializedWithoutTags.anotherVeryLongFieldNameForAnotherNestedObject.description,
+        "description1",
+        "Another nested object description should match");
+    assertEquals(deserializedWithoutTags.simpleField, "simpleValue", "Simple 
field should match");
+
+    System.out.printf(
+        "Nested objects test - %s/%s/codegen=%s/registered=%s - With tags: %d 
bytes, Without tags: %d bytes%n",
+        language,
+        compatibleMode,
+        codegen,
+        registered,
+        bytesWithTags.length,
+        bytesWithoutTags.length);
+
+    // Tag IDs should produce smaller payload in all modes
+    assertTrue(
+        bytesWithTags.length < bytesWithoutTags.length,
+        String.format(
+            "Expected nested objects with tag IDs (%d bytes) to be < without 
tag IDs (%d bytes) in %s/%s/codegen=%s/registered=%s",
+            bytesWithTags.length,
+            bytesWithoutTags.length,
+            language,
+            compatibleMode,
+            codegen,
+            registered));
+
+    // Print savings information
+    System.out.printf(
+        "  Savings from tag IDs: %d bytes (%.1f%%)%n",
+        bytesWithoutTags.length - bytesWithTags.length,
+        100.0 * (bytesWithoutTags.length - bytesWithTags.length) / 
bytesWithoutTags.length);
+  }
+
+  /**
+   * Test that verifies field IDs are written in the payload (not field 
names). This test inspects
+   * the raw bytes to confirm the serialization format.
+   */
+  @Test(dataProvider = "modes")
+  public void testNestedObjectFieldIdInPayload(
+      Language language, CompatibleMode compatibleMode, boolean codegen, 
boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCompatibleMode(compatibleMode)
+            .withCodegen(codegen)
+            .build();
+
+    if (registered) {
+      fory.register(ContainerWithTagIds.class, "test.ContainerWithTagIds");
+      fory.register(VeryLongNestedObjectClassName.class, 
"test.VeryLongNestedObjectClassName");
+      fory.register(
+          AnotherVeryLongNestedObjectClassName.class, 
"test.AnotherVeryLongNestedObjectClassName");
+    }
+
+    VeryLongNestedObjectClassName nested1 = new 
VeryLongNestedObjectClassName("test", 1);
+    AnotherVeryLongNestedObjectClassName nested2 = new 
AnotherVeryLongNestedObjectClassName("desc");
+
+    ContainerWithTagIds container = new ContainerWithTagIds(nested1, nested2, 
"simple");
+
+    byte[] bytes = fory.serialize(container);
+
+    // Verify deserialization
+    ContainerWithTagIds deserialized = (ContainerWithTagIds) 
fory.deserialize(bytes);
+    assertEquals(deserialized.veryLongFieldNameForNestedObject.value, "test");
+    assertEquals(deserialized.veryLongFieldNameForNestedObject.count, 1);
+    
assertEquals(deserialized.anotherVeryLongFieldNameForAnotherNestedObject.description,
 "desc");
+    assertEquals(deserialized.simpleField, "simple");
+
+    // When using tag IDs with @ForyField, field names should NOT be in payload
+    // This works in all modes: JAVA/XLANG and COMPATIBLE/SCHEMA_CONSISTENT
+    String serialized = new String(bytes, StandardCharsets.UTF_8);
+
+    // These long field names should not be present because we're using field 
IDs (0, 1, 2)
+    boolean hasLongFieldName1 = 
serialized.contains("veryLongFieldNameForNestedObject");
+    boolean hasLongFieldName2 =
+        serialized.contains("anotherVeryLongFieldNameForAnotherNestedObject");
+
+    assertFalse(
+        hasLongFieldName1,
+        String.format(
+            "Field name 'veryLongFieldNameForNestedObject' should NOT be in 
payload with tag ID in %s/%s/codegen=%s/registered=%s",
+            language, compatibleMode, codegen, registered));
+    assertFalse(
+        hasLongFieldName2,
+        String.format(
+            "Field name 'anotherVeryLongFieldNameForAnotherNestedObject' 
should NOT be in payload with tag ID in %s/%s/codegen=%s/registered=%s",
+            language, compatibleMode, codegen, registered));
+
+    System.out.printf(
+        "Verified: Field IDs used instead of field names in 
%s/%s/codegen=%s/registered=%s%n",
+        language, compatibleMode, codegen, registered);
+  }
+}
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTagIdTest.java
 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTagIdTest.java
new file mode 100644
index 000000000..f96f64d39
--- /dev/null
+++ 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTagIdTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.annotation;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import lombok.Data;
+import org.apache.fory.Fory;
+import org.apache.fory.ForyTestBase;
+import org.apache.fory.config.Language;
+import org.apache.fory.meta.ClassDef;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+public class ForyFieldTagIdTest extends ForyTestBase {
+
+  @Data
+  public static class TestClass {
+    @ForyField(id = 0, nullable = false)
+    public String fieldWithTag0;
+
+    @ForyField(id = 5, nullable = false)
+    public String fieldWithTag5;
+
+    @ForyField(id = -1, nullable = false)
+    public String fieldOptingOutOfTag;
+
+    public String fieldWithoutAnnotation;
+  }
+
+  @Test(dataProvider = "languageAndCodegen")
+  public void testFieldInfoCreationWithTagIds(
+      Language language, boolean codegen, boolean registered) {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(language)
+            .requireClassRegistration(registered)
+            .withCodegen(codegen)
+            .build();
+
+    if (language == Language.XLANG) {
+      fory.register(TestClass.class, "test.TestClass");
+    }
+
+    ClassDef classDef = ClassDef.buildClassDef(fory, TestClass.class);
+    List<ClassDef.FieldInfo> fieldsInfo = classDef.getFieldsInfo();
+
+    // Should have 4 fields
+    assertEquals(fieldsInfo.size(), 4);
+
+    // Find each field by name and verify tag behavior
+    ClassDef.FieldInfo field0 = findFieldByName(fieldsInfo, "fieldWithTag0");
+    ClassDef.FieldInfo field5 = findFieldByName(fieldsInfo, "fieldWithTag5");
+    ClassDef.FieldInfo fieldOptOut = findFieldByName(fieldsInfo, 
"fieldOptingOutOfTag");
+    ClassDef.FieldInfo fieldNoAnnotation = findFieldByName(fieldsInfo, 
"fieldWithoutAnnotation");
+
+    // Verify field with id=0 has tag
+    assertTrue(field0.hasFieldId(), "Field with id=0 should have tag in " + 
language + " mode");
+    assertEquals(
+        field0.getFieldId(),
+        (short) 0,
+        "Field with id=0 should have tag value 0 in " + language + " mode");
+
+    // Verify field with id=5 has tag
+    assertTrue(field5.hasFieldId(), "Field with id=5 should have tag in " + 
language + " mode");
+    assertEquals(
+        field5.getFieldId(),
+        (short) 5,
+        "Field with id=5 should have tag value 5 in " + language + " mode");
+
+    // Verify field with id=-1 does NOT have tag (opts out)
+    assertFalse(
+        fieldOptOut.hasFieldId(),
+        "Field with id=-1 should NOT have tag (opt-out) in " + language + " 
mode");
+    assertEquals(
+        fieldOptOut.getFieldName(),
+        "fieldOptingOutOfTag",
+        "Field with id=-1 should use field name in " + language + " mode");
+
+    // Verify field without annotation does NOT have tag
+    assertFalse(
+        fieldNoAnnotation.hasFieldId(),
+        "Field without annotation should NOT have tag (use field name) in " + 
language + " mode");
+    assertEquals(
+        fieldNoAnnotation.getFieldName(),
+        "fieldWithoutAnnotation",
+        "Field without annotation should use field name in " + language + " 
mode");
+  }
+
+  @DataProvider(name = "languageAndCodegen")
+  public Object[][] languageAndCodegen() {
+    return new Object[][] {
+      {Language.JAVA, false, false},
+      {Language.JAVA, false, true},
+      {Language.JAVA, true, false},
+      {Language.JAVA, true, true},
+      {Language.XLANG, false, true},
+      {Language.XLANG, true, true},
+    };
+  }
+
+  @Test
+  public void testTagIdAnnotationValues() throws Exception {
+    // Directly test that annotation reading works correctly
+    Field field0 = TestClass.class.getDeclaredField("fieldWithTag0");
+    Field field5 = TestClass.class.getDeclaredField("fieldWithTag5");
+    Field fieldOptOut = 
TestClass.class.getDeclaredField("fieldOptingOutOfTag");
+    Field fieldNoAnnotation = 
TestClass.class.getDeclaredField("fieldWithoutAnnotation");
+
+    ForyField annotation0 = field0.getAnnotation(ForyField.class);
+    ForyField annotation5 = field5.getAnnotation(ForyField.class);
+    ForyField annotationOptOut = fieldOptOut.getAnnotation(ForyField.class);
+    ForyField annotationNoAnnotation = 
fieldNoAnnotation.getAnnotation(ForyField.class);
+
+    // Verify annotation values
+    assertEquals(annotation0.id(), 0, "Field 0 should have id=0");
+    assertEquals(annotation5.id(), 5, "Field 5 should have id=5");
+    assertEquals(annotationOptOut.id(), -1, "Opt-out field should have id=-1");
+    assertNull(
+        annotationNoAnnotation, "Field without annotation should have no 
ForyField annotation");
+  }
+
+  @Test
+  public void testIdMinusOneOptOutBehavior() {
+    // Test that id=-1 explicitly opts out of tag ID usage
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).requireClassRegistration(false).build();
+    fory.register(TestClass.class, "test.TestClass");
+
+    TestClass obj = new TestClass();
+    obj.setFieldWithTag0("value0");
+    obj.setFieldWithTag5("value5");
+    obj.setFieldOptingOutOfTag("optOutValue");
+    obj.setFieldWithoutAnnotation("noAnnotationValue");
+
+    // Serialize and deserialize
+    byte[] bytes = fory.serialize(obj);
+    TestClass deserialized = (TestClass) fory.deserialize(bytes);
+
+    // All fields should deserialize correctly
+    assertEquals(deserialized.getFieldWithTag0(), "value0");
+    assertEquals(deserialized.getFieldWithTag5(), "value5");
+    assertEquals(deserialized.getFieldOptingOutOfTag(), "optOutValue");
+    assertEquals(deserialized.getFieldWithoutAnnotation(), 
"noAnnotationValue");
+  }
+
+  /** Helper method to find a FieldInfo by field name */
+  private ClassDef.FieldInfo findFieldByName(List<ClassDef.FieldInfo> 
fieldsInfo, String name) {
+    for (ClassDef.FieldInfo fieldInfo : fieldsInfo) {
+      if (fieldInfo.getFieldName().equals(name)) {
+        return fieldInfo;
+      }
+    }
+    throw new AssertionError("Field not found: " + name);
+  }
+}
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTest.java 
b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTest.java
new file mode 100644
index 000000000..a9b2a81ef
--- /dev/null
+++ b/java/fory-core/src/test/java/org/apache/fory/annotation/ForyFieldTest.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.annotation;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.fory.Fory;
+import org.apache.fory.ForyTestBase;
+import org.apache.fory.config.Language;
+import org.testng.annotations.Test;
+
+public class ForyFieldTest extends ForyTestBase {
+
+  /** Example 1: Simple Value Object */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class Point {
+    @ForyField(id = 0, nullable = false, ref = false)
+    public double x;
+
+    @ForyField(id = 1, nullable = false, ref = false)
+    public double y;
+  }
+
+  @Test
+  public void testSimpleValueObject() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    Point point = new Point(3.14, 2.71);
+    byte[] bytes = fory.serialize(point);
+    Point deserialized = (Point) fory.deserialize(bytes);
+    assertEquals(deserialized.x, 3.14);
+    assertEquals(deserialized.y, 2.71);
+  }
+
+  /** Example 2: Entity with Optional Fields */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class User {
+    @ForyField(id = 0, nullable = false, ref = false)
+    public long userId;
+
+    @ForyField(id = 1, nullable = false, ref = false)
+    public String username;
+
+    @ForyField(id = 2, nullable = true, ref = false)
+    public String email; // Can be null during account creation
+
+    @ForyField(id = 3, nullable = true, ref = false)
+    public String phoneNumber; // Optional contact method
+
+    @ForyField(id = 4, nullable = false, ref = false)
+    public long createdAt;
+  }
+
+  @Test
+  public void testEntityWithOptionalFields() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    User user = new User(123L, "john_doe", "[email protected]", null, 
System.currentTimeMillis());
+    byte[] bytes = fory.serialize(user);
+    User deserialized = (User) fory.deserialize(bytes);
+    assertEquals(deserialized.userId, 123L);
+    assertEquals(deserialized.username, "john_doe");
+    assertEquals(deserialized.email, "[email protected]");
+    assertNull(deserialized.phoneNumber);
+  }
+
+  @Test
+  public void testEntityWithAllNullOptionalFields() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    User user = new User(456L, "jane_doe", null, null, 
System.currentTimeMillis());
+    byte[] bytes = fory.serialize(user);
+    User deserialized = (User) fory.deserialize(bytes);
+    assertEquals(deserialized.userId, 456L);
+    assertEquals(deserialized.username, "jane_doe");
+    assertNull(deserialized.email);
+    assertNull(deserialized.phoneNumber);
+  }
+
+  /** Example 3: Shared Object References */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class Customer {
+    @ForyField(id = 0, nullable = false, ref = false)
+    public long customerId;
+
+    @ForyField(id = 1, nullable = false, ref = false)
+    public String name;
+  }
+
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class Order {
+    @ForyField(id = 0, nullable = false, ref = false)
+    public long orderId;
+
+    @ForyField(id = 1, nullable = false, ref = true)
+    public Customer customer; // Same Customer might appear in many orders
+
+    @ForyField(id = 2, nullable = true, ref = false)
+    public String notes; // Unique per order
+  }
+
+  @Test
+  public void testSharedObjectReferences() {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(Language.JAVA)
+            .withRefTracking(true)
+            .requireClassRegistration(false)
+            .build();
+    Customer customer = new Customer(1L, "Alice");
+    Order order1 = new Order(100L, customer, "First order");
+    Order order2 = new Order(101L, customer, null);
+
+    // Serialize orders that share the same customer
+    List<Order> orders = new ArrayList<>();
+    orders.add(order1);
+    orders.add(order2);
+
+    byte[] bytes = fory.serialize(orders);
+    @SuppressWarnings("unchecked")
+    List<Order> deserialized = (List<Order>) fory.deserialize(bytes);
+
+    assertEquals(deserialized.size(), 2);
+    assertEquals(deserialized.get(0).orderId, 100L);
+    assertEquals(deserialized.get(1).orderId, 101L);
+    assertEquals(deserialized.get(0).customer.customerId, 1L);
+    assertEquals(deserialized.get(1).customer.customerId, 1L);
+    // Both orders should reference the same customer object due to ref=true
+    // (though this is more about serialization efficiency than behavior)
+  }
+
+  /** Test nullable defaults */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class DefaultNullableTest {
+    @ForyField(id = 0) // nullable defaults to false
+    public String field1;
+
+    @ForyField(id = 1, nullable = true)
+    public String field2;
+  }
+
+  @Test
+  public void testNullableDefaults() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    DefaultNullableTest obj = new DefaultNullableTest("value1", null);
+    byte[] bytes = fory.serialize(obj);
+    DefaultNullableTest deserialized = (DefaultNullableTest) 
fory.deserialize(bytes);
+    assertEquals(deserialized.field1, "value1");
+    assertNull(deserialized.field2);
+  }
+
+  /** Test ref defaults */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class DefaultRefTest {
+    @ForyField(id = 0) // ref defaults to false
+    public String field1;
+
+    @ForyField(id = 1, ref = true)
+    public String field2;
+  }
+
+  @Test
+  public void testRefDefaults() {
+    Fory fory =
+        Fory.builder()
+            .withLanguage(Language.JAVA)
+            .withRefTracking(true)
+            .requireClassRegistration(false)
+            .build();
+    DefaultRefTest obj = new DefaultRefTest("value1", "value2");
+    byte[] bytes = fory.serialize(obj);
+    DefaultRefTest deserialized = (DefaultRefTest) fory.deserialize(bytes);
+    assertEquals(deserialized.field1, "value1");
+    assertEquals(deserialized.field2, "value2");
+  }
+
+  /** Test mixed annotations and non-annotations */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class MixedFieldsTest {
+    @ForyField(id = 0, nullable = false)
+    public String annotatedField;
+
+    public String regularField; // No annotation
+
+    @ForyField(id = 1, nullable = true)
+    public String anotherAnnotatedField;
+  }
+
+  @Test
+  public void testMixedAnnotatedAndRegularFields() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    MixedFieldsTest obj = new MixedFieldsTest("annotated", "regular", null);
+    byte[] bytes = fory.serialize(obj);
+    MixedFieldsTest deserialized = (MixedFieldsTest) fory.deserialize(bytes);
+    assertEquals(deserialized.annotatedField, "annotated");
+    assertEquals(deserialized.regularField, "regular");
+    assertNull(deserialized.anotherAnnotatedField);
+  }
+
+  /** Test with primitive types */
+  @Data
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class PrimitiveFieldsTest {
+    @ForyField(id = 0) // primitives are never null
+    public int intField;
+
+    @ForyField(id = 1)
+    public long longField;
+
+    @ForyField(id = 2)
+    public boolean booleanField;
+
+    @ForyField(id = 3, nullable = false) // Boxed type marked as non-nullable
+    public Integer boxedInt;
+  }
+
+  @Test
+  public void testPrimitiveFields() {
+    Fory fory = 
Fory.builder().withLanguage(Language.JAVA).requireClassRegistration(false).build();
+    PrimitiveFieldsTest obj = new PrimitiveFieldsTest(42, 123456789L, true, 
99);
+    byte[] bytes = fory.serialize(obj);
+    PrimitiveFieldsTest deserialized = (PrimitiveFieldsTest) 
fory.deserialize(bytes);
+    assertEquals(deserialized.intField, 42);
+    assertEquals(deserialized.longField, 123456789L);
+    assertTrue(deserialized.booleanField);
+    assertEquals(deserialized.boxedInt, Integer.valueOf(99));
+  }
+}
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefEncoderTest.java 
b/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefEncoderTest.java
index 170c5a50e..356732d0b 100644
--- a/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefEncoderTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefEncoderTest.java
@@ -26,6 +26,7 @@ import java.io.Serializable;
 import java.util.List;
 import lombok.Data;
 import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
 import org.apache.fory.config.CompatibleMode;
 import org.apache.fory.config.Language;
 import org.apache.fory.memory.MemoryBuffer;
@@ -38,7 +39,7 @@ import org.testng.annotations.Test;
 public class ClassDefEncoderTest {
 
   @Test
-  public void testBasicClassDef() throws Exception {
+  public void testBasicClassDef() {
     Fory fory = Fory.builder().withMetaShare(true).build();
     Class<ClassDefTest.TestFieldsOrderClass1> type = 
ClassDefTest.TestFieldsOrderClass1.class;
     List<ClassDef.FieldInfo> fieldsInfo = 
buildFieldsInfo(fory.getClassResolver(), type);
@@ -170,4 +171,73 @@ public class ClassDefEncoderTest {
   public static class ChildClass extends BaseAbstractClass {
     private String name;
   }
+
+  // Test classes for duplicate tag ID validation in ClassDefEncoder
+  @Data
+  public static class ClassWithDuplicateTagIds {
+    @ForyField(id = 10)
+    private String fieldA;
+
+    @ForyField(id = 10)
+    private String fieldB;
+
+    @ForyField(id = 20)
+    private int fieldC;
+  }
+
+  @Data
+  public static class ClassWithValidTagIds {
+    @ForyField(id = 10)
+    private String fieldA;
+
+    @ForyField(id = 20)
+    private String fieldB;
+
+    @ForyField(id = 30)
+    private int fieldC;
+  }
+
+  @Data
+  public static class ClassWithMixedFields {
+    @ForyField(id = 15)
+    private String annotatedField1;
+
+    private String noAnnotation;
+
+    @ForyField(id = 15) // Duplicate with annotatedField1
+    private int annotatedField2;
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithDuplicateTagIds() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () -> buildFieldsInfo(fory.getClassResolver(), 
ClassWithDuplicateTagIds.class));
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithValidTagIds() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Should not throw any exception
+    List<ClassDef.FieldInfo> fieldsInfo =
+        buildFieldsInfo(fory.getClassResolver(), ClassWithValidTagIds.class);
+
+    Assert.assertEquals(fieldsInfo.size(), 3);
+    // Verify all fields have the correct tag IDs
+    for (ClassDef.FieldInfo fieldInfo : fieldsInfo) {
+      Assert.assertTrue(fieldInfo.hasFieldId());
+    }
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithMixedFields() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () -> buildFieldsInfo(fory.getClassResolver(), 
ClassWithMixedFields.class));
+  }
 }
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefTest.java 
b/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefTest.java
index db45a58d7..25a57d0f5 100644
--- a/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/meta/ClassDefTest.java
@@ -33,11 +33,14 @@ import java.util.Map;
 import java.util.TreeSet;
 import org.apache.fory.Fory;
 import org.apache.fory.ForyTestBase;
+import org.apache.fory.annotation.ForyField;
 import org.apache.fory.memory.MemoryBuffer;
 import org.apache.fory.reflect.ReflectionUtils;
 import org.apache.fory.reflect.TypeRef;
 import org.apache.fory.resolver.ClassResolver;
 import org.apache.fory.test.bean.Foo;
+import org.apache.fory.type.Descriptor;
+import org.testng.Assert;
 import org.testng.annotations.Test;
 
 public class ClassDefTest extends ForyTestBase {
@@ -188,4 +191,308 @@ public class ClassDefTest extends ForyTestBase {
     assertTrue(classResolver.needToWriteRef(TypeRef.of(Foo.class, new 
TypeExtMeta(true, true))));
     assertFalse(classResolver.needToWriteRef(TypeRef.of(Foo.class, new 
TypeExtMeta(true, false))));
   }
+
+  // Test classes for duplicate tag ID validation
+  static class ClassWithDuplicateTagIds {
+    @ForyField(id = 1)
+    private String field1;
+
+    @ForyField(id = 1)
+    private String field2;
+
+    @ForyField(id = 2)
+    private int field3;
+  }
+
+  static class ClassWithDuplicateTagIdsMultiple {
+    @ForyField(id = 5)
+    private String field1;
+
+    @ForyField(id = 5)
+    private String field2;
+
+    @ForyField(id = 5)
+    private int field3;
+  }
+
+  static class ClassWithValidTagIds {
+    @ForyField(id = 1)
+    private String field1;
+
+    @ForyField(id = 2)
+    private String field2;
+
+    @ForyField(id = 3)
+    private int field3;
+  }
+
+  @Test
+  public void testDuplicateTagIdsThrowsException() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+    List<Field> fields = 
ReflectionUtils.getFields(ClassWithDuplicateTagIds.class, true);
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClassDef.buildClassDef(
+                fory.getClassResolver(), ClassWithDuplicateTagIds.class, 
fields));
+  }
+
+  @Test
+  public void testDuplicateTagIdsMultipleThrowsException() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+    List<Field> fields = 
ReflectionUtils.getFields(ClassWithDuplicateTagIdsMultiple.class, true);
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            ClassDef.buildClassDef(
+                fory.getClassResolver(), 
ClassWithDuplicateTagIdsMultiple.class, fields));
+  }
+
+  @Test
+  public void testValidTagIdsSucceeds() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+    List<Field> fields = ReflectionUtils.getFields(ClassWithValidTagIds.class, 
true);
+
+    // Should not throw any exception
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
ClassWithValidTagIds.class, fields);
+    assertEquals(classDef.getClassName(), 
ClassWithValidTagIds.class.getName());
+    assertEquals(classDef.getFieldsInfo().size(), fields.size());
+  }
+
+  // Test classes for getDescriptors method
+  static class TargetClassWithDuplicateTagIds {
+    @ForyField(id = 100)
+    private String field1;
+
+    @ForyField(id = 100) // Duplicate tag ID
+    private String field2;
+
+    @ForyField(id = 200)
+    private int field3;
+  }
+
+  static class TargetClassWithValidTags {
+    @ForyField(id = 10)
+    private String taggedField1;
+
+    @ForyField(id = 20)
+    private int taggedField2;
+
+    private String normalField;
+  }
+
+  static class TargetClassWithMixedTags {
+    @ForyField(id = 50)
+    private String field1;
+
+    private String field2;
+
+    @ForyField(id = 60)
+    private int field3;
+  }
+
+  @Test
+  public void testGetDescriptorsWithDuplicateTagIds() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with valid fields (no duplicates in ClassDef itself)
+    List<Field> sourceFields = 
ReflectionUtils.getFields(ClassWithValidTagIds.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
ClassWithValidTagIds.class, sourceFields);
+
+    // Try to get descriptors for a class that has duplicate tag IDs
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            classDef.getDescriptors(fory.getClassResolver(), 
TargetClassWithDuplicateTagIds.class));
+  }
+
+  @Test
+  public void testGetDescriptorsWithValidTags() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with tagged fields
+    List<Field> sourceFields = 
ReflectionUtils.getFields(TargetClassWithValidTags.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(
+            fory.getClassResolver(), TargetClassWithValidTags.class, 
sourceFields);
+
+    // Get descriptors should succeed
+    List<Descriptor> descriptors =
+        classDef.getDescriptors(fory.getClassResolver(), 
TargetClassWithValidTags.class);
+
+    assertEquals(descriptors.size(), 3);
+  }
+
+  @Test
+  public void testGetDescriptorsWithMixedTags() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with mixed tagged and non-tagged fields
+    List<Field> sourceFields = 
ReflectionUtils.getFields(TargetClassWithMixedTags.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(
+            fory.getClassResolver(), TargetClassWithMixedTags.class, 
sourceFields);
+
+    // Get descriptors should succeed
+    List<Descriptor> descriptors =
+        classDef.getDescriptors(fory.getClassResolver(), 
TargetClassWithMixedTags.class);
+
+    assertEquals(descriptors.size(), 3);
+
+    // Verify that tagged fields are matched by tag, not by name
+    boolean foundField1 = false;
+    boolean foundField2 = false;
+    boolean foundField3 = false;
+
+    for (Descriptor desc : descriptors) {
+      if (desc.getName().equals("field1")) {
+        foundField1 = true;
+      } else if (desc.getName().equals("field2")) {
+        foundField2 = true;
+      } else if (desc.getName().equals("field3")) {
+        foundField3 = true;
+      }
+    }
+
+    assertTrue(foundField1);
+    assertTrue(foundField2);
+    assertTrue(foundField3);
+  }
+
+  static class SourceClassWithTags {
+    @ForyField(id = 100)
+    private String renamedField; // This will be matched by tag ID
+
+    @ForyField(id = 200)
+    private int anotherField;
+  }
+
+  static class TargetClassWithDifferentNames {
+    @ForyField(id = 100)
+    private String differentName; // Same tag ID as renamedField
+
+    @ForyField(id = 200)
+    private int alsoRenamed; // Same tag ID as anotherField
+  }
+
+  @Test
+  public void testGetDescriptorsMatchesByTagNotName() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef from source class with specific tag IDs
+    List<Field> sourceFields = 
ReflectionUtils.getFields(SourceClassWithTags.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
SourceClassWithTags.class, sourceFields);
+
+    // Get descriptors for target class with different field names but same 
tag IDs
+    List<Descriptor> descriptors =
+        classDef.getDescriptors(fory.getClassResolver(), 
TargetClassWithDifferentNames.class);
+
+    // Should match fields by tag ID, not by name
+    assertEquals(descriptors.size(), 2);
+
+    // Verify the descriptors were matched correctly (by tag, not name)
+    // When matched by tag, descriptors will have the target class field 
information
+    for (Descriptor desc : descriptors) {
+      // The descriptor should have the field from the target class since it 
was matched by tag
+      assertTrue(
+          desc.getName().equals("differentName") || 
desc.getName().equals("alsoRenamed"),
+          "Descriptor name should match target class field names when matched 
by tag ID");
+    }
+  }
+
+  static class TargetClassWithZeroTagId {
+    @ForyField(id = 0)
+    private String field1;
+
+    @ForyField(id = 0) // Duplicate tag ID 0
+    private String field2;
+  }
+
+  @Test
+  public void testGetDescriptorsWithDuplicateZeroTagIds() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with some fields
+    List<Field> sourceFields = 
ReflectionUtils.getFields(ClassWithValidTagIds.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
ClassWithValidTagIds.class, sourceFields);
+
+    // Try to get descriptors for a class that has duplicate tag ID 0
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () -> classDef.getDescriptors(fory.getClassResolver(), 
TargetClassWithZeroTagId.class));
+  }
+
+  static class EmptyClass {
+    // No fields
+  }
+
+  @Test
+  public void testGetDescriptorsWithEmptyClass() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with no fields
+    List<Field> sourceFields = ReflectionUtils.getFields(EmptyClass.class, 
true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), EmptyClass.class, 
sourceFields);
+
+    // Get descriptors should succeed and return empty list
+    List<Descriptor> descriptors =
+        classDef.getDescriptors(fory.getClassResolver(), EmptyClass.class);
+
+    assertEquals(descriptors.size(), 0);
+  }
+
+  static class InheritedBaseClass {
+    @ForyField(id = 10)
+    private String baseField;
+  }
+
+  static class InheritedChildClass extends InheritedBaseClass {
+    @ForyField(id = 20)
+    private String childField;
+  }
+
+  static class InheritedChildWithDuplicateTag extends InheritedBaseClass {
+    @ForyField(id = 10) // Duplicate with baseField
+    private String childField;
+  }
+
+  @Test
+  public void testGetDescriptorsWithInheritance() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with inherited fields
+    List<Field> sourceFields = 
ReflectionUtils.getFields(InheritedChildClass.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
InheritedChildClass.class, sourceFields);
+
+    // Get descriptors should succeed
+    List<Descriptor> descriptors =
+        classDef.getDescriptors(fory.getClassResolver(), 
InheritedChildClass.class);
+
+    // Should have both base and child fields
+    assertEquals(descriptors.size(), 2);
+  }
+
+  @Test
+  public void testGetDescriptorsWithInheritedDuplicateTag() {
+    Fory fory = Fory.builder().withMetaShare(true).build();
+
+    // Build a ClassDef with some fields
+    List<Field> sourceFields = 
ReflectionUtils.getFields(InheritedBaseClass.class, true);
+    ClassDef classDef =
+        ClassDef.buildClassDef(fory.getClassResolver(), 
InheritedBaseClass.class, sourceFields);
+
+    // Try to get descriptors for a class that has duplicate tag ID across 
inheritance
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            classDef.getDescriptors(fory.getClassResolver(), 
InheritedChildWithDuplicateTag.class));
+  }
 }
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/meta/TypeDefEncoderTest.java 
b/java/fory-core/src/test/java/org/apache/fory/meta/TypeDefEncoderTest.java
new file mode 100644
index 000000000..e006935f0
--- /dev/null
+++ b/java/fory-core/src/test/java/org/apache/fory/meta/TypeDefEncoderTest.java
@@ -0,0 +1,369 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.fory.meta;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import lombok.Data;
+import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyField;
+import org.apache.fory.config.Language;
+import org.apache.fory.resolver.TypeResolver;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class TypeDefEncoderTest {
+
+  // Test data: Class with duplicate tag IDs (both set to 100)
+  @Data
+  public static class ClassWithDuplicateTagIds {
+    @ForyField(id = 100)
+    private String field1;
+
+    @ForyField(id = 100)
+    private String field2;
+
+    @ForyField(id = 200)
+    private int field3;
+  }
+
+  // Test data: Class with duplicate tag IDs set to 0
+  @Data
+  public static class ClassWithDuplicateTagIdsZero {
+    @ForyField(id = 0)
+    private String field1;
+
+    @ForyField(id = 0)
+    private int field2;
+  }
+
+  // Test data: Class with valid unique tag IDs
+  @Data
+  public static class ClassWithValidTagIds {
+    @ForyField(id = 100)
+    private String field1;
+
+    @ForyField(id = 200)
+    private String field2;
+
+    @ForyField(id = 300)
+    private int field3;
+  }
+
+  // Test data: Class with mixed annotations (with and without tags)
+  @Data
+  public static class ClassWithMixedAnnotations {
+    @ForyField(id = 50)
+    private String annotatedField1;
+
+    private String noAnnotation;
+
+    @ForyField(id = -1) // -1 means use field name
+    private String optOutField;
+
+    @ForyField(id = 60)
+    private int annotatedField2;
+  }
+
+  // Test data: Class with duplicate tag IDs in mixed scenario
+  @Data
+  public static class ClassWithMixedDuplicateTagIds {
+    @ForyField(id = 50)
+    private String annotatedField1;
+
+    private String noAnnotation;
+
+    @ForyField(id = -1) // -1 means use field name
+    private String optOutField;
+
+    @ForyField(id = 50) // Duplicate with annotatedField1
+    private int annotatedField2;
+  }
+
+  // Test data: Class with single field
+  @Data
+  public static class ClassWithSingleField {
+    @ForyField(id = 42)
+    private String field;
+  }
+
+  // Test data: Class with no annotations
+  @Data
+  public static class ClassWithNoAnnotations {
+    private String field1;
+    private int field2;
+    private double field3;
+  }
+
+  // Test data: Class with all fields using field names (tagId = -1)
+  @Data
+  public static class ClassWithAllFieldNames {
+    @ForyField(id = -1)
+    private String field1;
+
+    @ForyField(id = -1)
+    private int field2;
+
+    @ForyField(id = -1)
+    private double field3;
+  }
+
+  // Test data: Class with large tag IDs
+  @Data
+  public static class ClassWithLargeTagIds {
+    @ForyField(id = 32000)
+    private String field1;
+
+    @ForyField(id = 32767) // Max short value
+    private int field2;
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithDuplicateTagIds() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithDuplicateTagIds.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithDuplicateTagIds.class, "field1"),
+            getField(ClassWithDuplicateTagIds.class, "field2"),
+            getField(ClassWithDuplicateTagIds.class, "field3"));
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () -> TypeDefEncoder.buildFieldsInfo(resolver, 
ClassWithDuplicateTagIds.class, fields));
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithDuplicateTagIdsZero() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithDuplicateTagIdsZero.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithDuplicateTagIdsZero.class, "field1"),
+            getField(ClassWithDuplicateTagIdsZero.class, "field2"));
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () -> TypeDefEncoder.buildFieldsInfo(resolver, 
ClassWithDuplicateTagIdsZero.class, fields));
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithValidTagIds() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithValidTagIds.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithValidTagIds.class, "field1"),
+            getField(ClassWithValidTagIds.class, "field2"),
+            getField(ClassWithValidTagIds.class, "field3"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithValidTagIds.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 3);
+
+    // Verify all fields have the correct tag IDs
+    Assert.assertTrue(fieldInfos.get(0).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(0).getFieldId(), (short) 100);
+    Assert.assertEquals(fieldInfos.get(0).getFieldName(), "field1");
+
+    Assert.assertTrue(fieldInfos.get(1).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(1).getFieldId(), (short) 200);
+    Assert.assertEquals(fieldInfos.get(1).getFieldName(), "field2");
+
+    Assert.assertTrue(fieldInfos.get(2).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(2).getFieldId(), (short) 300);
+    Assert.assertEquals(fieldInfos.get(2).getFieldName(), "field3");
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithMixedAnnotations() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithMixedAnnotations.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithMixedAnnotations.class, "annotatedField1"),
+            getField(ClassWithMixedAnnotations.class, "noAnnotation"),
+            getField(ClassWithMixedAnnotations.class, "optOutField"),
+            getField(ClassWithMixedAnnotations.class, "annotatedField2"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, 
ClassWithMixedAnnotations.class, fields);
+
+    Assert.assertEquals(fieldInfos.size(), 4);
+
+    // annotatedField1 should have tag 50
+    Assert.assertTrue(fieldInfos.get(0).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(0).getFieldId(), (short) 50);
+    Assert.assertEquals(fieldInfos.get(0).getFieldName(), "annotatedField1");
+
+    // noAnnotation should not have a tag (uses field name)
+    Assert.assertFalse(fieldInfos.get(1).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(1).getFieldName(), "noAnnotation");
+
+    // optOutField with id=-1 should not have a tag (uses field name)
+    Assert.assertFalse(fieldInfos.get(2).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(2).getFieldName(), "optOutField");
+
+    // annotatedField2 should have tag 60
+    Assert.assertTrue(fieldInfos.get(3).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(3).getFieldId(), (short) 60);
+    Assert.assertEquals(fieldInfos.get(3).getFieldName(), "annotatedField2");
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithMixedDuplicateTagIds() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithMixedDuplicateTagIds.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithMixedDuplicateTagIds.class, "annotatedField1"),
+            getField(ClassWithMixedDuplicateTagIds.class, "noAnnotation"),
+            getField(ClassWithMixedDuplicateTagIds.class, "optOutField"),
+            getField(ClassWithMixedDuplicateTagIds.class, "annotatedField2"));
+
+    Assert.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TypeDefEncoder.buildFieldsInfo(resolver, 
ClassWithMixedDuplicateTagIds.class, fields));
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithSingleField() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithSingleField.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields = 
Collections.singletonList(getField(ClassWithSingleField.class, "field"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithSingleField.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 1);
+    Assert.assertTrue(fieldInfos.get(0).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(0).getFieldId(), (short) 42);
+    Assert.assertEquals(fieldInfos.get(0).getFieldName(), "field");
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithNoAnnotations() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithNoAnnotations.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithNoAnnotations.class, "field1"),
+            getField(ClassWithNoAnnotations.class, "field2"),
+            getField(ClassWithNoAnnotations.class, "field3"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithNoAnnotations.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 3);
+
+    // All fields should not have tags (use field names)
+    for (ClassDef.FieldInfo fieldInfo : fieldInfos) {
+      Assert.assertFalse(fieldInfo.hasFieldId());
+    }
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithAllFieldNames() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithAllFieldNames.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithAllFieldNames.class, "field1"),
+            getField(ClassWithAllFieldNames.class, "field2"),
+            getField(ClassWithAllFieldNames.class, "field3"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithAllFieldNames.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 3);
+
+    // All fields with id=-1 should not have tags (use field names)
+    for (ClassDef.FieldInfo fieldInfo : fieldInfos) {
+      Assert.assertFalse(fieldInfo.hasFieldId());
+    }
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithLargeTagIds() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithLargeTagIds.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields =
+        Arrays.asList(
+            getField(ClassWithLargeTagIds.class, "field1"),
+            getField(ClassWithLargeTagIds.class, "field2"));
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithLargeTagIds.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 2);
+
+    Assert.assertTrue(fieldInfos.get(0).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(0).getFieldId(), (short) 32000);
+
+    Assert.assertTrue(fieldInfos.get(1).hasFieldId());
+    Assert.assertEquals(fieldInfos.get(1).getFieldId(), (short) 32767);
+  }
+
+  @Test
+  public void testBuildFieldsInfoWithEmptyFieldList() {
+    Fory fory = 
Fory.builder().withLanguage(Language.XLANG).withMetaShare(true).build();
+    fory.register(ClassWithValidTagIds.class);
+    TypeResolver resolver = fory.getXtypeResolver();
+
+    List<Field> fields = Collections.emptyList();
+
+    List<ClassDef.FieldInfo> fieldInfos =
+        TypeDefEncoder.buildFieldsInfo(resolver, ClassWithValidTagIds.class, 
fields);
+
+    Assert.assertEquals(fieldInfos.size(), 0);
+  }
+
+  /** Helper method to get a field from a class by name. */
+  private Field getField(Class<?> clazz, String fieldName) {
+    try {
+      return clazz.getDeclaredField(fieldName);
+    } catch (NoSuchFieldException e) {
+      throw new RuntimeException(
+          "Field not found: " + fieldName + " in class " + clazz.getName(), e);
+    }
+  }
+}
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/type/DescriptorGrouperTest.java 
b/java/fory-core/src/test/java/org/apache/fory/type/DescriptorGrouperTest.java
index 99277da4c..015728887 100644
--- 
a/java/fory-core/src/test/java/org/apache/fory/type/DescriptorGrouperTest.java
+++ 
b/java/fory-core/src/test/java/org/apache/fory/type/DescriptorGrouperTest.java
@@ -41,13 +41,15 @@ public class DescriptorGrouperTest {
     List<Descriptor> descriptors = new ArrayList<>();
     int index = 0;
     for (Class<?> aClass : Primitives.allPrimitiveTypes()) {
-      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass"));
+      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass", false));
     }
     for (Class<?> t : Primitives.allWrapperTypes()) {
-      descriptors.add(new Descriptor(TypeRef.of(t), "f" + index++, -1, 
"TestClass"));
+      descriptors.add(new Descriptor(TypeRef.of(t), "f" + index++, -1, 
"TestClass", false));
     }
-    descriptors.add(new Descriptor(TypeRef.of(String.class), "f" + index++, 
-1, "TestClass"));
-    descriptors.add(new Descriptor(TypeRef.of(Object.class), "f" + index++, 
-1, "TestClass"));
+    descriptors.add(
+        new Descriptor(TypeRef.of(String.class), "f" + index++, -1, 
"TestClass", false));
+    descriptors.add(
+        new Descriptor(TypeRef.of(Object.class), "f" + index++, -1, 
"TestClass", false));
     Collections.shuffle(descriptors, new Random(17));
     return descriptors;
   }
@@ -88,7 +90,7 @@ public class DescriptorGrouperTest {
     List<Descriptor> descriptors = new ArrayList<>();
     int index = 0;
     for (Class<?> aClass : Primitives.allPrimitiveTypes()) {
-      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass"));
+      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass", false));
     }
     Collections.shuffle(descriptors, new Random(7));
     descriptors.sort(DescriptorGrouper.getPrimitiveComparator(false, false));
@@ -113,7 +115,7 @@ public class DescriptorGrouperTest {
     List<Descriptor> descriptors = new ArrayList<>();
     int index = 0;
     for (Class<?> aClass : Primitives.allPrimitiveTypes()) {
-      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass"));
+      descriptors.add(new Descriptor(TypeRef.of(aClass), "f" + index++, -1, 
"TestClass", false));
     }
     Collections.shuffle(descriptors, new Random(7));
     descriptors.sort(DescriptorGrouper.getPrimitiveComparator(true, true));
@@ -137,17 +139,23 @@ public class DescriptorGrouperTest {
   public void testGrouper() {
     List<Descriptor> descriptors = createDescriptors();
     int index = 0;
-    descriptors.add(new Descriptor(TypeRef.of(Object.class), "c" + index++, 
-1, "TestClass"));
-    descriptors.add(new Descriptor(TypeRef.of(Date.class), "c" + index++, -1, 
"TestClass"));
-    descriptors.add(new Descriptor(TypeRef.of(Instant.class), "c" + index++, 
-1, "TestClass"));
-    descriptors.add(new Descriptor(TypeRef.of(Instant.class), "c" + index++, 
-1, "TestClass"));
-    descriptors.add(new Descriptor(new TypeRef<List<String>>() {}, "c" + 
index++, -1, "TestClass"));
     descriptors.add(
-        new Descriptor(new TypeRef<List<Integer>>() {}, "c" + index++, -1, 
"TestClass"));
+        new Descriptor(TypeRef.of(Object.class), "c" + index++, -1, 
"TestClass", false));
+    descriptors.add(new Descriptor(TypeRef.of(Date.class), "c" + index++, -1, 
"TestClass", false));
+    descriptors.add(
+        new Descriptor(TypeRef.of(Instant.class), "c" + index++, -1, 
"TestClass", false));
+    descriptors.add(
+        new Descriptor(TypeRef.of(Instant.class), "c" + index++, -1, 
"TestClass", false));
+    descriptors.add(
+        new Descriptor(new TypeRef<List<String>>() {}, "c" + index++, -1, 
"TestClass", false));
+    descriptors.add(
+        new Descriptor(new TypeRef<List<Integer>>() {}, "c" + index++, -1, 
"TestClass", false));
     descriptors.add(
-        new Descriptor(new TypeRef<Map<String, Integer>>() {}, "c" + index++, 
-1, "TestClass"));
+        new Descriptor(
+            new TypeRef<Map<String, Integer>>() {}, "c" + index++, -1, 
"TestClass", false));
     descriptors.add(
-        new Descriptor(new TypeRef<Map<String, String>>() {}, "c" + index++, 
-1, "TestClass"));
+        new Descriptor(
+            new TypeRef<Map<String, String>>() {}, "c" + index++, -1, 
"TestClass", false));
     DescriptorGrouper grouper =
         DescriptorGrouper.createDescriptorGrouper(
                 ReflectionUtils::isMonomorphic,
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/type/DescriptorTest.java 
b/java/fory-core/src/test/java/org/apache/fory/type/DescriptorTest.java
index a591b4b77..99603d413 100644
--- a/java/fory-core/src/test/java/org/apache/fory/type/DescriptorTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/type/DescriptorTest.java
@@ -97,7 +97,7 @@ public class DescriptorTest {
 
   @Test
   public void testDescriptorBuilder() {
-    Descriptor descriptor = new Descriptor(TypeRef.of(A.class), "c", -1, 
"TestClass");
+    Descriptor descriptor = new Descriptor(TypeRef.of(A.class), "c", -1, 
"TestClass", true);
     // test copyBuilder
     Descriptor descriptor1 = descriptor.copyBuilder().build();
     Assert.assertEquals(descriptor.getTypeRef(), descriptor1.getTypeRef());
diff --git 
a/java/fory-format/src/test/java/org/apache/fory/format/encoder/ImplementInterfaceTest.java
 
b/java/fory-format/src/test/java/org/apache/fory/format/encoder/ImplementInterfaceTest.java
index 62661f18b..7f532da5c 100644
--- 
a/java/fory-format/src/test/java/org/apache/fory/format/encoder/ImplementInterfaceTest.java
+++ 
b/java/fory-format/src/test/java/org/apache/fory/format/encoder/ImplementInterfaceTest.java
@@ -56,7 +56,7 @@ public class ImplementInterfaceTest {
   }
 
   public interface NestedType {
-    @ForyField(nullable = false)
+    @ForyField(id = 0, nullable = false)
     String getF3();
   }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to