You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by sd...@apache.org on 2022/01/10 12:59:05 UTC

[ignite-3] branch main updated: IGNITE-16164 Implement (un)marshalling of arbitrary objects (#530)

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

sdanilov pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 781122a  IGNITE-16164 Implement (un)marshalling of arbitrary objects (#530)
781122a is described below

commit 781122a710652f0c0668178da9ba8ef45d64de7c
Author: Roman Puchkovskiy <ro...@gmail.com>
AuthorDate: Mon Jan 10 16:58:59 2022 +0400

    IGNITE-16164 Implement (un)marshalling of arbitrary objects (#530)
---
 modules/network/pom.xml                            |   6 +
 .../network/message/FieldDescriptorMessage.java    |   5 +
 .../network/serialization/BuiltinType.java         |   6 +-
 .../serialization/ClassDescriptorFactory.java      |  60 ++-
 .../ClassDescriptorFactoryContext.java             |  17 +-
 .../serialization/ClassIndexedDescriptors.java     |  50 ++
 .../ValueWriter.java => FieldAccessor.java}        |  27 +-
 .../network/serialization/FieldAccessorImpl.java   |  83 ++++
 .../network/serialization/FieldDescriptor.java     |  46 +-
 .../PerSessionSerializationService.java            |  15 +-
 ...ueWriter.java => SerializedStreamCommands.java} |  22 +-
 .../SpecialSerializationMethodsImpl.java           |  34 +-
 .../marshal/ArbitraryObjectMarshaller.java         |  82 ++++
 .../marshal/BestEffortInstantiation.java           |  56 +++
 .../marshal/BuiltInContainerMarshallers.java       | 159 ++++---
 .../serialization/marshal/BuiltInMarshalling.java  | 126 ++++--
 .../marshal/BuiltInNonContainerMarshallers.java    |  39 +-
 .../marshal/DefaultUserObjectMarshaller.java       | 349 +++++++++-----
 .../marshal/ExternalizableMarshaller.java          |  75 +++
 .../{ValueWriter.java => Instantiation.java}       |  28 +-
 ...alueWriter.java => InstantiationException.java} |  24 +-
 .../serialization/marshal/MarshallingContext.java  |  90 ++++
 .../marshal/NoArgConstructorInstantiation.java     |  50 ++
 .../marshal/SerializableInstantiation.java         | 115 +++++
 ...ackingMarshaller.java => TypedValueWriter.java} |  18 +-
 .../marshal/UnmarshallingContext.java              |  32 +-
 ...allingContext.java => UnsafeInstantiation.java} |  25 +-
 .../network/serialization/marshal/ValueWriter.java |   7 +-
 .../serialization/ClassDescriptorFactoryTest.java  |  31 ++
 .../marshal/BestEffortInstantiationTest.java       |  97 ++++
 ...erObjectMarshallerWithArbitraryObjectsTest.java | 504 +++++++++++++++++++++
 ...efaultUserObjectMarshallerWithBuiltinsTest.java | 233 ++++++----
 .../marshal/MarshallingContextTest.java            |  86 ++++
 .../marshal/NoArgConstructorInstantiationTest.java |  62 +++
 .../marshal/SerializableInstantiationTest.java     | 163 +++++++
 .../marshal/UnsafeInstantiationTest.java           |  53 +++
 .../marshal/WithAccessibleNoArgConstructor.java}   |  17 +-
 .../marshal/WithPrivateNoArgConstructor.java}      |  19 +-
 .../marshal/WithoutNoArgConstructor.java}          |  20 +-
 39 files changed, 2452 insertions(+), 479 deletions(-)

diff --git a/modules/network/pom.xml b/modules/network/pom.xml
index c9ce704..c55a57e 100644
--- a/modules/network/pom.xml
+++ b/modules/network/pom.xml
@@ -108,6 +108,12 @@
             <scope>test</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+
         <!-- Logging in tests -->
         <dependency>
             <groupId>org.slf4j</groupId>
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/message/FieldDescriptorMessage.java b/modules/network/src/main/java/org/apache/ignite/internal/network/message/FieldDescriptorMessage.java
index aa7a3ec..1409544 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/message/FieldDescriptorMessage.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/message/FieldDescriptorMessage.java
@@ -39,4 +39,9 @@ public interface FieldDescriptorMessage extends NetworkMessage {
      * Field's class name.
      */
     String className();
+
+    /**
+     * The name of The class in which this field is declared.
+     */
+    String declaringClassName();
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BuiltinType.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BuiltinType.java
index 6ffc8c9..46d22e5 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BuiltinType.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/BuiltinType.java
@@ -31,6 +31,8 @@ import org.apache.ignite.lang.IgniteUuid;
 
 /**
  * Built-in types.
+ *
+ * <p>They share ID space with commands defined in {@link SerializedStreamCommands}.
  */
 public enum BuiltinType {
     BYTE(0, byte.class),
@@ -79,7 +81,9 @@ public enum BuiltinType {
     LINKED_HASH_MAP(41, LinkedHashMap.class),
     BIT_SET(42, BitSet.class),
     NULL(43, Null.class),
-    VOID(44, Void.class);
+    VOID(44, Void.class)
+    // 45 is REFERENCE command, see SerializedStreamCommands#REFERENCE
+    ;
 
     /**
      * Pre-defined descriptor id.
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactory.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactory.java
index 0732b4e..0a3a4a9 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactory.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactory.java
@@ -17,19 +17,24 @@
 
 package org.apache.ignite.internal.network.serialization;
 
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
 import java.io.Externalizable;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
 import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Queue;
-import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.ignite.lang.IgniteException;
 import org.jetbrains.annotations.Nullable;
 
@@ -207,26 +212,55 @@ public class ClassDescriptorFactory {
     }
 
     /**
-     * Gets field descriptors of the class. If a field's type doesn't have an id yet, generates it.
+     * Gets field descriptors of the class in the correct order (see {@link #classFields(Class)}. If a field's type
+     * doesn't have an id yet, generates it.
      *
      * @param clazz Class.
      * @return List of field descriptor.
      */
     private List<FieldDescriptor> fields(Class<?> clazz) {
-        if (clazz.getSuperclass() != Object.class) {
-            // TODO: IGNITE-15945 add support for the inheritance
-            throw new UnsupportedOperationException("IGNITE-15945");
+        List<Class<?>> lineage = lineage(clazz);
+
+        return lineage.stream()
+                .flatMap(this::classFields)
+                .collect(toList());
+    }
+
+    /**
+     * Returns the lineage (all the ancestors, from Object down the line, including the given class).
+     *
+     * @param clazz class from which to obtain lineage
+     * @return ancestors from Object down the line, plus the given class itself
+     */
+    private List<Class<?>> lineage(Class<?> clazz) {
+        List<Class<?>> classes = new ArrayList<>();
+
+        Class<?> currentClass = clazz;
+        while (currentClass != null) {
+            classes.add(currentClass);
+            currentClass = currentClass.getSuperclass();
         }
 
+        Collections.reverse(classes);
+        return classes;
+    }
+
+    /**
+     * Returns 'serializable' (i.e. non-static non-transient) declared fields of the given class sorted lexicographically by their names.
+     *
+     * @param clazz class
+     * @return properly sorted fields
+     */
+    private Stream<FieldDescriptor> classFields(Class<?> clazz) {
         return Arrays.stream(clazz.getDeclaredFields())
-            .filter(field -> {
-                int modifiers = field.getModifiers();
-
-                // Ignore static and transient field.
-                return !Modifier.isStatic(modifiers) && !Modifier.isTransient(modifiers);
-            })
-            .map(field -> new FieldDescriptor(field, context.getId(field.getType())))
-            .collect(Collectors.toList());
+                .sorted(comparing(Field::getName))
+                .filter(field -> {
+                    int modifiers = field.getModifiers();
+
+                    // Ignore static and transient fields.
+                    return !Modifier.isStatic(modifiers) && !Modifier.isTransient(modifiers);
+                })
+                .map(field -> new FieldDescriptor(field, context.getId(field.getType())));
     }
 
     /**
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryContext.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryContext.java
index e0dc9ba..708aae9 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryContext.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryContext.java
@@ -25,7 +25,7 @@ import org.jetbrains.annotations.Nullable;
 /**
  * Class descriptor factory context.
  */
-public class ClassDescriptorFactoryContext implements IdIndexedDescriptors {
+public class ClassDescriptorFactoryContext implements IdIndexedDescriptors, ClassIndexedDescriptors {
     /** Quantity of descriptor ids reserved for the default descriptors. */
     private static final int DEFAULT_DESCRIPTORS_OFFSET_COUNT = 1000;
 
@@ -93,6 +93,7 @@ public class ClassDescriptorFactoryContext implements IdIndexedDescriptors {
      * @param clazz Class.
      * @return Descriptor.
      */
+    @Override
     @Nullable
     public ClassDescriptor getDescriptor(Class<?> clazz) {
         Integer descriptorId = idMap.get(clazz);
@@ -105,20 +106,6 @@ public class ClassDescriptorFactoryContext implements IdIndexedDescriptors {
     }
 
     /**
-     * Gets a descriptor by the class or throws an exception if no such class is known.
-     *
-     * @param clazz Class.
-     * @return Descriptor.
-     */
-    public ClassDescriptor getRequiredDescriptor(Class<?> clazz) {
-        ClassDescriptor descriptor = getDescriptor(clazz);
-        if (descriptor == null) {
-            throw new IllegalStateException("No descriptor exists for " + clazz);
-        }
-        return descriptor;
-    }
-
-    /**
      * Returns a descriptor for a built-in type.
      *
      * @param builtinType   built-in type for lookup
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassIndexedDescriptors.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassIndexedDescriptors.java
new file mode 100644
index 0000000..4bef0d7
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/ClassIndexedDescriptors.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ignite.internal.network.serialization;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Container of {@link ClassDescriptor}s indexed by their classes.
+ */
+public interface ClassIndexedDescriptors {
+    /**
+     * Returns a descriptor by class or throws an exception if no such descriptor is known.
+     *
+     * @param clazz  for lookup
+     * @return descriptor by class
+     */
+    @Nullable
+    ClassDescriptor getDescriptor(Class<?> clazz);
+
+    /**
+     * Returns a descriptor by class or throws an exception if no such descriptor is known.
+     *
+     * @param clazz  for lookup
+     * @return descriptor by class
+     */
+    default ClassDescriptor getRequiredDescriptor(Class<?> clazz) {
+        ClassDescriptor descriptor = getDescriptor(clazz);
+
+        if (descriptor == null) {
+            throw new IllegalStateException("Did not find a descriptor by class=" + clazz);
+        }
+
+        return descriptor;
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
similarity index 61%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
index d3d75b9..34c4806 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessor.java
@@ -15,22 +15,25 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.network.serialization.marshal;
-
-import java.io.DataOutput;
-import java.io.IOException;
+package org.apache.ignite.internal.network.serialization;
 
 /**
- * Knows how to write a value to a {@link DataOutput}.
+ * Accessor for a specific field.
  */
-interface ValueWriter<T> {
+public interface FieldAccessor {
+    /**
+     * Returns the bound field value of the given object.
+     *
+     * @param target target object
+     * @return the bound field value of the given object
+     */
+    Object get(Object target);
+
     /**
-     * Writes the given value to a {@link DataOutput}.
+     * Sets the bound field value on the given object.
      *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
+     * @param target     target object
+     * @param fieldValue value to set
      */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+    void set(Object target, Object fieldValue);
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessorImpl.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessorImpl.java
new file mode 100644
index 0000000..1346397
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldAccessorImpl.java
@@ -0,0 +1,83 @@
+/*
+ * 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.ignite.internal.network.serialization;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+
+/**
+ * {@link FieldAccessor} implementation.
+ */
+class FieldAccessorImpl implements FieldAccessor {
+    private final Field field;
+    private final VarHandle varHandle;
+
+    FieldAccessorImpl(FieldDescriptor descriptor) {
+        field = findField(descriptor);
+        field.setAccessible(true);
+
+        varHandle = varHandleFrom(field);
+    }
+
+    private static Field findField(FieldDescriptor fieldDescriptor) {
+        try {
+            return fieldDescriptor.declaringClass().getDeclaredField(fieldDescriptor.name());
+        } catch (NoSuchFieldException e) {
+            throw new ReflectionException("Cannot find field", e);
+        }
+    }
+
+    private static VarHandle varHandleFrom(Field field) {
+        try {
+            MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(field.getDeclaringClass(), MethodHandles.lookup());
+            return lookup.unreflectVarHandle(field);
+        } catch (ReflectiveOperationException e) {
+            throw new ReflectionException("Cannot get a field VarHandle", e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Object get(Object target) {
+        return varHandle.get(target);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void set(Object target, Object fieldValue) {
+        if (isFieldFinal()) {
+            setViaField(target, fieldValue);
+        } else {
+            varHandle.set(target, fieldValue);
+        }
+    }
+
+    private boolean isFieldFinal() {
+        return Modifier.isFinal(field.getModifiers());
+    }
+
+    private void setViaField(Object target, Object fieldValue) {
+        try {
+            field.set(target, fieldValue);
+        } catch (IllegalAccessException e) {
+            throw new ReflectionException("Cannot set a value", e);
+        }
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
index ff72f20..fe86f24 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/FieldDescriptor.java
@@ -27,13 +27,11 @@ public class FieldDescriptor {
     /**
      * Name of the field.
      */
-    @NotNull
     private final String name;
 
     /**
      * Type of the field.
      */
-    @NotNull
     private final Class<?> clazz;
 
     /**
@@ -42,23 +40,37 @@ public class FieldDescriptor {
     private final int typeDescriptorId;
 
     /**
+     * The class in which the field is declared.
+     */
+    private final Class<?> declaringClass;
+
+    /**
+     * Accessor for accessing this field.
+     */
+    private final FieldAccessor accessor;
+
+    /**
      * Constructor.
      */
-    public FieldDescriptor(@NotNull Field field, int typeDescriptorId) {
-        this(field.getName(), field.getType(), typeDescriptorId);
+    public FieldDescriptor(Field field, int typeDescriptorId) {
+        this(field.getName(), field.getType(), typeDescriptorId, field.getDeclaringClass());
     }
 
     /**
      * Constructor.
      *
-     * @param fieldName .
-     * @param fieldClazz .
-     * @param typeDescriptorId .
+     * @param fieldName         field name
+     * @param fieldClazz        type of the field
+     * @param typeDescriptorId  ID of the descriptor corresponding to field type
+     * @param declaringClass    the class in which the field if declared
      */
-    public FieldDescriptor(@NotNull String fieldName, @NotNull Class<?> fieldClazz, int typeDescriptorId) {
+    public FieldDescriptor(String fieldName, Class<?> fieldClazz, int typeDescriptorId, Class<?> declaringClass) {
         this.name = fieldName;
         this.clazz = fieldClazz;
         this.typeDescriptorId = typeDescriptorId;
+        this.declaringClass = declaringClass;
+
+        accessor = new FieldAccessorImpl(this);
     }
 
     /**
@@ -89,4 +101,22 @@ public class FieldDescriptor {
     public int typeDescriptorId() {
         return typeDescriptorId;
     }
+
+    /**
+     * Returns the class in which the field is declared.
+     *
+     * @return the class in which the field is declared
+     */
+    public Class<?> declaringClass() {
+        return declaringClass;
+    }
+
+    /**
+     * Returns {@link FieldAccessor} for this field.
+     *
+     * @return {@link FieldAccessor} for this field
+     */
+    public FieldAccessor accessor() {
+        return accessor;
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
index 53315f6..d8a546c 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/PerSessionSerializationService.java
@@ -114,6 +114,7 @@ public class PerSessionSerializationService {
                                 .name(d.name())
                                 .typeDescriptorId(d.typeDescriptorId())
                                 .className(d.clazz().getName())
+                                .declaringClassName(d.declaringClass().getName())
                                 .build();
                     })
                     .collect(toList());
@@ -162,11 +163,9 @@ public class PerSessionSerializationService {
     private ClassDescriptor messageToMergedClassDescriptor(ClassDescriptorMessage clsMsg) {
         ClassDescriptor localDescriptor = serializationService.getClassDescriptor(clsMsg.className());
 
-        List<FieldDescriptor> remoteFields = clsMsg.fields().stream().map(fieldMsg -> {
-            int typeDescriptorId = fieldMsg.typeDescriptorId();
-
-            return new FieldDescriptor(fieldMsg.name(), getClass(typeDescriptorId, fieldMsg.className()), typeDescriptorId);
-        }).collect(toList());
+        List<FieldDescriptor> remoteFields = clsMsg.fields().stream()
+                .map(this::fieldDescriptorFromMessage)
+                .collect(toList());
 
         SerializationType serializationType = SerializationType.getByValue(clsMsg.serializationType());
 
@@ -187,6 +186,12 @@ public class PerSessionSerializationService {
         return mergeDescriptor(localDescriptor, remoteDescriptor);
     }
 
+    private FieldDescriptor fieldDescriptorFromMessage(FieldDescriptorMessage fieldMsg) {
+        int typeDescriptorId = fieldMsg.typeDescriptorId();
+        Class<?> declaringClass = serializationService.getClassDescriptor(fieldMsg.declaringClassName()).clazz();
+        return new FieldDescriptor(fieldMsg.name(), getClass(typeDescriptorId, fieldMsg.className()), typeDescriptorId, declaringClass);
+    }
+
     private ClassDescriptor mergeDescriptor(ClassDescriptor localDescriptor, ClassDescriptor remoteDescriptor) {
         // TODO: IGNITE-15948 Handle class structure changes
         return remoteDescriptor;
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializedStreamCommands.java
similarity index 60%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializedStreamCommands.java
index d3d75b9..1299519 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SerializedStreamCommands.java
@@ -15,22 +15,18 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.network.serialization.marshal;
-
-import java.io.DataOutput;
-import java.io.IOException;
+package org.apache.ignite.internal.network.serialization;
 
 /**
- * Knows how to write a value to a {@link DataOutput}.
+ * Lists commands used in the serialized stream.
+ * Command IDs share space with IDs of {@link ClassDescriptor}s (most importantly, those that are defined in {@link BuiltinType}.
  */
-interface ValueWriter<T> {
+public class SerializedStreamCommands {
     /**
-     * Writes the given value to a {@link DataOutput}.
-     *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
+     * Reference: an object that was already seen in the graph, so we relate to it by its ID instead of serializing it again.
      */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+    public static final int REFERENCE = 45;
+
+    private SerializedStreamCommands() {
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SpecialSerializationMethodsImpl.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SpecialSerializationMethodsImpl.java
index 4384307..553c9dc 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SpecialSerializationMethodsImpl.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/SpecialSerializationMethodsImpl.java
@@ -49,53 +49,41 @@ class SpecialSerializationMethodsImpl implements SpecialSerializationMethods {
     private static MethodHandle writeReplaceHandle(ClassDescriptor descriptor) {
         Method writeReplaceMethod = findWriteReplaceMethod(descriptor);
 
+        return unreflect(writeReplaceMethod, MethodType.methodType(Object.class, Object.class), descriptor);
+    }
+
+    private static MethodHandle unreflect(Method method, MethodType methodType, ClassDescriptor descriptor) {
         try {
-            return MethodHandles.lookup()
-                        .unreflect(writeReplaceMethod)
-                        .asType(MethodType.methodType(Object.class, Object.class));
+            return MethodHandles.privateLookupIn(descriptor.clazz(), MethodHandles.lookup())
+                        .unreflect(method)
+                        .asType(methodType);
         } catch (IllegalAccessException e) {
-            throw new ReflectionException("writeReplace() cannot be unreflected", e);
+            throw new ReflectionException("Cannot unreflect", e);
         }
     }
 
     private static Method findWriteReplaceMethod(ClassDescriptor descriptor) {
-        Method writeReplaceMethod;
         try {
-            writeReplaceMethod = descriptor.clazz().getDeclaredMethod("writeReplace");
+            return descriptor.clazz().getDeclaredMethod("writeReplace");
         } catch (NoSuchMethodException e) {
             throw new ReflectionException("writeReplace() was not found on " + descriptor.clazz()
                     + " even though the descriptor says the class has the method", e);
         }
-
-        writeReplaceMethod.setAccessible(true);
-
-        return writeReplaceMethod;
     }
 
     private static MethodHandle readResolveHandle(ClassDescriptor descriptor) {
         Method readResolveMethod = findReadResolveMethod(descriptor);
 
-        try {
-            return MethodHandles.lookup()
-                    .unreflect(readResolveMethod)
-                    .asType(MethodType.methodType(Object.class, Object.class));
-        } catch (IllegalAccessException e) {
-            throw new ReflectionException("readResolve() cannot be unreflected", e);
-        }
+        return unreflect(readResolveMethod, MethodType.methodType(Object.class, Object.class), descriptor);
     }
 
     private static Method findReadResolveMethod(ClassDescriptor descriptor) {
-        Method readResolveMethod;
         try {
-            readResolveMethod = descriptor.clazz().getDeclaredMethod("readResolve");
+            return descriptor.clazz().getDeclaredMethod("readResolve");
         } catch (NoSuchMethodException e) {
             throw new ReflectionException("readResolve() was not found on " + descriptor.clazz()
                     + " even though the descriptor says the class has the method", e);
         }
-
-        readResolveMethod.setAccessible(true);
-
-        return readResolveMethod;
     }
 
     /** {@inheritDoc} */
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ArbitraryObjectMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ArbitraryObjectMarshaller.java
new file mode 100644
index 0000000..cddc9cb
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ArbitraryObjectMarshaller.java
@@ -0,0 +1,82 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.IOException;
+import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+import org.apache.ignite.internal.network.serialization.ClassIndexedDescriptors;
+import org.apache.ignite.internal.network.serialization.FieldDescriptor;
+
+/**
+ * (Un)marshals arbitrary objects (that is, objects that are not built-in nor serializable/externalizable).
+ */
+class ArbitraryObjectMarshaller {
+    private final TypedValueWriter valueWriter;
+    private final ValueReader<Object> valueReader;
+
+    private final Instantiation instantiation;
+
+    ArbitraryObjectMarshaller(ClassIndexedDescriptors descriptors, TypedValueWriter valueWriter, ValueReader<Object> valueReader) {
+        this.valueWriter = valueWriter;
+        this.valueReader = valueReader;
+
+        instantiation = new BestEffortInstantiation(
+                new NoArgConstructorInstantiation(),
+                new SerializableInstantiation(descriptors),
+                new UnsafeInstantiation()
+        );
+    }
+
+    void writeArbitraryObject(Object object, ClassDescriptor descriptor, DataOutput output, MarshallingContext context)
+            throws MarshalException, IOException {
+        context.addUsedDescriptor(descriptor);
+
+        for (FieldDescriptor fieldDescriptor : descriptor.fields()) {
+            writeField(object, fieldDescriptor, output, context);
+        }
+    }
+
+    private void writeField(Object object, FieldDescriptor fieldDescriptor, DataOutput output, MarshallingContext context)
+            throws MarshalException, IOException {
+        Object fieldValue = fieldDescriptor.accessor().get(object);
+
+        valueWriter.write(fieldValue, fieldDescriptor.clazz(), output, context);
+    }
+
+    Object preInstantiateArbitraryObject(ClassDescriptor descriptor) throws UnmarshalException {
+        try {
+            return instantiation.newInstance(descriptor.clazz());
+        } catch (InstantiationException e) {
+            throw new UnmarshalException("Cannot instantiate " + descriptor.clazz(), e);
+        }
+    }
+
+    void fillArbitraryObjectFrom(DataInput input, Object object, ClassDescriptor descriptor, UnmarshallingContext context)
+            throws IOException, UnmarshalException {
+        for (FieldDescriptor fieldDescriptor : descriptor.fields()) {
+            Object fieldValue = valueReader.read(input, context);
+            setFieldValue(object, fieldDescriptor, fieldValue);
+        }
+    }
+
+    private void setFieldValue(Object target, FieldDescriptor fieldDescriptor, Object value) {
+        fieldDescriptor.accessor().set(target, value);
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiation.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiation.java
new file mode 100644
index 0000000..c741a72
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiation.java
@@ -0,0 +1,56 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import java.util.List;
+
+/**
+ * Instantiation strategy that delegates to a list of instantiation strategies and uses the first one that
+ * announces support for instantiating a given class.
+ */
+class BestEffortInstantiation implements Instantiation {
+    private final List<Instantiation> delegates;
+
+    BestEffortInstantiation(Instantiation... delegates) {
+        this.delegates = List.of(delegates);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean supports(Class<?> objectClass) {
+        for (Instantiation delegate : delegates) {
+            if (delegate.supports(objectClass)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Object newInstance(Class<?> objectClass) throws InstantiationException {
+        for (Instantiation delegate : delegates) {
+            if (delegate.supports(objectClass)) {
+                return delegate.newInstance(objectClass);
+            }
+        }
+
+        throw new InstantiationException("No delegate supports " + objectClass);
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInContainerMarshallers.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInContainerMarshallers.java
index 80f710e..2f9a8d7 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInContainerMarshallers.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInContainerMarshallers.java
@@ -32,7 +32,6 @@ import java.util.LinkedHashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.function.IntFunction;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 
@@ -47,7 +46,7 @@ class BuiltInContainerMarshallers {
      */
     private final Map<Class<?>, IntFunction<? extends Collection<?>>> mutableBuiltInCollectionFactories = Map.of(
             ArrayList.class, ArrayList::new,
-            LinkedList.class, len -> new LinkedList<>(),
+            LinkedList.class, size -> new LinkedList<>(),
             HashSet.class, HashSet::new,
             LinkedHashSet.class, LinkedHashSet::new
     );
@@ -62,29 +61,36 @@ class BuiltInContainerMarshallers {
             LinkedHashMap.class, LinkedHashMap::new
     );
 
-    private final TrackingMarshaller trackingMarshaller;
+    /**
+     * Used to write elements.
+     */
+    private final ValueWriter<?> elementWriter;
 
-    BuiltInContainerMarshallers(TrackingMarshaller trackingMarshaller) {
-        this.trackingMarshaller = trackingMarshaller;
+    BuiltInContainerMarshallers(ValueWriter<?> elementWriter) {
+        this.elementWriter = elementWriter;
     }
 
-    Set<ClassDescriptor> writeGenericRefArray(Object[] array, ClassDescriptor arrayDescriptor, DataOutput output)
+    void writeGenericRefArray(Object[] array, ClassDescriptor arrayDescriptor, DataOutput output, MarshallingContext context)
             throws IOException, MarshalException {
         output.writeUTF(array.getClass().getComponentType().getName());
-        return writeCollection(Arrays.asList(array), arrayDescriptor, output);
+        writeCollection(Arrays.asList(array), arrayDescriptor, output, context);
+    }
+
+    <T> T[] preInstantiateGenericRefArray(DataInput input) throws IOException {
+        return BuiltInMarshalling.preInstantiateGenericRefArray(input);
     }
 
-    <T> T[] readGenericRefArray(DataInput input, ValueReader<T> elementReader, UnmarshallingContext context)
+    <T> void fillGenericRefArray(DataInput input, T[] array, ValueReader<T> elementReader, UnmarshallingContext context)
             throws IOException, UnmarshalException {
-        return BuiltInMarshalling.readGenericRefArray(input, elementReader, context);
+        BuiltInMarshalling.fillGenericRefArray(input, array, elementReader, context);
     }
 
-    Set<ClassDescriptor> writeBuiltInCollection(Collection<?> object, ClassDescriptor descriptor, DataOutput output)
+    void writeBuiltInCollection(Collection<?> object, ClassDescriptor descriptor, DataOutput output, MarshallingContext context)
             throws IOException, MarshalException {
         if (supportsAsMutableBuiltInCollection(descriptor)) {
-            return writeCollection(object, descriptor, output);
+            writeCollection(object, descriptor, output, context);
         } else if (descriptor.isSingletonList()) {
-            return writeSingletonList((List<?>) object, descriptor, output);
+            writeSingletonList((List<?>) object, descriptor, output, context);
         } else {
             throw new IllegalStateException("Marshalling of " + descriptor.clazz() + " is not supported, but it's marked as a built-in");
         }
@@ -102,49 +108,35 @@ class BuiltInContainerMarshallers {
         return mutableBuiltInCollectionFactories.containsKey(descriptor.clazz());
     }
 
-    private Set<ClassDescriptor> writeCollection(Collection<?> collection, ClassDescriptor collectionDescriptor, DataOutput output)
-            throws IOException, MarshalException {
-        Set<ClassDescriptor> usedDescriptors = new HashSet<>();
-        usedDescriptors.add(collectionDescriptor);
-
-        BuiltInMarshalling.writeCollection(collection, output, writerAddingUsedDescriptor(usedDescriptors));
+    private void writeCollection(
+            Collection<?> collection,
+            ClassDescriptor collectionDescriptor,
+            DataOutput output,
+            MarshallingContext context
+    ) throws IOException, MarshalException {
+        context.addUsedDescriptor(collectionDescriptor);
 
-        return usedDescriptors;
+        BuiltInMarshalling.writeCollection(collection, output, valueWriter(), context);
     }
 
-    private <T> ValueWriter<T> writerAddingUsedDescriptor(Set<ClassDescriptor> usedDescriptors) {
-        return (elem, out) -> {
-            Set<ClassDescriptor> elementDescriptors = trackingMarshaller.marshal(elem, out);
-            usedDescriptors.addAll(elementDescriptors);
-        };
+    @SuppressWarnings("unchecked")
+    private <T> ValueWriter<T> valueWriter() {
+        return (ValueWriter<T>) elementWriter;
     }
 
-    private Set<ClassDescriptor> writeSingletonList(List<?> list, ClassDescriptor listDescriptor, DataOutput output)
+    private void writeSingletonList(List<?> list, ClassDescriptor listDescriptor, DataOutput output, MarshallingContext context)
             throws MarshalException, IOException {
         assert list.size() == 1;
 
         Object element = list.get(0);
 
-        Set<ClassDescriptor> usedDescriptors = new HashSet<>();
-        usedDescriptors.add(listDescriptor);
+        context.addUsedDescriptor(listDescriptor);
 
-        Set<ClassDescriptor> descriptorsFromElement = trackingMarshaller.marshal(element, output);
-        usedDescriptors.addAll(descriptorsFromElement);
-
-        return usedDescriptors;
+        valueWriter().write(element, output, context);
     }
 
     @SuppressWarnings("unchecked")
-    <T, C extends Collection<T>> C readBuiltInCollection(
-            ClassDescriptor collectionDescriptor,
-            ValueReader<T> elementReader,
-            DataInput input,
-            UnmarshallingContext context
-    ) throws UnmarshalException, IOException {
-        if (collectionDescriptor.isSingletonList()) {
-            return (C) singletonList(elementReader.read(input, context));
-        }
-
+    private <T, C extends Collection<T>> IntFunction<C> requiredCollectionFactory(ClassDescriptor collectionDescriptor) {
         IntFunction<C> collectionFactory = (IntFunction<C>) mutableBuiltInCollectionFactories.get(collectionDescriptor.clazz());
 
         if (collectionFactory == null) {
@@ -152,39 +144,89 @@ class BuiltInContainerMarshallers {
                     + " even though it is marked as a built-in");
         }
 
-        return BuiltInMarshalling.readCollection(input, collectionFactory, elementReader, context);
+        return collectionFactory;
+    }
+
+    Object preInstantiateBuiltInMutableCollection(ClassDescriptor collectionDescriptor, DataInput input, UnmarshallingContext context)
+            throws IOException {
+        // TODO: IGNITE-16229 - proper immutable collections unmarshalling?
+        if (collectionDescriptor.isSingletonList()) {
+            return singletonList(null);
+        }
+
+        return preInstantiateNonSingletonCollection(collectionDescriptor, input, context);
     }
 
-    Set<ClassDescriptor> writeBuiltInMap(Map<?, ?> map, ClassDescriptor mapDescriptor, DataOutput output)
+    private <T, C extends Collection<T>> C preInstantiateNonSingletonCollection(
+            ClassDescriptor collectionDescriptor,
+            DataInput input,
+            UnmarshallingContext context
+    ) throws IOException {
+        IntFunction<C> collectionFactory = requiredCollectionFactory(collectionDescriptor);
+
+        context.markSource();
+
+        C collection = BuiltInMarshalling.preInstantiateCollection(input, collectionFactory);
+
+        context.resetSourceToMark();
+
+        return collection;
+    }
+
+    <T, C extends Collection<T>> void fillBuiltInCollectionFrom(
+            DataInput input,
+            C collection,
+            ClassDescriptor collectionDescriptor,
+            ValueReader<T> elementReader,
+            UnmarshallingContext context
+    ) throws UnmarshalException, IOException {
+        // TODO: IGNITE-16229 - proper immutable collections unmarshalling?
+        if (collectionDescriptor.isSingletonList()) {
+            BuiltInMarshalling.fillSingletonCollectionFrom(input, collection, elementReader, context);
+            return;
+        }
+
+        BuiltInMarshalling.fillCollectionFrom(input, collection, elementReader, context);
+    }
+
+    void writeBuiltInMap(Map<?, ?> map, ClassDescriptor mapDescriptor, DataOutput output, MarshallingContext context)
             throws MarshalException, IOException {
         if (!supportsAsBuiltInMap(mapDescriptor)) {
             throw new IllegalStateException("Marshalling of " + mapDescriptor.clazz() + " is not supported, but it's marked as a built-in");
         }
 
-        Set<ClassDescriptor> usedDescriptors = new HashSet<>();
-        usedDescriptors.add(mapDescriptor);
+        context.addUsedDescriptor(mapDescriptor);
 
         BuiltInMarshalling.writeMap(
                 map,
                 output,
-                writerAddingUsedDescriptor(usedDescriptors),
-                writerAddingUsedDescriptor(usedDescriptors)
+                valueWriter(),
+                valueWriter(),
+                context
         );
-
-        return usedDescriptors;
     }
 
     private boolean supportsAsBuiltInMap(ClassDescriptor mapDescriptor) {
         return mutableBuiltInMapFactories.containsKey(mapDescriptor.clazz());
     }
 
-    <K, V, M extends Map<K, V>> M readBuiltInMap(
+    <K, V, M extends Map<K, V>> M preInstantiateBuiltInMutableMap(
             ClassDescriptor mapDescriptor,
-            ValueReader<K> keyReader,
-            ValueReader<V> valueReader,
             DataInput input,
             UnmarshallingContext context
-    ) throws UnmarshalException, IOException {
+    ) throws IOException {
+        IntFunction<M> mapFactory = requiredMapFactory(mapDescriptor);
+
+        context.markSource();
+
+        M map = BuiltInMarshalling.preInstantiateMap(input, mapFactory);
+
+        context.resetSourceToMark();
+
+        return map;
+    }
+
+    private <K, V, M extends Map<K, V>> IntFunction<M> requiredMapFactory(ClassDescriptor mapDescriptor) {
         @SuppressWarnings("unchecked")
         IntFunction<M> mapFactory = (IntFunction<M>) mutableBuiltInMapFactories.get(mapDescriptor.clazz());
 
@@ -192,7 +234,16 @@ class BuiltInContainerMarshallers {
             throw new IllegalStateException("Did not find a map factory for " + mapDescriptor.clazz()
                     + " even though it is marked as a built-in");
         }
+        return mapFactory;
+    }
 
-        return BuiltInMarshalling.readMap(input, mapFactory, keyReader, valueReader, context);
+    <K, V, M extends Map<K, V>> void fillBuiltInMapFrom(
+            DataInput input,
+            M map,
+            ValueReader<K> keyReader,
+            ValueReader<V> valueReader,
+            UnmarshallingContext context
+    ) throws UnmarshalException, IOException {
+        BuiltInMarshalling.fillMapFrom(input, map, keyReader, valueReader, context);
     }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInMarshalling.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInMarshalling.java
index 68aea24..cb12b8a 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInMarshalling.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInMarshalling.java
@@ -17,10 +17,13 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
+import static java.util.Collections.singletonList;
+
 import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.IOException;
 import java.lang.reflect.Array;
+import java.lang.reflect.Field;
 import java.math.BigDecimal;
 import java.util.BitSet;
 import java.util.Collection;
@@ -35,17 +38,28 @@ import org.jetbrains.annotations.NotNull;
  * Built-in types marshalling.
  */
 class BuiltInMarshalling {
-    private static final ValueWriter<String> stringWriter = BuiltInMarshalling::writeString;
+    private static final ValueWriter<String> stringWriter = (obj, out, ctx) -> writeString(obj, out);
     private static final IntFunction<String[]> stringArrayFactory = String[]::new;
     private static final ValueReader<String> stringReader = (in, ctx) -> readString(in);
 
-    private static final ValueWriter<BigDecimal> bigDecimalWriter = BuiltInMarshalling::writeBigDecimal;
+    private static final ValueWriter<BigDecimal> bigDecimalWriter = (obj, out, ctx) -> writeBigDecimal(obj, out);
     private static final IntFunction<BigDecimal[]> bigDecimalArrayFactory = BigDecimal[]::new;
     private static final ValueReader<BigDecimal> bigDecimalReader = (in, ctx) -> readBigDecimal(in);
 
-    private static final ValueWriter<Enum<?>> enumWriter = BuiltInMarshalling::writeEnum;
+    private static final ValueWriter<Enum<?>> enumWriter = (obj, out, ctx) -> writeEnum(obj, out);
     private static final ValueReader<Enum<?>> enumReader = (in, ctx) -> readEnum(in);
 
+    private static final Field singletonListElementField;
+
+    static {
+        try {
+            singletonListElementField = singletonList(null).getClass().getDeclaredField("element");
+            singletonListElementField.setAccessible(true);
+        } catch (ReflectiveOperationException e) {
+            throw new ExceptionInInitializerError(e);
+        }
+    }
+
     static void writeString(String string, DataOutput output) throws IOException {
         output.writeUTF(string);
     }
@@ -54,7 +68,7 @@ class BuiltInMarshalling {
         return input.readUTF();
     }
 
-    static Object readBareObject(DataInput input) {
+    static Object readBareObject(@SuppressWarnings("unused") DataInput input) {
         return new Object();
     }
 
@@ -256,52 +270,69 @@ class BuiltInMarshalling {
         }
     }
 
-    static <T> void writeRefArray(T[] array, DataOutput output, ValueWriter<T> valueWriter) throws IOException, MarshalException {
+    static <T> void writeRefArray(T[] array, DataOutput output, ValueWriter<T> valueWriter, MarshallingContext context)
+            throws IOException, MarshalException {
         output.writeInt(array.length);
         for (T object : array) {
-            valueWriter.write(object, output);
+            valueWriter.write(object, output, context);
         }
     }
 
     static <T> T[] readRefArray(DataInput input, IntFunction<T[]> arrayFactory, ValueReader<T> valueReader, UnmarshallingContext context)
             throws IOException, UnmarshalException {
         int length = input.readInt();
+
         T[] array = arrayFactory.apply(length);
-        for (int i = 0; i < length; i++) {
-            array[i] = valueReader.read(input, context);
-        }
+        fillRefArrayFrom(input, array, valueReader, context);
+
         return array;
     }
 
-    static <T> T[] readGenericRefArray(DataInput input, ValueReader<T> elementReader, UnmarshallingContext context)
+    private static <T> void fillRefArrayFrom(DataInput input, T[] array, ValueReader<T> valueReader, UnmarshallingContext context)
             throws IOException, UnmarshalException {
+        for (int i = 0; i < array.length; i++) {
+            array[i] = valueReader.read(input, context);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T> IntFunction<T[]> readTypeAndCreateArrayFactory(DataInput input) throws IOException {
         String componentClassName = input.readUTF();
         Class<T> componentType = classByName(componentClassName, "component");
-        @SuppressWarnings("unchecked")
-        IntFunction<T[]> arrayFactory = len -> (T[]) Array.newInstance(componentType, len);
-        return readRefArray(input, arrayFactory, elementReader, context);
+        return len -> (T[]) Array.newInstance(componentType, len);
     }
 
+    static <T> T[] preInstantiateGenericRefArray(DataInput input) throws IOException {
+        IntFunction<T[]> arrayFactory = readTypeAndCreateArrayFactory(input);
+        int length = input.readInt();
+        return arrayFactory.apply(length);
+    }
+
+    static <T> void fillGenericRefArray(DataInput input, T[] array, ValueReader<T> elementReader, UnmarshallingContext context)
+            throws IOException, UnmarshalException {
+        fillRefArrayFrom(input, array, elementReader, context);
+    }
 
-    static void writeStringArray(String[] array, DataOutput output) throws IOException, MarshalException {
-        writeRefArray(array, output, stringWriter);
+    static void writeStringArray(String[] array, DataOutput output, MarshallingContext context) throws IOException, MarshalException {
+        writeRefArray(array, output, stringWriter, context);
     }
 
     static String[] readStringArray(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
         return readRefArray(input, stringArrayFactory, stringReader, context);
     }
 
-    static void writeBigDecimalArray(BigDecimal[] array, DataOutput output) throws IOException, MarshalException {
-        writeRefArray(array, output, bigDecimalWriter);
+    static void writeBigDecimalArray(BigDecimal[] array, DataOutput output, MarshallingContext context)
+            throws IOException, MarshalException {
+        writeRefArray(array, output, bigDecimalWriter, context);
     }
 
     static BigDecimal[] readBigDecimalArray(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
         return readRefArray(input, bigDecimalArrayFactory, bigDecimalReader, context);
     }
 
-    static void writeEnumArray(Enum<?>[] array, DataOutput output) throws IOException, MarshalException {
+    static void writeEnumArray(Enum<?>[] array, DataOutput output, MarshallingContext context) throws IOException, MarshalException {
         output.writeUTF(array.getClass().getComponentType().getName());
-        writeRefArray(array, output, enumWriter);
+        writeRefArray(array, output, enumWriter, context);
     }
 
     static Enum<?>[] readEnumArray(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
@@ -310,50 +341,79 @@ class BuiltInMarshalling {
         return readRefArray(input, len -> (Enum<?>[]) Array.newInstance(enumClass, len), enumReader, context);
     }
 
-    static <T> void writeCollection(Collection<T> collection, DataOutput output, ValueWriter<T> valueWriter)
+    static <T> void writeCollection(Collection<T> collection, DataOutput output, ValueWriter<T> valueWriter, MarshallingContext context)
             throws IOException, MarshalException {
         output.writeInt(collection.size());
+
         for (T object : collection) {
-            valueWriter.write(object, output);
+            valueWriter.write(object, output, context);
         }
     }
 
-    static <T, C extends Collection<T>> C readCollection(
+    static <T, C extends Collection<T>> void fillCollectionFrom(
             DataInput input,
-            IntFunction<C> collectionFactory,
+            C collection,
             ValueReader<T> valueReader,
             UnmarshallingContext context
     ) throws IOException, UnmarshalException {
         int length = input.readInt();
-        C collection = collectionFactory.apply(length);
+
         for (int i = 0; i < length; i++) {
             collection.add(valueReader.read(input, context));
         }
-        return collection;
     }
 
-    static <K, V> void writeMap(Map<K, V> map, DataOutput output, ValueWriter<K> keyWriter, ValueWriter<V> valueWriter)
-            throws IOException, MarshalException {
+    static <T, C extends Collection<T>> C preInstantiateCollection(DataInput input, IntFunction<C> collectionFactory) throws IOException {
+        int length = input.readInt();
+        return collectionFactory.apply(length);
+    }
+
+    static <T, C extends Collection<T>> void fillSingletonCollectionFrom(
+            DataInput input,
+            C collection,
+            ValueReader<T> elementReader,
+            UnmarshallingContext context
+    ) throws IOException, UnmarshalException {
+        T element = elementReader.read(input, context);
+
+        try {
+            singletonListElementField.set(collection, element);
+        } catch (ReflectiveOperationException e) {
+            throw new UnmarshalException("Cannot set field value", e);
+        }
+    }
+
+    static <K, V> void writeMap(
+            Map<K, V> map,
+            DataOutput output,
+            ValueWriter<K> keyWriter,
+            ValueWriter<V> valueWriter,
+            MarshallingContext context
+    ) throws IOException, MarshalException {
         output.writeInt(map.size());
+
         for (Map.Entry<K, V> entry : map.entrySet()) {
-            keyWriter.write(entry.getKey(), output);
-            valueWriter.write(entry.getValue(), output);
+            keyWriter.write(entry.getKey(), output, context);
+            valueWriter.write(entry.getValue(), output, context);
         }
     }
 
-    static <K, V, M extends Map<K, V>> M readMap(
+    static <K, V, M extends Map<K, V>> void fillMapFrom(
             DataInput input,
-            IntFunction<M> mapFactory,
+            M map,
             ValueReader<K> keyReader,
             ValueReader<V> valueReader,
             UnmarshallingContext context
     ) throws IOException, UnmarshalException {
         int length = input.readInt();
-        M map = mapFactory.apply(length);
         for (int i = 0; i < length; i++) {
             map.put(keyReader.read(input, context), valueReader.read(input, context));
         }
-        return map;
+    }
+
+    static <K, V, M extends Map<K, V>> M preInstantiateMap(DataInput input, IntFunction<M> mapFactory) throws IOException {
+        int length = input.readInt();
+        return mapFactory.apply(length);
     }
 
     static void writeBitSet(BitSet object, DataOutput output) throws IOException {
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInNonContainerMarshallers.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInNonContainerMarshallers.java
index aa3db82..7cd0758 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInNonContainerMarshallers.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/BuiltInNonContainerMarshallers.java
@@ -25,9 +25,9 @@ import java.util.BitSet;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.Set;
 import java.util.UUID;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+import org.apache.ignite.internal.network.serialization.Null;
 import org.apache.ignite.lang.IgniteUuid;
 
 /**
@@ -66,6 +66,7 @@ class BuiltInNonContainerMarshallers {
         addSingle(map, Enum.class, BuiltInMarshalling::writeEnum, BuiltInMarshalling::readEnum);
         addSingle(map, Enum[].class, BuiltInMarshalling::writeEnumArray, BuiltInMarshalling::readEnumArray);
         addSingle(map, BitSet.class, BuiltInMarshalling::writeBitSet, BuiltInMarshalling::readBitSet);
+        addSingle(map, Null.class, (obj, output) -> {}, input -> null);
         addSingle(map, Void.class, (obj, output) -> {}, input -> null);
 
         return Map.copyOf(map);
@@ -85,25 +86,29 @@ class BuiltInNonContainerMarshallers {
     private static <T> void addSingle(
             Map<Class<?>, BuiltInMarshaller<?>> map,
             Class<T> objectClass,
-            ValueWriter<T> writer,
+            ContextlessValueWriter<T> writer,
             ContextlessValueReader<T> reader
     ) {
-        addSingle(map, objectClass, writer, contextless(reader));
+        addSingle(map, objectClass, contextless(writer), contextless(reader));
     }
 
     private static <T> void addPrimitiveAndWrapper(
             Map<Class<?>, BuiltInMarshaller<?>> map,
             Class<?> primitiveClass,
             Class<T> wrapperClass,
-            ValueWriter<T> writer,
+            ContextlessValueWriter<T> writer,
             ContextlessValueReader<T> reader
     ) {
-        BuiltInMarshaller<T> builtInMarshaller = builtInMarshaller(wrapperClass, writer, contextless(reader));
+        BuiltInMarshaller<T> builtInMarshaller = builtInMarshaller(wrapperClass, contextless(writer), contextless(reader));
 
         map.put(primitiveClass, builtInMarshaller);
         map.put(wrapperClass, builtInMarshaller);
     }
 
+    private static <T> ValueWriter<T> contextless(ContextlessValueWriter<T> writer) {
+        return (obj, out, ctx) -> writer.write(obj, out);
+    }
+
     private static <T> ValueReader<T> contextless(ContextlessValueReader<T> reader) {
         return (in, ctx) -> reader.read(in);
     }
@@ -122,12 +127,13 @@ class BuiltInNonContainerMarshallers {
         return builtInMarshallers.containsKey(classToCheck);
     }
 
-    Set<ClassDescriptor> writeBuiltIn(Object object, ClassDescriptor descriptor, DataOutput output) throws IOException, MarshalException {
+    void writeBuiltIn(Object object, ClassDescriptor descriptor, DataOutput output, MarshallingContext context)
+            throws IOException, MarshalException {
         BuiltInMarshaller<?> builtInMarshaller = findBuiltInMarshaller(descriptor);
 
-        builtInMarshaller.marshal(object, output);
+        builtInMarshaller.marshal(object, output, context);
 
-        return Set.of(descriptor);
+        context.addUsedDescriptor(descriptor);
     }
 
     Object readBuiltIn(ClassDescriptor descriptor, DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
@@ -154,8 +160,8 @@ class BuiltInNonContainerMarshallers {
             this.reader = reader;
         }
 
-        private void marshal(Object object, DataOutput output) throws IOException, MarshalException {
-            writer.write(valueRefClass.cast(object), output);
+        private void marshal(Object object, DataOutput output, MarshallingContext context) throws IOException, MarshalException {
+            writer.write(valueRefClass.cast(object), output, context);
         }
 
         private Object unmarshal(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
@@ -163,6 +169,19 @@ class BuiltInNonContainerMarshallers {
         }
     }
 
+    interface ContextlessValueWriter<T> {
+        /**
+         * Writes the given value to a {@link DataOutput}.
+         *
+         * @param value     value to write
+         * @param output    where to write to
+         * @throws IOException      if an I/O problem occurs
+         * @throws MarshalException if another problem occurs
+         */
+        void write(T value, DataOutput output) throws IOException, MarshalException;
+    }
+
+
     private interface ContextlessValueReader<T> {
         /**
          * Reads the next value from a {@link DataInput}.
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
index fb72a05..146ada9 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshaller.java
@@ -27,17 +27,17 @@ import java.io.DataOutput;
 import java.io.DataOutputStream;
 import java.io.Externalizable;
 import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
 import java.util.Collection;
 import java.util.Map;
-import java.util.Set;
 import org.apache.ignite.internal.network.serialization.BuiltinType;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorFactory;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorFactoryContext;
 import org.apache.ignite.internal.network.serialization.IdIndexedDescriptors;
 import org.apache.ignite.internal.network.serialization.Null;
+import org.apache.ignite.internal.network.serialization.SerializedStreamCommands;
 import org.apache.ignite.internal.network.serialization.SpecialMethodInvocationException;
 import org.jetbrains.annotations.Nullable;
 
@@ -49,11 +49,27 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller {
     private final ClassDescriptorFactory descriptorFactory;
 
     private final BuiltInNonContainerMarshallers builtInNonContainerMarshallers = new BuiltInNonContainerMarshallers();
-    private final BuiltInContainerMarshallers builtInContainerMarshallers = new BuiltInContainerMarshallers(this::marshalToOutput);
-
+    private final BuiltInContainerMarshallers builtInContainerMarshallers = new BuiltInContainerMarshallers(
+            (obj, out, ctx) -> marshalToOutput(obj, objectClass(obj), out, ctx)
+    );
+    private final ExternalizableMarshaller externalizableMarshaller = new ExternalizableMarshaller();
+    private final ArbitraryObjectMarshaller arbitraryObjectMarshaller;
+
+    /**
+     * Constructor.
+     *
+     * @param descriptorRegistry registry of local descriptors to consult with
+     * @param descriptorFactory  descriptor factory to create new descriptors from classes
+     */
     public DefaultUserObjectMarshaller(ClassDescriptorFactoryContext descriptorRegistry, ClassDescriptorFactory descriptorFactory) {
         this.descriptorRegistry = descriptorRegistry;
         this.descriptorFactory = descriptorFactory;
+
+        arbitraryObjectMarshaller = new ArbitraryObjectMarshaller(
+                descriptorRegistry,
+                this::marshalToOutput,
+                this::unmarshalFromInput
+        );
     }
 
     public MarshalledObject marshal(@Nullable Object object) throws MarshalException {
@@ -63,53 +79,110 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller {
     /** {@inheritDoc} */
     @Override
     public MarshalledObject marshal(@Nullable Object object, Class<?> declaredClass) throws MarshalException {
-        Set<ClassDescriptor> usedDescriptors;
+        MarshallingContext context = new MarshallingContext();
 
         var baos = new ByteArrayOutputStream();
         try (var dos = new DataOutputStream(baos)) {
-            usedDescriptors = marshalToOutput(object, declaredClass, dos);
+            marshalToOutput(object, declaredClass, dos, context);
         } catch (IOException e) {
             throw new MarshalException("Cannot marshal", e);
         }
 
-        return new MarshalledObject(baos.toByteArray(), usedDescriptors);
-    }
-
-    private Set<ClassDescriptor> marshalToOutput(Object element, DataOutput output) throws MarshalException, IOException {
-        return marshalToOutput(element, objectClass(element), output);
+        return new MarshalledObject(baos.toByteArray(), context.usedDescriptors());
     }
 
-    private Set<ClassDescriptor> marshalToOutput(@Nullable Object object, Class<?> declaredClass, DataOutput output)
+    private void marshalToOutput(@Nullable Object object, Class<?> declaredClass, DataOutput output, MarshallingContext context)
             throws MarshalException, IOException {
         assert declaredClass != null;
         assert object == null
                 || declaredClass.isPrimitive()
                 || objectIsMemberOfEnumWithAnonymousClassesForMembers(object, declaredClass)
-                || object.getClass() == declaredClass
+                || declaredClass.isAssignableFrom(object.getClass())
                 : "Object " + object + " is expected to have class " + declaredClass + ", but its " + object.getClass();
 
+        throwIfMarshallingNotSupported(object);
+
         DescribedObject writeReplaced = applyWriteReplaceIfNeeded(object, declaredClass);
 
-        writeDescriptorId(writeReplaced.descriptor, output);
+        if (canParticipateInCycles(writeReplaced.descriptor)) {
+            Integer maybeRefId = context.rememberAsSeen(writeReplaced.object);
+            if (maybeRefId != null) {
+                writeReference(maybeRefId, output);
+            } else {
+                marshalCycleable(writeReplaced, output, context);
+            }
+        } else {
+            marshalNonCycleable(writeReplaced, output, context);
+        }
+    }
 
-        return writeObject(writeReplaced.object, writeReplaced.descriptor, output);
+    /**
+     * Returns {@code true} if an instance of the type represented by the descriptor may actively form a cycle.
+     *
+     * @param descriptor    descriptor to check
+     * @return {@code true} if an instance of the type represented by the descriptor may actively form a cycle
+     */
+    boolean canParticipateInCycles(ClassDescriptor descriptor) {
+        return !builtInNonContainerMarshallers.supports(descriptor.clazz());
     }
 
     private boolean objectIsMemberOfEnumWithAnonymousClassesForMembers(Object object, Class<?> declaredClass) {
         return declaredClass.isEnum() && object.getClass().getSuperclass() == declaredClass;
     }
 
+    private void throwIfMarshallingNotSupported(@Nullable Object object) {
+        if (object == null) {
+            return;
+        }
+        if (Enum.class.isAssignableFrom(object.getClass())) {
+            return;
+        }
+
+        Class<?> objectClass = object.getClass();
+        if (isInnerClass(objectClass)) {
+            throw new IllegalArgumentException("Non-static inner class instances are not supported for marshalling: " + objectClass);
+        }
+        if (isCapturingClosure(objectClass)) {
+            throw new IllegalArgumentException("Capturing nested class instances are not supported for marshalling: " + object);
+        }
+    }
+
+    private boolean isInnerClass(Class<?> objectClass) {
+        return objectClass.getDeclaringClass() != null && !Modifier.isStatic(objectClass.getModifiers());
+    }
+
+    private boolean isCapturingClosure(Class<?> objectClass) {
+        for (Field field : objectClass.getDeclaredFields()) {
+            if ((field.isSynthetic() && field.getName().equals("this$0"))
+                    || field.getName().startsWith("arg$")) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     private DescribedObject applyWriteReplaceIfNeeded(@Nullable Object originalObject, Class<?> declaredClass) throws MarshalException {
-        final ClassDescriptor originalDescriptor = getOrCreateDescriptor(declaredClass);
+        // object class is not a subclass of the declared class for primitives
+        // for enums we don't need the specific classes at all
+        Class<?> classToQueryForOriginalDescriptor = isInstanceOfSubclass(originalObject, declaredClass)
+                && !(originalObject instanceof Enum)
+                ? originalObject.getClass() : declaredClass;
+
+        final ClassDescriptor originalDescriptor = getOrCreateDescriptor(classToQueryForOriginalDescriptor);
+
+        if (originalDescriptor.supportsWriteReplace()) {
+            Object objectToWrite = applyWriteReplace(originalObject, originalDescriptor);
+            ClassDescriptor descriptorToUse = getOrCreateDescriptor(objectToWrite, objectClass(objectToWrite));
 
-        if (!originalDescriptor.supportsWriteReplace()) {
+            return new DescribedObject(objectToWrite, descriptorToUse);
+        } else {
             return new DescribedObject(originalObject, originalDescriptor);
         }
+    }
 
-        Object objectToWrite = applyWriteReplace(originalObject, originalDescriptor);
-        ClassDescriptor descriptorToUse = getOrCreateDescriptor(objectToWrite, objectClass(objectToWrite));
-
-        return new DescribedObject(objectToWrite, descriptorToUse);
+    private boolean isInstanceOfSubclass(@Nullable Object object, Class<?> maybeSuperclass) {
+        return object != null && maybeSuperclass.isAssignableFrom(object.getClass());
     }
 
     @Nullable
@@ -148,41 +221,67 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller {
         ClassDescriptor descriptor = descriptorRegistry.getDescriptor(objectClass);
         if (descriptor != null) {
             return descriptor;
-        }
+        } else {
+            // This is some custom class (not a built-in). If it's a non-built-in array, we need handle it as a generic container.
+            if (objectClass.isArray()) {
+                return descriptorRegistry.getBuiltInDescriptor(BuiltinType.OBJECT_ARRAY);
+            }
 
-        // This is some custom class (not a built-in). If it's a non-built-in array, we need handle it as a generic container.
-        if (objectClass.isArray()) {
-            return descriptorRegistry.getBuiltInDescriptor(BuiltinType.OBJECT_ARRAY);
+            return descriptorFactory.create(objectClass);
         }
-
-        descriptor = descriptorFactory.create(objectClass);
-        return descriptor;
     }
 
     private boolean isEnumArray(Class<?> objectClass) {
         return objectClass.isArray() && objectClass.getComponentType().isEnum();
     }
 
+    private void writeReference(int referenceId, DataOutput output) throws IOException {
+        writeDescriptorOrCommandId(SerializedStreamCommands.REFERENCE, output);
+        writeReferenceId(referenceId, output);
+    }
+
+    private void marshalCycleable(DescribedObject describedObject, DataOutput output, MarshallingContext context)
+            throws IOException, MarshalException {
+        writeDescriptorId(describedObject.descriptor, output);
+        writeReferenceId(context.referenceId(describedObject.object), output);
+
+        writeObject(describedObject.object, describedObject.descriptor, output, context);
+    }
+
+    private void marshalNonCycleable(DescribedObject describedObject, DataOutput output, MarshallingContext context)
+            throws IOException, MarshalException {
+        writeDescriptorId(describedObject.descriptor, output);
+
+        writeObject(describedObject.object, describedObject.descriptor, output, context);
+    }
+
     private void writeDescriptorId(ClassDescriptor descriptor, DataOutput output) throws IOException {
-        output.writeInt(descriptor.descriptorId());
+        writeDescriptorOrCommandId(descriptor.descriptorId(), output);
     }
 
-    private Set<ClassDescriptor> writeObject(@Nullable Object object, ClassDescriptor descriptor, DataOutput output)
+    private void writeDescriptorOrCommandId(int id, DataOutput output) throws IOException {
+        output.writeInt(id);
+    }
+
+    private void writeReferenceId(int referenceId, DataOutput output) throws IOException {
+        output.writeInt(referenceId);
+    }
+
+    private void writeObject(@Nullable Object object, ClassDescriptor descriptor, DataOutput output, MarshallingContext context)
             throws IOException, MarshalException {
-        if (descriptor.isNull()) {
-            return Set.of(descriptor);
-        } else if (isBuiltInNonContainer(descriptor)) {
-            return builtInNonContainerMarshallers.writeBuiltIn(object, descriptor, output);
+        if (isBuiltInNonContainer(descriptor)) {
+            builtInNonContainerMarshallers.writeBuiltIn(object, descriptor, output, context);
         } else if (isBuiltInCollection(descriptor)) {
-            return builtInContainerMarshallers.writeBuiltInCollection((Collection<?>) object, descriptor, output);
+            builtInContainerMarshallers.writeBuiltInCollection((Collection<?>) object, descriptor, output, context);
         } else if (isBuiltInMap(descriptor)) {
-            return builtInContainerMarshallers.writeBuiltInMap((Map<?, ?>) object, descriptor, output);
+            builtInContainerMarshallers.writeBuiltInMap((Map<?, ?>) object, descriptor, output, context);
         } else if (isArray(descriptor)) {
-            return builtInContainerMarshallers.writeGenericRefArray((Object[]) object, descriptor, output);
+            //noinspection ConstantConditions
+            builtInContainerMarshallers.writeGenericRefArray((Object[]) object, descriptor, output, context);
         } else if (descriptor.isExternalizable()) {
-            return writeExternalizable((Externalizable) object, descriptor, output);
+            externalizableMarshaller.writeExternalizable((Externalizable) object, descriptor, output, context);
         } else {
-            throw new UnsupportedOperationException("Not supported yet");
+            arbitraryObjectMarshaller.writeArbitraryObject(object, descriptor, output, context);
         }
     }
 
@@ -202,32 +301,12 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller {
         return descriptor.isBuiltIn() && Map.class.isAssignableFrom(descriptor.clazz());
     }
 
-    private Set<ClassDescriptor> writeExternalizable(Externalizable externalizable, ClassDescriptor descriptor, DataOutput output)
-            throws IOException {
-        byte[] externalizableBytes = externalize(externalizable);
-
-        output.writeInt(externalizableBytes.length);
-        output.write(externalizableBytes);
-
-        return Set.of(descriptor);
-    }
-
-    private byte[] externalize(Externalizable externalizable) throws IOException {
-        var baos = new ByteArrayOutputStream();
-        try (var oos = new ObjectOutputStream(baos)) {
-            externalizable.writeExternal(oos);
-        }
-
-        return baos.toByteArray();
-    }
-
     /** {@inheritDoc} */
     @Override
     @Nullable
     public <T> T unmarshal(byte[] bytes, IdIndexedDescriptors mergedDescriptors) throws UnmarshalException {
-        UnmarshallingContext context = new UnmarshallingContext(mergedDescriptors);
-
-        try (var dis = new DataInputStream(new ByteArrayInputStream(bytes))) {
+        try (var bais = new ByteArrayInputStream(bytes); var dis = new DataInputStream(bais)) {
+            UnmarshallingContext context = new UnmarshallingContext(bais, mergedDescriptors);
             return unmarshalFromInput(dis, context);
         } catch (IOException e) {
             throw new UnmarshalException("Cannot unmarshal", e);
@@ -235,91 +314,137 @@ public class DefaultUserObjectMarshaller implements UserObjectMarshaller {
     }
 
     private <T> T unmarshalFromInput(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
-        int descriptorId = readDescriptorId(input);
-        ClassDescriptor descriptor = context.getRequiredDescriptor(descriptorId);
+        int commandOrDescriptorId = readDescriptorOrCommandId(input);
+        if (commandOrDescriptorId == SerializedStreamCommands.REFERENCE) {
+            // TODO: IGNITE-16165 - make sure readResolve() is applied correctly when we exit early due to reading a reference
+            return unmarshalReference(input, context);
+        }
+
+        ClassDescriptor descriptor = context.getRequiredDescriptor(commandOrDescriptorId);
+        Object readObject;
+        if (canParticipateInCycles(descriptor)) {
+            readObject = readCycleable(input, context, descriptor);
+        } else {
+            readObject = readObject(input, descriptor, context);
+        }
 
-        Object readObject = readObject(input, descriptor, context);
         @SuppressWarnings("unchecked") T resolvedObject = (T) applyReadResolveIfNeeded(descriptor, readObject);
         return resolvedObject;
     }
 
-    private int readDescriptorId(DataInput input) throws IOException {
+    private int readDescriptorOrCommandId(DataInput input) throws IOException {
         return input.readInt();
     }
 
-    private Object applyReadResolveIfNeeded(ClassDescriptor descriptor, Object object) throws UnmarshalException {
-        if (descriptor.hasReadResolve()) {
-            return applyReadResolve(descriptor, object);
-        } else {
-            return object;
-        }
+    private <T> T unmarshalReference(DataInput input, UnmarshallingContext context) throws IOException {
+        int referenceId = input.readInt();
+        return context.dereference(referenceId);
     }
 
-    private Object applyReadResolve(ClassDescriptor descriptor, Object readObject) throws UnmarshalException {
-        try {
-            return descriptor.serializationMethods().readResolve(readObject);
-        } catch (SpecialMethodInvocationException e) {
-            throw new UnmarshalException("Cannot apply readResolve()", e);
-        }
+    private Object readCycleable(DataInput input, UnmarshallingContext context, ClassDescriptor descriptor)
+            throws IOException, UnmarshalException {
+        int referenceId = readReferenceId(input);
+
+        Object preInstantiatedObject = preInstantiate(descriptor, input, context);
+        context.registerReference(referenceId, preInstantiatedObject);
+
+        fillObjectFrom(input, preInstantiatedObject, descriptor, context);
+
+        return preInstantiatedObject;
     }
 
-    @Nullable
-    private Object readObject(DataInput input, ClassDescriptor descriptor, UnmarshallingContext context)
+    private int readReferenceId(DataInput input) throws IOException {
+        return input.readInt();
+    }
+
+    private Object preInstantiate(ClassDescriptor descriptor, DataInput input, UnmarshallingContext context)
             throws IOException, UnmarshalException {
-        if (descriptor.isNull()) {
-            return null;
-        } else if (isBuiltInNonContainer(descriptor)) {
-            return builtInNonContainerMarshallers.readBuiltIn(descriptor, input, context);
+        if (isBuiltInNonContainer(descriptor)) {
+            throw new IllegalStateException("Should not be here");
         } else if (isBuiltInCollection(descriptor)) {
-            return readBuiltInCollection(input, descriptor, context);
+            return builtInContainerMarshallers.preInstantiateBuiltInMutableCollection(descriptor, input, context);
         } else if (isBuiltInMap(descriptor)) {
-            return readBuiltInMap(input, descriptor, context);
+            return builtInContainerMarshallers.preInstantiateBuiltInMutableMap(descriptor, input, context);
         } else if (isArray(descriptor)) {
-            return readGenericRefArray(input, context);
+            return preInstantiateGenericRefArray(input);
         } else if (descriptor.isExternalizable()) {
-            return readExternalizable(descriptor, input);
+            return externalizableMarshaller.preInstantiateExternalizable(descriptor);
         } else {
-            throw new UnsupportedOperationException("Not supported yet");
+            return arbitraryObjectMarshaller.preInstantiateArbitraryObject(descriptor);
         }
     }
 
-    private Object[] readGenericRefArray(DataInput input, UnmarshallingContext context) throws IOException, UnmarshalException {
-        return builtInContainerMarshallers.readGenericRefArray(input, this::unmarshalFromInput, context);
+    private Object[] preInstantiateGenericRefArray(DataInput input) throws IOException {
+        return builtInContainerMarshallers.preInstantiateGenericRefArray(input);
     }
 
-    private Collection<Object> readBuiltInCollection(DataInput input, ClassDescriptor descriptor, UnmarshallingContext context)
+    private void fillObjectFrom(DataInput input, Object preInstantiatedObject, ClassDescriptor descriptor, UnmarshallingContext context)
             throws UnmarshalException, IOException {
-        return builtInContainerMarshallers.readBuiltInCollection(descriptor, this::unmarshalFromInput, input, context);
+        if (isBuiltInNonContainer(descriptor)) {
+            throw new IllegalStateException("Cannot fill " + descriptor.clazz() + ", this is a programmatic error");
+        } else if (isBuiltInCollection(descriptor)) {
+            fillBuiltInCollectionFrom(input, (Collection<?>) preInstantiatedObject, descriptor, context);
+        } else if (isBuiltInMap(descriptor)) {
+            fillBuiltInMapFrom(input, (Map<?, ?>) preInstantiatedObject, context);
+        } else if (isArray(descriptor)) {
+            fillGenericRefArrayFrom(input, (Object[]) preInstantiatedObject, context);
+        } else if (descriptor.isExternalizable()) {
+            externalizableMarshaller.fillExternalizableFrom(input, (Externalizable) preInstantiatedObject);
+        } else {
+            arbitraryObjectMarshaller.fillArbitraryObjectFrom(input, preInstantiatedObject, descriptor, context);
+        }
     }
 
-    private Map<Object, Object> readBuiltInMap(DataInput input, ClassDescriptor descriptor, UnmarshallingContext context)
-            throws UnmarshalException, IOException {
-        return builtInContainerMarshallers.readBuiltInMap(descriptor, this::unmarshalFromInput, this::unmarshalFromInput, input, context);
+    private void fillBuiltInCollectionFrom(
+            DataInput input,
+            Collection<?> preInstantiatedObject,
+            ClassDescriptor descriptor,
+            UnmarshallingContext context
+    ) throws UnmarshalException, IOException {
+        builtInContainerMarshallers.fillBuiltInCollectionFrom(input, preInstantiatedObject, descriptor, this::unmarshalFromInput, context);
     }
 
-    private <T extends Externalizable> T readExternalizable(ClassDescriptor descriptor, DataInput input)
-            throws IOException, UnmarshalException {
-        T object = instantiateObject(descriptor);
+    private void fillBuiltInMapFrom(
+            DataInput input,
+            Map<?, ?> preInstantiatedObject,
+            UnmarshallingContext context
+    ) throws UnmarshalException, IOException {
+        builtInContainerMarshallers.fillBuiltInMapFrom(input,
+                preInstantiatedObject,
+                this::unmarshalFromInput,
+                this::unmarshalFromInput,
+                context
+        );
+    }
 
-        int length = input.readInt();
-        byte[] bytes = new byte[length];
-        input.readFully(bytes);
+    private void fillGenericRefArrayFrom(DataInput input, Object[] array, UnmarshallingContext context)
+            throws IOException, UnmarshalException {
+        builtInContainerMarshallers.fillGenericRefArray(input, array, this::unmarshalFromInput, context);
+    }
 
-        try (var ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
-            object.readExternal(ois);
-        } catch (ClassNotFoundException e) {
-            throw new UnmarshalException("Cannot unmarshal due to a missing class", e);
+    @Nullable
+    private Object readObject(DataInput input, ClassDescriptor descriptor, UnmarshallingContext context)
+            throws IOException, UnmarshalException {
+        if (isBuiltInNonContainer(descriptor)) {
+            return builtInNonContainerMarshallers.readBuiltIn(descriptor, input, context);
+        } else {
+            throw new IllegalStateException("Cannot read an instance of " + descriptor.clazz() + ", this is a programmatic error");
         }
+    }
 
-        return object;
+    private Object applyReadResolveIfNeeded(ClassDescriptor descriptor, Object object) throws UnmarshalException {
+        if (descriptor.hasReadResolve()) {
+            return applyReadResolve(descriptor, object);
+        } else {
+            return object;
+        }
     }
 
-    @SuppressWarnings("unchecked")
-    private <T extends Externalizable> T instantiateObject(ClassDescriptor descriptor) throws UnmarshalException {
+    private Object applyReadResolve(ClassDescriptor descriptor, Object readObject) throws UnmarshalException {
         try {
-            return (T) descriptor.clazz().getConstructor().newInstance();
-        } catch (ReflectiveOperationException e) {
-            throw new UnmarshalException("Cannot instantiate " + descriptor.clazz(), e);
+            return descriptor.serializationMethods().readResolve(readObject);
+        } catch (SpecialMethodInvocationException e) {
+            throw new UnmarshalException("Cannot apply readResolve()", e);
         }
     }
 
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ExternalizableMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ExternalizableMarshaller.java
new file mode 100644
index 0000000..6ace075
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ExternalizableMarshaller.java
@@ -0,0 +1,75 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInput;
+import java.io.DataOutput;
+import java.io.Externalizable;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+
+/**
+ * (Um)marshalling specific to EXTERNALIZABLE serialization type.
+ */
+class ExternalizableMarshaller {
+    private final NoArgConstructorInstantiation instantiation = new NoArgConstructorInstantiation();
+
+    void writeExternalizable(Externalizable externalizable, ClassDescriptor descriptor, DataOutput output, MarshallingContext context)
+            throws IOException {
+        byte[] externalizableBytes = externalize(externalizable);
+
+        output.writeInt(externalizableBytes.length);
+        output.write(externalizableBytes);
+
+        context.addUsedDescriptor(descriptor);
+    }
+
+    private byte[] externalize(Externalizable externalizable) throws IOException {
+        var baos = new ByteArrayOutputStream();
+        try (var oos = new ObjectOutputStream(baos)) {
+            externalizable.writeExternal(oos);
+        }
+
+        return baos.toByteArray();
+    }
+
+    @SuppressWarnings("unchecked")
+    <T extends Externalizable> T preInstantiateExternalizable(ClassDescriptor descriptor) throws UnmarshalException {
+        try {
+            return (T) instantiation.newInstance(descriptor.clazz());
+        } catch (InstantiationException e) {
+            throw new UnmarshalException("Cannot instantiate " + descriptor.clazz(), e);
+        }
+    }
+
+    <T extends Externalizable> void fillExternalizableFrom(DataInput input, T object) throws IOException, UnmarshalException {
+        int length = input.readInt();
+        byte[] bytes = new byte[length];
+        input.readFully(bytes);
+
+        try (var ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
+            object.readExternal(ois);
+        } catch (ClassNotFoundException e) {
+            throw new UnmarshalException("Cannot unmarshal due to a missing class", e);
+        }
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/Instantiation.java
similarity index 50%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/Instantiation.java
index d3d75b9..b1d39d0 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/Instantiation.java
@@ -17,20 +17,26 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import java.io.DataOutput;
-import java.io.IOException;
-
 /**
- * Knows how to write a value to a {@link DataOutput}.
+ * Strategy for creating an empty (not yet filled with values) instance of a class.
+ * Only used to instantiate proper classes, never gets interfaces, primitive classes and so on,
+ * so implementations should not bother checking for them in {@link #supports(Class)}.
  */
-interface ValueWriter<T> {
+interface Instantiation {
+    /**
+     * Returns {@code true} iff supports the provided class for means of instantiation.
+     *
+     * @param objectClass   class to check for support
+     * @return {@code true} iff supports the provided class for means of instantiation
+     */
+    boolean supports(Class<?> objectClass);
+
     /**
-     * Writes the given value to a {@link DataOutput}.
+     * Creates a new instance of the provided class.
      *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
+     * @param objectClass   class to instantiate
+     * @return new instance of the given class
+     * @throws InstantiationException   if something goes wrong during instantiation
      */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+    Object newInstance(Class<?> objectClass) throws InstantiationException;
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/InstantiationException.java
similarity index 64%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/InstantiationException.java
index d3d75b9..9d600c4 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/InstantiationException.java
@@ -17,20 +17,18 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import java.io.DataOutput;
-import java.io.IOException;
+import org.apache.ignite.lang.IgniteInternalCheckedException;
+import org.jetbrains.annotations.Nullable;
 
 /**
- * Knows how to write a value to a {@link DataOutput}.
+ * Thrown if class instantiation fails.
  */
-interface ValueWriter<T> {
-    /**
-     * Writes the given value to a {@link DataOutput}.
-     *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
-     */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+public class InstantiationException extends IgniteInternalCheckedException {
+    public InstantiationException(String msg) {
+        super(msg);
+    }
+
+    public InstantiationException(String msg, @Nullable Throwable cause) {
+        super(msg, cause);
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContext.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContext.java
new file mode 100644
index 0000000..5989190
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContext.java
@@ -0,0 +1,90 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static java.util.Collections.unmodifiableSet;
+
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.Set;
+import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Context using during marshalling of an object graph accessible from a root object.
+ */
+class MarshallingContext {
+    private final Set<ClassDescriptor> usedDescriptors = new HashSet<>();
+
+    private final Map<Object, Integer> objectsToRefIds = new IdentityHashMap<>();
+
+    private int nextRefId = 0;
+
+    public void addUsedDescriptor(ClassDescriptor descriptor) {
+        usedDescriptors.add(descriptor);
+    }
+
+    public Set<ClassDescriptor> usedDescriptors() {
+        return unmodifiableSet(usedDescriptors);
+    }
+
+    /**
+     * If the object was already seen before, its ID is returned; otherwise, it's memorized as seen with a fresh ID.
+     *
+     * @param object object to operate upon
+     * @return object ID if it was seen earlier or {@code null} if the object is new
+     */
+    @Nullable
+    public Integer rememberAsSeen(@Nullable Object object) {
+        if (object == null) {
+            return null;
+        }
+
+        Integer prevRefId = objectsToRefIds.get(object);
+        if (prevRefId != null) {
+            return prevRefId;
+        } else {
+            int newRefId = nextRefId();
+
+            objectsToRefIds.put(object, newRefId);
+
+            return null;
+        }
+    }
+
+    private int nextRefId() {
+        return nextRefId++;
+    }
+
+    /**
+     * Returns a reference ID by the given object.
+     *
+     * @param object lookup object
+     * @return object ID
+     */
+    public int referenceId(Object object) {
+        Integer refId = objectsToRefIds.get(object);
+
+        if (refId == null) {
+            throw new IllegalStateException("No reference created yet for " + object);
+        }
+
+        return refId;
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiation.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiation.java
new file mode 100644
index 0000000..acfaf56
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiation.java
@@ -0,0 +1,50 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import java.lang.reflect.Constructor;
+
+/**
+ * Instantiates using no-arg constructor. It only supports classes that have such constructors (but the constructors
+ * may be private, they are still supported).
+ */
+class NoArgConstructorInstantiation implements Instantiation {
+    /** {@inheritDoc} */
+    @Override
+    public boolean supports(Class<?> objectClass) {
+        for (Constructor<?> constructor : objectClass.getDeclaredConstructors()) {
+            if (constructor.getParameterCount() == 0) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Object newInstance(Class<?> objectClass) throws InstantiationException {
+        try {
+            Constructor<?> constructor = objectClass.getDeclaredConstructor();
+            constructor.setAccessible(true);
+            return constructor.newInstance();
+        } catch (ReflectiveOperationException e) {
+            throw new InstantiationException("Cannot instantiate " + objectClass, e);
+        }
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiation.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiation.java
new file mode 100644
index 0000000..665bfb1
--- /dev/null
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiation.java
@@ -0,0 +1,115 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+import java.io.ObjectStreamConstants;
+import java.io.Serializable;
+import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+import org.apache.ignite.internal.network.serialization.ClassIndexedDescriptors;
+
+/**
+ * Instantiates {@link Serializable} classes (they are the only ones supported) by crafting a representation of
+ * a serialized object of a given class (without any field data) and then deserializing it using the standard
+ * Java Serialization.
+ */
+class SerializableInstantiation implements Instantiation {
+
+    private static final int STREAM_VERSION = 5;
+
+    private final ClassIndexedDescriptors descriptors;
+
+    SerializableInstantiation(ClassIndexedDescriptors descriptors) {
+        this.descriptors = descriptors;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean supports(Class<?> objectClass) {
+        if (!Serializable.class.isAssignableFrom(objectClass)) {
+            return false;
+        }
+
+        ClassDescriptor descriptor = descriptors.getRequiredDescriptor(objectClass);
+        return !descriptor.hasWriteReplace() && !descriptor.hasReadResolve();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Object newInstance(Class<?> objectClass) throws InstantiationException {
+        byte[] jdkSerialization = jdkSerializationOfEmptyInstanceOf(objectClass);
+
+        try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(jdkSerialization))) {
+            return ois.readObject();
+        } catch (IOException | ClassNotFoundException e) {
+            throw new InstantiationException("Cannot deserialize JDK serialization of an empty instance", e);
+        }
+    }
+
+    private byte[] jdkSerializationOfEmptyInstanceOf(Class<?> objectClass) throws InstantiationException {
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+        try (DataOutputStream dos = new DataOutputStream(baos)) {
+            writeSignature(dos);
+
+            dos.writeByte(ObjectStreamConstants.TC_OBJECT);
+            dos.writeByte(ObjectStreamConstants.TC_CLASSDESC);
+            dos.writeUTF(objectClass.getName());
+
+            dos.writeLong(serialVersionUid(objectClass));
+
+            writeFlags(dos);
+
+            writeZeroFields(dos);
+
+            dos.writeByte(ObjectStreamConstants.TC_ENDBLOCKDATA);
+            writeNullForNoParentDescriptor(dos);
+        } catch (IOException e) {
+            throw new InstantiationException("Cannot create JDK serialization of an empty instance", e);
+        }
+
+        return baos.toByteArray();
+    }
+
+    private void writeSignature(DataOutputStream dos) throws IOException {
+        dos.writeShort(ObjectStreamConstants.STREAM_MAGIC);
+        dos.writeShort(STREAM_VERSION);
+    }
+
+    private long serialVersionUid(Class<?> objectClass) {
+        ObjectStreamClass descriptor = ObjectStreamClass.lookup(objectClass);
+        return descriptor.getSerialVersionUID();
+    }
+
+    private void writeFlags(DataOutputStream dos) throws IOException {
+        dos.writeByte(ObjectStreamConstants.SC_SERIALIZABLE);
+    }
+
+    private void writeZeroFields(DataOutputStream dos) throws IOException {
+        dos.writeShort(0);
+    }
+
+    private void writeNullForNoParentDescriptor(DataOutputStream dos) throws IOException {
+        dos.writeByte(ObjectStreamConstants.TC_NULL);
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TrackingMarshaller.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TypedValueWriter.java
similarity index 64%
rename from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TrackingMarshaller.java
rename to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TypedValueWriter.java
index 718a34d..294bc4e 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TrackingMarshaller.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/TypedValueWriter.java
@@ -19,21 +19,21 @@ package org.apache.ignite.internal.network.serialization.marshal;
 
 import java.io.DataOutput;
 import java.io.IOException;
-import java.util.Set;
-import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 
 /**
- * Marshals objects to a {@link DataOutput} and also tracks what {@link ClassDescriptor}s were used when marshalling.
+ * Writes objects to a {@link DataOutput} taking their original (for example, declared) types into consideration.
  */
-interface TrackingMarshaller {
+interface TypedValueWriter {
     /**
-     * Marshals the given object to the {@link DataOutput}.
+     * Writes the given object to the {@link DataOutput}.
      *
-     * @param object    object to marshal
-     * @param output    where to marshal to
-     * @return {@link ClassDescriptor}s that were used when marshalling
+     * @param object        object to write
+     * @param declaredClass the original class of the object (i.e. {@code byte.class} for {@code byte})
+     * @param output        where to write to
+     * @param context       marshalling context
      * @throws IOException      if an I/O problem occurs
      * @throws MarshalException if another problem occurs
      */
-    Set<ClassDescriptor> marshal(Object object, DataOutput output) throws IOException, MarshalException;
+    void write(Object object, Class<?> declaredClass, DataOutput output, MarshallingContext context)
+            throws IOException, MarshalException;
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java
index ebea301..94d7325 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java
@@ -17,6 +17,9 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
+import java.io.ByteArrayInputStream;
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 import org.apache.ignite.internal.network.serialization.IdIndexedDescriptors;
 import org.jetbrains.annotations.Nullable;
@@ -25,9 +28,13 @@ import org.jetbrains.annotations.Nullable;
  * Context of unmarshalling act. Created once per unmarshalling a root object.
  */
 class UnmarshallingContext implements IdIndexedDescriptors {
+    private final ByteArrayInputStream source;
     private final IdIndexedDescriptors descriptors;
 
-    public UnmarshallingContext(IdIndexedDescriptors descriptors) {
+    private final Map<Integer, Object> refsToObjects = new HashMap<>();
+
+    public UnmarshallingContext(ByteArrayInputStream source, IdIndexedDescriptors descriptors) {
+        this.source = source;
         this.descriptors = descriptors;
     }
 
@@ -36,4 +43,27 @@ class UnmarshallingContext implements IdIndexedDescriptors {
     public @Nullable ClassDescriptor getDescriptor(int descriptorId) {
         return descriptors.getDescriptor(descriptorId);
     }
+
+    public void registerReference(int referenceId, Object object) {
+        refsToObjects.put(referenceId, object);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <T> T dereference(int referenceId) {
+        Object result = refsToObjects.get(referenceId);
+
+        if (result == null) {
+            throw new IllegalStateException("Unknown reference: " + referenceId);
+        }
+
+        return (T) result;
+    }
+
+    public void markSource() {
+        source.mark(4);
+    }
+
+    public void resetSourceToMark() {
+        source.reset();
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiation.java
similarity index 58%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java
copy to modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiation.java
index ebea301..5912344 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnmarshallingContext.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiation.java
@@ -17,23 +17,26 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import org.apache.ignite.internal.network.serialization.ClassDescriptor;
-import org.apache.ignite.internal.network.serialization.IdIndexedDescriptors;
-import org.jetbrains.annotations.Nullable;
+import org.apache.ignite.internal.util.GridUnsafe;
 
 /**
- * Context of unmarshalling act. Created once per unmarshalling a root object.
+ * Instantiation strategy that uses {@link sun.misc.Unsafe#allocateInstance(Class)} to create an empty instance.
+ * It supports any class.
  */
-class UnmarshallingContext implements IdIndexedDescriptors {
-    private final IdIndexedDescriptors descriptors;
-
-    public UnmarshallingContext(IdIndexedDescriptors descriptors) {
-        this.descriptors = descriptors;
+class UnsafeInstantiation implements Instantiation {
+    /** {@inheritDoc} */
+    @Override
+    public boolean supports(Class<?> objectClass) {
+        return true;
     }
 
     /** {@inheritDoc} */
     @Override
-    public @Nullable ClassDescriptor getDescriptor(int descriptorId) {
-        return descriptors.getDescriptor(descriptorId);
+    public Object newInstance(Class<?> objectClass) throws InstantiationException {
+        try {
+            return GridUnsafe.allocateInstance(objectClass);
+        } catch (java.lang.InstantiationException e) {
+            throw new InstantiationException("Cannot instantiate " + objectClass, e);
+        }
     }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
index d3d75b9..fe18d79 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
@@ -27,10 +27,11 @@ interface ValueWriter<T> {
     /**
      * Writes the given value to a {@link DataOutput}.
      *
-     * @param value  value to write
-     * @param output where to write to
+     * @param value     value to write
+     * @param output    where to write to
+     * @param context   marshalling context
      * @throws IOException      if an I/O problem occurs
      * @throws MarshalException if another problem occurs
      */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+    void write(T value, DataOutput output, MarshallingContext context) throws IOException, MarshalException;
 }
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryTest.java
index ca5ab1c..332097e 100644
--- a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryTest.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/ClassDescriptorFactoryTest.java
@@ -20,6 +20,8 @@ package org.apache.ignite.internal.network.serialization;
 import static org.apache.ignite.internal.network.serialization.SerializationType.ARBITRARY;
 import static org.apache.ignite.internal.network.serialization.SerializationType.EXTERNALIZABLE;
 import static org.apache.ignite.internal.network.serialization.SerializationType.SERIALIZABLE;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -441,4 +443,33 @@ public class ClassDescriptorFactoryTest {
         assertEquals(writeReplace, serialization.hasWriteReplace());
         assertEquals(readResolve, serialization.hasReadResolve());
     }
+
+    @Test
+    void shouldSortArbitraryObjectFieldsByClassHierarchyAndLexicographicallyByFieldName() {
+        ClassDescriptor descriptor = factory.create(ArbitraryWithFieldNameClashAndOrderPermutation.class);
+
+        assertThat(descriptor.fields().get(0).clazz(), is(String.class));
+        assertThat(descriptor.fields().get(0).name(), is("value"));
+
+        assertThat(descriptor.fields().get(1).clazz(), is(int.class));
+        assertThat(descriptor.fields().get(1).name(), is("apple"));
+
+        assertThat(descriptor.fields().get(2).clazz(), is(int.class));
+        assertThat(descriptor.fields().get(2).name(), is("banana"));
+
+        assertThat(descriptor.fields().get(3).clazz(), is(int.class));
+        assertThat(descriptor.fields().get(3).name(), is("value"));
+    }
+
+    @SuppressWarnings("unused")
+    private static class Parent {
+        private String value;
+    }
+
+    @SuppressWarnings("unused")
+    private static class ArbitraryWithFieldNameClashAndOrderPermutation extends Parent {
+        private int value;
+        private int banana;
+        private int apple;
+    }
 }
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiationTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiationTest.java
new file mode 100644
index 0000000..4dccead
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/BestEffortInstantiationTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class BestEffortInstantiationTest {
+    @Mock
+    private Instantiation delegate1;
+    @Mock
+    private Instantiation delegate2;
+
+    private BestEffortInstantiation instantiation;
+
+    @BeforeEach
+    void initMocks() throws Exception {
+        lenient().when(delegate1.newInstance(any())).thenReturn("first");
+        lenient().when(delegate2.newInstance(any())).thenReturn("second");
+    }
+
+    @BeforeEach
+    void createObjectUnderTest() {
+        instantiation = new BestEffortInstantiation(delegate1, delegate2);
+    }
+
+    @Test
+    void whenFirstDelegateSupportsThenThisSupports() {
+        when(delegate1.supports(any())).thenReturn(true);
+
+        assertTrue(instantiation.supports(Object.class));
+    }
+
+    @Test
+    void whenOnlySecondDelegateSupportsThenThisSupports() {
+        when(delegate2.supports(any())).thenReturn(true);
+
+        assertTrue(instantiation.supports(Object.class));
+    }
+
+    @Test
+    void whenNoDelegateSupportsThenThisDoesNotSupport() {
+        assertFalse(instantiation.supports(Object.class));
+    }
+
+    @Test
+    void whenFirstDelegateSupportsThenItIsUsedForInstantiation() throws Exception {
+        when(delegate1.supports(any())).thenReturn(true);
+
+        Object instance = instantiation.newInstance(Object.class);
+
+        assertThat(instance, is("first"));
+    }
+
+    @Test
+    void whenOnlySecondDelegateSupportsThenItIsUsedForInstantiation() throws Exception {
+        when(delegate2.supports(any())).thenReturn(true);
+
+        Object instance = instantiation.newInstance(Object.class);
+
+        assertThat(instance, is("second"));
+    }
+
+    @Test
+    void whenNoDelegateSupportsThenInstantiationFails() {
+        InstantiationException ex = assertThrows(InstantiationException.class, () -> instantiation.newInstance(Object.class));
+        assertThat(ex.getMessage(), is("No delegate supports " + Object.class));
+    }
+}
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithArbitraryObjectsTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithArbitraryObjectsTest.java
new file mode 100644
index 0000000..51201ab
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithArbitraryObjectsTest.java
@@ -0,0 +1,504 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static java.util.Collections.singletonList;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.sameInstance;
+import static org.hamcrest.Matchers.startsWith;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import org.apache.ignite.internal.network.serialization.BuiltinType;
+import org.apache.ignite.internal.network.serialization.ClassDescriptorFactory;
+import org.apache.ignite.internal.network.serialization.ClassDescriptorFactoryContext;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for how {@link DefaultUserObjectMarshaller} handles arbitrary objects.
+ */
+class DefaultUserObjectMarshallerWithArbitraryObjectsTest {
+    private final ClassDescriptorFactoryContext descriptorRegistry = new ClassDescriptorFactoryContext();
+    private final ClassDescriptorFactory descriptorFactory = new ClassDescriptorFactory(descriptorRegistry);
+
+    private final DefaultUserObjectMarshaller marshaller = new DefaultUserObjectMarshaller(descriptorRegistry, descriptorFactory);
+
+    @Test
+    void marshalsAndUnmarshalsSimpleClassInstances() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new Simple(42));
+
+        Simple unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.value, is(42));
+    }
+
+    private <T> T unmarshalNonNull(MarshalledObject marshalled) throws UnmarshalException {
+        T unmarshalled = marshaller.unmarshal(marshalled.bytes(), descriptorRegistry);
+
+        assertThat(unmarshalled, is(notNullValue()));
+
+        return unmarshalled;
+    }
+
+    @Test
+    void marshalsArbitraryObjectsUsingDescriptorsOfThemAndTheirContents() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new Simple(42));
+
+        assertThat(marshalled.usedDescriptors(), equalTo(Set.of(
+                descriptorRegistry.getRequiredDescriptor(Simple.class),
+                descriptorRegistry.getBuiltInDescriptor(BuiltinType.INT)
+        )));
+    }
+
+    @Test
+    void marshalsArbitraryObjectWithCorrectDescriptorIdInMarshalledRepresentation() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new Simple(42));
+
+        assertThat(readType(marshalled), is(descriptorRegistry.getRequiredDescriptor(Simple.class).descriptorId()));
+    }
+
+    private int readType(MarshalledObject marshalled) throws IOException {
+        try (var dis = new DataInputStream(new ByteArrayInputStream(marshalled.bytes()))) {
+            return dis.readInt();
+        }
+    }
+
+    @Test
+    void marshalsAndUnmarshalsClassInstancesInvolvingSuperclasses() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new SimpleChild("answer", 42));
+
+        SimpleChild unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.parentValue(), is("answer"));
+        assertThat(unmarshalled.childValue(), is(42));
+    }
+
+    @Test
+    void marshalsAndUnmarshalsClassInstancesHavingNestedArbitraryObjects() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithArbitraryClassField(new Simple(42)));
+
+        WithArbitraryClassField unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.nested, is(notNullValue()));
+        assertThat(unmarshalled.nested.value, is(42));
+    }
+
+    @Test
+    void marshalsAndUnmarshalsClassInstancesHavingCollectionsOfArbitraryObjects() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(withArbitraryObjectInArrayList(new Simple(42)));
+
+        WithArbitraryObjectInList unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.list, hasSize(1));
+        assertThat(unmarshalled.list.get(0).value, is(42));
+    }
+
+    private WithArbitraryObjectInList withArbitraryObjectInArrayList(Simple object) {
+        List<Simple> list = new ArrayList<>(List.of(object));
+        return new WithArbitraryObjectInList(list);
+    }
+
+    @Test
+    void marshalsAndUnmarshalsClassInstancesHavingPolymorphicNestedArbitraryObjects() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithArbitraryClassField(new ChildOfSimple(42)));
+
+        WithArbitraryClassField unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.nested, is(instanceOf(ChildOfSimple.class)));
+        assertThat(unmarshalled.nested.value, is(42));
+    }
+
+    @Test
+    void marshalsAndUnmarshalsClassInstancesHavingCollectionsOfPolymorphicArbitraryObjects() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(withArbitraryObjectInArrayList(new ChildOfSimple(42)));
+
+        WithArbitraryObjectInList unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.list, hasSize(1));
+        assertThat(unmarshalled.list.get(0), is(instanceOf(ChildOfSimple.class)));
+        assertThat(unmarshalled.list.get(0).value, is(42));
+    }
+
+    @Test
+    void restoresConcreteCollectionTypeCorrectlyWhenUnmarshalls() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(withArbitraryObjectInArrayList(new Simple(42)));
+
+        WithArbitraryObjectInList unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.list, is(instanceOf(ArrayList.class)));
+    }
+
+    @Test
+    void ignoresTransientFields() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithTransientFields("Hi"));
+
+        WithTransientFields unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.value, is(nullValue()));
+    }
+
+    @Test
+    void supportsFinalFields() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithFinalFields(42));
+
+        WithFinalFields unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.value, is(42));
+    }
+
+    @Test
+    void supportsNonCapturingAnonymousClassInstances() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(nonCapturingAnonymousInstance());
+
+        Callable<String> unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.call(), is("Hi!"));
+    }
+
+    @SuppressWarnings("Convert2Lambda")
+    private static Callable<String> nonCapturingAnonymousInstance() {
+        return new Callable<>() {
+            @Override
+            public String call() {
+                return "Hi!";
+            }
+        };
+    }
+
+    @Test
+    void supportsNonCapturingLambdas() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(nonCapturingLambda());
+
+        Callable<String> unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.call(), is("Hi!"));
+    }
+
+    private static Callable<String> nonCapturingLambda() {
+        return () -> "Hi!";
+    }
+
+    @Test
+    @Disabled("IGNITE-16165")
+    // TODO: IGNITE-16165 - enable this test when we are able to work with serializable lambdas
+    void supportsNonCapturingSerializableLambdas() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(nonCapturingSerializableLambda());
+
+        Callable<String> unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.call(), is("Hi!"));
+    }
+
+    private static Callable<String> nonCapturingSerializableLambda() {
+        return (Callable<String> & Serializable) () -> "Hi!";
+    }
+
+    @Test
+    void doesNotSupportInnerClassInstances() {
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(new Inner()));
+        assertThat(ex.getMessage(), is("Non-static inner class instances are not supported for marshalling: " + Inner.class));
+    }
+
+    @Test
+    void doesNotSupportInnerClassInstancesInsideContainers() {
+        List<Inner> list = singletonList(new Inner());
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(list));
+        assertThat(ex.getMessage(), is("Non-static inner class instances are not supported for marshalling: " + Inner.class));
+    }
+
+    @Test
+    void doesNotSupportCapturingAnonymousClassInstances() {
+        Runnable capturingClosure = capturingAnonymousInstance();
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(capturingClosure));
+        assertThat(ex.getMessage(), startsWith("Capturing nested class instances are not supported for marshalling: "));
+    }
+
+    private Runnable capturingAnonymousInstance() {
+        //noinspection Convert2Lambda
+        return new Runnable() {
+            @Override
+            public void run() {
+                System.out.println(DefaultUserObjectMarshallerWithArbitraryObjectsTest.this);
+            }
+        };
+    }
+
+    @Test
+    void doesNotSupportCapturingAnonymousClassInstancesInsideContainers() {
+        Runnable capturingAnonymousInstance = capturingAnonymousInstance();
+        List<Runnable> list = singletonList(capturingAnonymousInstance);
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(list));
+        assertThat(ex.getMessage(), startsWith("Capturing nested class instances are not supported for marshalling: "));
+    }
+
+    @Test
+    void doesNotSupportCapturingLambdas() {
+        Runnable capturingClosure = capturingLambda();
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(capturingClosure));
+        assertThat(ex.getMessage(), startsWith("Capturing nested class instances are not supported for marshalling: "));
+    }
+
+    private Runnable capturingLambda() {
+        return () -> System.out.println(DefaultUserObjectMarshallerWithArbitraryObjectsTest.this);
+    }
+
+    @Test
+    void doesNotSupportCapturingAnonymousLambdasInsideContainers() {
+        Runnable capturingLambda = capturingLambda();
+        List<Runnable> list = singletonList(capturingLambda);
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(list));
+        assertThat(ex.getMessage(), startsWith("Capturing nested class instances are not supported for marshalling: "));
+    }
+
+    @Test
+    void supportsNonCapturingLocalClassInstances() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(nonCapturingLocalClassInstance());
+
+        Callable<String> unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.call(), is("Hi!"));
+    }
+
+    private static Object nonCapturingLocalClassInstance() {
+        class Local implements Callable<String> {
+            /** {@inheritDoc} */
+            @Override
+            public String call() {
+                return "Hi!";
+            }
+        }
+
+        return new Local();
+    }
+
+    @Test
+    void doesNotSupportCapturingLocalClassInstances() {
+        Object instance = capturingLocalClassInstance();
+
+        Throwable ex = assertThrows(IllegalArgumentException.class, () -> marshaller.marshal(instance));
+        assertThat(ex.getMessage(), startsWith("Capturing nested class instances are not supported for marshalling: "));
+    }
+
+    private Object capturingLocalClassInstance() {
+        class Local {
+        }
+
+        return new Local();
+    }
+
+    @Test
+    void supportsClassesWithoutNoArgConstructor() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithoutNoArgConstructor(42));
+
+        WithoutNoArgConstructor unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.value, is(42));
+    }
+
+    @Test
+    void supportsInstancesDirectlyContainingThemselvesInFields() throws Exception {
+        MarshalledObject marshalled = marshaller.marshal(new WithInfiniteCycleViaField(42));
+
+        WithInfiniteCycleViaField unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.value, is(42));
+        assertThat(unmarshalled.myself, is(sameInstance(unmarshalled)));
+    }
+
+    @Test
+    void supportsInstancesParticipatingInIndirectInfiniteCyclesViaArbitraryObjects() throws Exception {
+        WithFirstCyclePart first = new WithFirstCyclePart();
+        WithSecondCyclePart second = new WithSecondCyclePart();
+        first.part = second;
+        second.part = first;
+
+        MarshalledObject marshalled = marshaller.marshal(first);
+
+        WithFirstCyclePart unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.part.part, is(sameInstance(unmarshalled)));
+    }
+
+    @Test
+    void supportsInstancesParticipatingInIndirectInfiniteCyclesViaMutableContainers() throws Exception {
+        WithObjectList object = new WithObjectList();
+        List<Object> container = new ArrayList<>();
+        object.contents = container;
+        container.add(object);
+
+        MarshalledObject marshalled = marshaller.marshal(object);
+
+        WithObjectList unmarshalled = unmarshalNonNull(marshalled);
+
+        assertThat(unmarshalled.contents.get(0), is(sameInstance(unmarshalled)));
+    }
+
+    private static class Simple {
+        private int value;
+
+        @SuppressWarnings("unused") // needed for instantiation
+        public Simple() {
+        }
+
+        public Simple(int value) {
+            this.value = value;
+        }
+    }
+
+    private abstract static class Parent {
+        private String value;
+
+        public Parent() {
+        }
+
+        public Parent(String value) {
+            this.value = value;
+        }
+
+        String parentValue() {
+            return value;
+        }
+    }
+
+    private static class SimpleChild extends Parent {
+        private int value;
+
+        @SuppressWarnings("unused") // needed for instantiation
+        public SimpleChild() {
+        }
+
+        public SimpleChild(String parentValue, int childValue) {
+            super(parentValue);
+            this.value = childValue;
+        }
+
+        int childValue() {
+            return value;
+        }
+    }
+
+    private static class WithArbitraryClassField {
+        private Simple nested;
+
+        @SuppressWarnings("unused") // used for instantiation
+        public WithArbitraryClassField() {
+        }
+
+        public WithArbitraryClassField(Simple nested) {
+            this.nested = nested;
+        }
+    }
+
+    private static class WithArbitraryObjectInList {
+        private List<Simple> list;
+
+        @SuppressWarnings("unused") // needed for instantiation
+        public WithArbitraryObjectInList() {
+        }
+
+        public WithArbitraryObjectInList(List<Simple> list) {
+            this.list = list;
+        }
+    }
+
+    private static class ChildOfSimple extends Simple {
+        @SuppressWarnings("unused") // needed for instantiation
+        public ChildOfSimple() {
+        }
+
+        public ChildOfSimple(int value) {
+            super(value);
+        }
+    }
+
+    private static class WithTransientFields {
+        private transient String value;
+
+        @SuppressWarnings("unused") // needed for instantiation
+        public WithTransientFields() {
+        }
+
+        public WithTransientFields(String value) {
+            this.value = value;
+        }
+    }
+
+    private static class WithFinalFields {
+        private final int value;
+
+        @SuppressWarnings("unused") // needed for instantiation
+        public WithFinalFields() {
+            this(0);
+        }
+
+        private WithFinalFields(int value) {
+            this.value = value;
+        }
+    }
+
+    @SuppressWarnings("InnerClassMayBeStatic")
+    private class Inner {
+    }
+
+    private static class WithInfiniteCycleViaField {
+        private int value;
+        @SuppressWarnings({"FieldCanBeLocal", "unused"})
+        private WithInfiniteCycleViaField myself;
+
+        @SuppressWarnings("unused")
+        public WithInfiniteCycleViaField() {
+        }
+
+        public WithInfiniteCycleViaField(int value) {
+            this.value = value;
+
+            this.myself = this;
+        }
+    }
+
+    private static class WithFirstCyclePart {
+        private WithSecondCyclePart part;
+    }
+
+    private static class WithSecondCyclePart {
+        private WithFirstCyclePart part;
+    }
+
+    private static class WithObjectList {
+        private List<Object> contents;
+    }
+}
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithBuiltinsTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithBuiltinsTest.java
index e8aafcc..937986f 100644
--- a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithBuiltinsTest.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/DefaultUserObjectMarshallerWithBuiltinsTest.java
@@ -23,7 +23,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.notNullValue;
-import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.sameInstance;
 import static org.junit.jupiter.api.Assumptions.assumingThat;
 
 import java.io.ByteArrayInputStream;
@@ -42,13 +42,16 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
 import java.util.stream.Stream;
 import org.apache.ignite.internal.network.serialization.BuiltinType;
 import org.apache.ignite.internal.network.serialization.ClassDescriptor;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorFactory;
 import org.apache.ignite.internal.network.serialization.ClassDescriptorFactoryContext;
+import org.apache.ignite.internal.network.serialization.Null;
 import org.apache.ignite.lang.IgniteUuid;
-import org.jetbrains.annotations.NotNull;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -64,35 +67,6 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
     private final DefaultUserObjectMarshaller marshaller = new DefaultUserObjectMarshaller(descriptorRegistry, descriptorFactory);
 
     @Test
-    void marshalsAndUnmarshalsNull() throws Exception {
-        MarshalledObject marshalled = marshaller.marshal(null);
-
-        Object unmarshalled = marshaller.unmarshal(marshalled.bytes(), descriptorRegistry);
-
-        assertThat(unmarshalled, is(nullValue()));
-    }
-
-    @Test
-    void marshalsNullUsingOnlyNullDescriptor() throws Exception {
-        MarshalledObject marshalled = marshaller.marshal(null);
-
-        assertThat(marshalled.usedDescriptors(), equalTo(Set.of(descriptorRegistry.getNullDescriptor())));
-    }
-
-    @Test
-    void marshalsNullWithCorrectDescriptorIdInMarshalledRepresentation() throws Exception {
-        MarshalledObject marshalled = marshaller.marshal(null);
-
-        assertThat(readType(marshalled), is(BuiltinType.NULL.descriptorId()));
-    }
-
-    private int readType(MarshalledObject marshalled) throws IOException {
-        try (var dis = new DataInputStream(new ByteArrayInputStream(marshalled.bytes()))) {
-            return dis.readInt();
-        }
-    }
-
-    @Test
     void marshalsAndUnmarshalsBareObject() throws Exception {
         MarshalledObject marshalled = marshaller.marshal(new Object());
 
@@ -123,6 +97,12 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
         assertThat(readType(marshalled), is(BuiltinType.BARE_OBJECT.descriptorId()));
     }
 
+    private int readType(MarshalledObject marshalled) throws IOException {
+        try (var dis = new DataInputStream(new ByteArrayInputStream(marshalled.bytes()))) {
+            return dis.readInt();
+        }
+    }
+
     @Test
     void marshalsObjectArrayUsingExactlyDescriptorsOfObjectArrayAndComponents() throws Exception {
         MarshalledObject marshalled = marshaller.marshal(new Object[]{42, "abc"});
@@ -149,7 +129,8 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
         Object unmarshalled = marshaller.unmarshal(marshalled.bytes(), descriptorRegistry);
 
         assertThat(unmarshalled, is(equalTo(typeValue.value)));
-        if (typeValue.builtinType != BuiltinType.VOID && typeValue.value.getClass().isArray()) {
+        if (typeValue.builtinType != BuiltinType.VOID && typeValue.builtinType != BuiltinType.NULL
+                && typeValue.value.getClass().isArray()) {
             assertThat(unmarshalled, is(notNullValue()));
             assertThat(unmarshalled.getClass().getComponentType(), is(typeValue.value.getClass().getComponentType()));
         }
@@ -170,57 +151,58 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
 
     static Stream<Arguments> builtInNonCollectionTypes() {
         return Stream.of(
-                builtInTypeValueArg((byte) 42, byte.class, BuiltinType.BYTE),
-                builtInTypeValueArg((byte) 42, Byte.class, BuiltinType.BYTE_BOXED),
-                builtInTypeValueArg((short) 42, short.class, BuiltinType.SHORT),
-                builtInTypeValueArg((short) 42, Short.class, BuiltinType.SHORT_BOXED),
-                builtInTypeValueArg(42, int.class, BuiltinType.INT),
-                builtInTypeValueArg(42, Integer.class, BuiltinType.INT_BOXED),
-                builtInTypeValueArg(42.0f, float.class, BuiltinType.FLOAT),
-                builtInTypeValueArg(42.0f, Float.class, BuiltinType.FLOAT_BOXED),
-                builtInTypeValueArg((long) 42, long.class, BuiltinType.LONG),
-                builtInTypeValueArg((long) 42, Long.class, BuiltinType.LONG_BOXED),
-                builtInTypeValueArg(42.0, double.class, BuiltinType.DOUBLE),
-                builtInTypeValueArg(42.0, Double.class, BuiltinType.DOUBLE_BOXED),
-                builtInTypeValueArg(true, boolean.class, BuiltinType.BOOLEAN),
-                builtInTypeValueArg(true, Boolean.class, BuiltinType.BOOLEAN_BOXED),
-                builtInTypeValueArg('a', char.class, BuiltinType.CHAR),
-                builtInTypeValueArg('a', Character.class, BuiltinType.CHAR_BOXED),
+                builtInTypeValue((byte) 42, byte.class, BuiltinType.BYTE),
+                builtInTypeValue((byte) 42, Byte.class, BuiltinType.BYTE_BOXED),
+                builtInTypeValue((short) 42, short.class, BuiltinType.SHORT),
+                builtInTypeValue((short) 42, Short.class, BuiltinType.SHORT_BOXED),
+                builtInTypeValue(42, int.class, BuiltinType.INT),
+                builtInTypeValue(42, Integer.class, BuiltinType.INT_BOXED),
+                builtInTypeValue(42.0f, float.class, BuiltinType.FLOAT),
+                builtInTypeValue(42.0f, Float.class, BuiltinType.FLOAT_BOXED),
+                builtInTypeValue((long) 42, long.class, BuiltinType.LONG),
+                builtInTypeValue((long) 42, Long.class, BuiltinType.LONG_BOXED),
+                builtInTypeValue(42.0, double.class, BuiltinType.DOUBLE),
+                builtInTypeValue(42.0, Double.class, BuiltinType.DOUBLE_BOXED),
+                builtInTypeValue(true, boolean.class, BuiltinType.BOOLEAN),
+                builtInTypeValue(true, Boolean.class, BuiltinType.BOOLEAN_BOXED),
+                builtInTypeValue('a', char.class, BuiltinType.CHAR),
+                builtInTypeValue('a', Character.class, BuiltinType.CHAR_BOXED),
                 // BARE_OBJECT is handled separately
-                builtInTypeValueArg("abc", String.class, BuiltinType.STRING),
-                builtInTypeValueArg(UUID.fromString("c6f57d4a-619f-11ec-add6-73bc97c3c49e"), UUID.class, BuiltinType.UUID),
-                builtInTypeValueArg(IgniteUuid.fromString("1234-c6f57d4a-619f-11ec-add6-73bc97c3c49e"), IgniteUuid.class,
+                builtInTypeValue("abc", String.class, BuiltinType.STRING),
+                builtInTypeValue(UUID.fromString("c6f57d4a-619f-11ec-add6-73bc97c3c49e"), UUID.class, BuiltinType.UUID),
+                builtInTypeValue(IgniteUuid.fromString("1234-c6f57d4a-619f-11ec-add6-73bc97c3c49e"), IgniteUuid.class,
                         BuiltinType.IGNITE_UUID),
-                builtInTypeValueArg(new Date(42), Date.class, BuiltinType.DATE),
-                builtInTypeValueArg(new byte[]{1, 2, 3}, byte[].class, BuiltinType.BYTE_ARRAY),
-                builtInTypeValueArg(new short[]{1, 2, 3}, short[].class, BuiltinType.SHORT_ARRAY),
-                builtInTypeValueArg(new int[]{1, 2, 3}, int[].class, BuiltinType.INT_ARRAY),
-                builtInTypeValueArg(new float[]{1.0f, 2.0f, 3.0f}, float[].class, BuiltinType.FLOAT_ARRAY),
-                builtInTypeValueArg(new long[]{1, 2, 3}, long[].class, BuiltinType.LONG_ARRAY),
-                builtInTypeValueArg(new double[]{1.0, 2.0, 3.0}, double[].class, BuiltinType.DOUBLE_ARRAY),
-                builtInTypeValueArg(new boolean[]{true, false}, boolean[].class, BuiltinType.BOOLEAN_ARRAY),
-                builtInTypeValueArg(new char[]{'a', 'b'}, char[].class, BuiltinType.CHAR_ARRAY),
-                builtInTypeValueArg(new Object[]{42, "123", null}, Object[].class, BuiltinType.OBJECT_ARRAY),
-                builtInTypeValueArg(new BitSet[]{BitSet.valueOf(new long[]{42, 43}), BitSet.valueOf(new long[]{1, 2}), null},
+                builtInTypeValue(new Date(42), Date.class, BuiltinType.DATE),
+                builtInTypeValue(new byte[]{1, 2, 3}, byte[].class, BuiltinType.BYTE_ARRAY),
+                builtInTypeValue(new short[]{1, 2, 3}, short[].class, BuiltinType.SHORT_ARRAY),
+                builtInTypeValue(new int[]{1, 2, 3}, int[].class, BuiltinType.INT_ARRAY),
+                builtInTypeValue(new float[]{1.0f, 2.0f, 3.0f}, float[].class, BuiltinType.FLOAT_ARRAY),
+                builtInTypeValue(new long[]{1, 2, 3}, long[].class, BuiltinType.LONG_ARRAY),
+                builtInTypeValue(new double[]{1.0, 2.0, 3.0}, double[].class, BuiltinType.DOUBLE_ARRAY),
+                builtInTypeValue(new boolean[]{true, false}, boolean[].class, BuiltinType.BOOLEAN_ARRAY),
+                builtInTypeValue(new char[]{'a', 'b'}, char[].class, BuiltinType.CHAR_ARRAY),
+                builtInTypeValue(new Object[]{42, "123", null}, Object[].class, BuiltinType.OBJECT_ARRAY),
+                builtInTypeValue(new BitSet[]{BitSet.valueOf(new long[]{42, 43}), BitSet.valueOf(new long[]{1, 2}), null},
                         BitSet[].class, BuiltinType.OBJECT_ARRAY),
-                builtInTypeValueArg(new String[]{"Ignite", "rulez"}, String[].class, BuiltinType.STRING_ARRAY),
-                builtInTypeValueArg(new BigDecimal(42), BigDecimal.class, BuiltinType.DECIMAL),
-                builtInTypeValueArg(new BigDecimal[]{new BigDecimal(42), new BigDecimal(43)}, BigDecimal[].class,
+                builtInTypeValue(new String[]{"Ignite", "rulez"}, String[].class, BuiltinType.STRING_ARRAY),
+                builtInTypeValue(new BigDecimal(42), BigDecimal.class, BuiltinType.DECIMAL),
+                builtInTypeValue(new BigDecimal[]{new BigDecimal(42), new BigDecimal(43)}, BigDecimal[].class,
                         BuiltinType.DECIMAL_ARRAY),
-                builtInTypeValueArg(SimpleEnum.FIRST, SimpleEnum.class, BuiltinType.ENUM),
-                builtInTypeValueArg(new Enum[]{SimpleEnum.FIRST, SimpleEnum.SECOND}, Enum[].class, BuiltinType.ENUM_ARRAY),
-                builtInTypeValueArg(new SimpleEnum[]{SimpleEnum.FIRST, SimpleEnum.SECOND}, SimpleEnum[].class, BuiltinType.ENUM_ARRAY),
-                builtInTypeValueArg(EnumWithAnonClassesForMembers.FIRST, EnumWithAnonClassesForMembers.class, BuiltinType.ENUM),
-                builtInTypeValueArg(new Enum[]{EnumWithAnonClassesForMembers.FIRST, EnumWithAnonClassesForMembers.SECOND}, Enum[].class,
+                builtInTypeValue(SimpleEnum.FIRST, SimpleEnum.class, BuiltinType.ENUM),
+                builtInTypeValue(new Enum[]{SimpleEnum.FIRST, SimpleEnum.SECOND}, Enum[].class, BuiltinType.ENUM_ARRAY),
+                builtInTypeValue(new SimpleEnum[]{SimpleEnum.FIRST, SimpleEnum.SECOND}, SimpleEnum[].class, BuiltinType.ENUM_ARRAY),
+                builtInTypeValue(EnumWithAnonClassesForMembers.FIRST, EnumWithAnonClassesForMembers.class, BuiltinType.ENUM),
+                builtInTypeValue(new Enum[]{EnumWithAnonClassesForMembers.FIRST, EnumWithAnonClassesForMembers.SECOND}, Enum[].class,
                         BuiltinType.ENUM_ARRAY),
-                builtInTypeValueArg(
+                builtInTypeValue(
                         new EnumWithAnonClassesForMembers[]{EnumWithAnonClassesForMembers.FIRST, EnumWithAnonClassesForMembers.SECOND},
                         EnumWithAnonClassesForMembers[].class,
                         BuiltinType.ENUM_ARRAY
                 ),
-                builtInTypeValueArg(BitSet.valueOf(new long[]{42, 43}), BitSet.class, BuiltinType.BIT_SET),
-                builtInTypeValueArg(null, Void.class, BuiltinType.VOID)
-        );
+                builtInTypeValue(BitSet.valueOf(new long[]{42, 43}), BitSet.class, BuiltinType.BIT_SET),
+                builtInTypeValue(null, Null.class, BuiltinType.NULL),
+                builtInTypeValue(null, Void.class, BuiltinType.VOID)
+        ).map(Arguments::of);
     }
 
     @ParameterizedTest
@@ -256,14 +238,14 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
 
     static Stream<Arguments> builtInCollectionTypes() {
         return Stream.of(
-                builtInTypeValueArg(new ArrayList<>(List.of(42, 43)), ArrayList.class, BuiltinType.ARRAY_LIST),
-                builtInTypeValueArg(new LinkedList<>(List.of(42, 43)), LinkedList.class, BuiltinType.LINKED_LIST),
-                builtInTypeValueArg(new HashSet<>(Set.of(42, 43)), HashSet.class, BuiltinType.HASH_SET),
-                builtInTypeValueArg(new LinkedHashSet<>(Set.of(42, 43)), LinkedHashSet.class, BuiltinType.LINKED_HASH_SET),
-                builtInTypeValueArg(singletonList(42), BuiltinType.SINGLETON_LIST.clazz(), BuiltinType.SINGLETON_LIST),
-                builtInTypeValueArg(new HashMap<>(Map.of(42, 43)), HashMap.class, BuiltinType.HASH_MAP),
-                builtInTypeValueArg(new LinkedHashMap<>(Map.of(42, 43)), LinkedHashMap.class, BuiltinType.LINKED_HASH_MAP)
-        );
+                builtInTypeValue(new ArrayList<>(List.of(42, 43)), ArrayList.class, BuiltinType.ARRAY_LIST),
+                builtInTypeValue(new LinkedList<>(List.of(42, 43)), LinkedList.class, BuiltinType.LINKED_LIST),
+                builtInTypeValue(new HashSet<>(Set.of(42, 43)), HashSet.class, BuiltinType.HASH_SET),
+                builtInTypeValue(new LinkedHashSet<>(Set.of(42, 43)), LinkedHashSet.class, BuiltinType.LINKED_HASH_SET),
+                builtInTypeValue(singletonList(42), BuiltinType.SINGLETON_LIST.clazz(), BuiltinType.SINGLETON_LIST),
+                builtInTypeValue(new HashMap<>(Map.of(42, 43)), HashMap.class, BuiltinType.HASH_MAP),
+                builtInTypeValue(new LinkedHashMap<>(Map.of(42, 43)), LinkedHashMap.class, BuiltinType.LINKED_HASH_MAP)
+        ).map(Arguments::of);
     }
 
     @ParameterizedTest
@@ -278,9 +260,64 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
         return Stream.concat(builtInNonCollectionTypes(), builtInCollectionTypes());
     }
 
-    @NotNull
-    private static Arguments builtInTypeValueArg(Object value, Class<?> valueClass, BuiltinType type) {
-        return Arguments.of(new BuiltInTypeValue(value, valueClass, type));
+    private static BuiltInTypeValue builtInTypeValue(Object value, Class<?> valueClass, BuiltinType type) {
+        return new BuiltInTypeValue(value, valueClass, type);
+    }
+
+    @Test
+    void unmarshalsObjectGraphWithCycleStartingWithSingletonList() throws Exception {
+        List<List<?>> mutableList = new ArrayList<>();
+        List<List<?>> singletonList = singletonList(mutableList);
+        mutableList.add(singletonList);
+
+        List<List<?>> unmarshalled = marshalAndUnmarshal(singletonList);
+
+        assertThat(unmarshalled.get(0).get(0), is(sameInstance(unmarshalled)));
+    }
+
+    private <T> T marshalAndUnmarshal(T object) throws MarshalException, UnmarshalException {
+        MarshalledObject marshalled = marshaller.marshal(object);
+        return unmarshalNonNull(marshalled);
+    }
+
+    @Test
+    void unmarshalsObjectGraphWithCycleContainingWithSingletonList() throws Exception {
+        List<List<?>> mutableList = new ArrayList<>();
+        List<List<?>> singletonList = singletonList(mutableList);
+        mutableList.add(singletonList);
+
+        List<List<?>> unmarshalled = marshalAndUnmarshal(mutableList);
+
+        assertThat(unmarshalled.get(0).get(0), is(sameInstance(unmarshalled)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("mutableContainerSelfAssignments")
+    <T> void unmarshalsObjectGraphWithSelfCycleViaMutableContainers(MutableContainerSelfAssignment<T> item) throws Exception {
+        T container = item.factory.get();
+        item.assignment.accept(container, container);
+
+        T unmarshalled = marshalAndUnmarshal(container);
+        T element = item.elementAccess.apply(unmarshalled);
+
+        assertThat(element, is(sameInstance(unmarshalled)));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Stream<Arguments> mutableContainerSelfAssignments() {
+        return Stream.of(
+                new MutableContainerSelfAssignment<>(Object[].class, () -> new Object[1], (a, b) -> a[0] = b, array -> (Object[]) array[0]),
+                new MutableContainerSelfAssignment<>(ArrayList.class, ArrayList::new, ArrayList::add, list -> (ArrayList<?>) list.get(0)),
+                new MutableContainerSelfAssignment<>(LinkedList.class, LinkedList::new, LinkedList::add,
+                        list -> (LinkedList<?>) list.get(0)),
+                new MutableContainerSelfAssignment<>(HashSet.class, HashSet::new, HashSet::add, set -> (HashSet<?>) set.iterator().next()),
+                new MutableContainerSelfAssignment<>(LinkedHashSet.class, LinkedHashSet::new, LinkedHashSet::add,
+                        set -> (LinkedHashSet<?>) set.iterator().next()),
+                new MutableContainerSelfAssignment<>(HashMap.class, HashMap::new, (map, el) -> map.put(el, el),
+                        map -> (HashMap<?, ?>) map.values().iterator().next()),
+                new MutableContainerSelfAssignment<>(LinkedHashMap.class, LinkedHashMap::new, (map, el) -> map.put(el, el),
+                        map -> (LinkedHashMap<?, ?>) map.values().iterator().next())
+        ).map(Arguments::of);
     }
 
     private enum SimpleEnum {
@@ -315,4 +352,30 @@ class DefaultUserObjectMarshallerWithBuiltinsTest {
                     + '}';
         }
     }
+
+    private static class MutableContainerSelfAssignment<T> {
+        private final Class<T> clazz;
+        private final Supplier<T> factory;
+        private final BiConsumer<T, T> assignment;
+        private final Function<T, T> elementAccess;
+
+        private MutableContainerSelfAssignment(
+                Class<T> clazz,
+                Supplier<T> factory,
+                BiConsumer<T, T> assignment,
+                Function<T, T> elementAccess
+        ) {
+            this.clazz = clazz;
+            this.factory = factory;
+            this.assignment = assignment;
+            this.elementAccess = elementAccess;
+        }
+
+        @Override
+        public String toString() {
+            return "ContainerSelfCycle{"
+                    + "clazz=" + clazz
+                    + '}';
+        }
+    }
 }
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContextTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContextTest.java
new file mode 100644
index 0000000..ec341c6
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/MarshallingContextTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Objects;
+import org.junit.jupiter.api.Test;
+
+class MarshallingContextTest {
+    private final MarshallingContext context = new MarshallingContext();
+
+    @Test
+    void firstMemorizationReturnsFalse() {
+        assertNull(context.rememberAsSeen(new Object()));
+    }
+
+    @Test
+    void secondMemorizationOfSameObjectReturnsTrue() {
+        Object object = new Object();
+
+        context.rememberAsSeen(object);
+
+        assertNotNull(context.rememberAsSeen(object));
+    }
+
+    @Test
+    void differentInstancesAreNotSameForMemorizationEvenWhenEqual() {
+        Key key1 = new Key("test");
+        Key key2 = new Key("test");
+
+        context.rememberAsSeen(key1);
+
+        assertNull(context.rememberAsSeen(key2));
+    }
+
+    @Test
+    void ignoresNulls() {
+        context.rememberAsSeen(null);
+
+        assertNull(context.rememberAsSeen(null));
+    }
+
+    private static class Key {
+        private final String key;
+
+        private Key(String key) {
+            this.key = key;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            Key key1 = (Key) o;
+            return Objects.equals(key, key1.key);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int hashCode() {
+            return Objects.hash(key);
+        }
+    }
+}
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiationTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiationTest.java
new file mode 100644
index 0000000..3357f13
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/NoArgConstructorInstantiationTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link NoArgConstructorInstantiation}.
+ */
+class NoArgConstructorInstantiationTest {
+    private final Instantiation instantiation = new NoArgConstructorInstantiation();
+
+    @Test
+    void supportsClassesHavingAccessibleNoArgConstructor() {
+        assertTrue(instantiation.supports(WithAccessibleNoArgConstructor.class));
+    }
+
+    @Test
+    void supportsClassesHavingPrivateNoArgConstructor() {
+        assertTrue(instantiation.supports(WithPrivateNoArgConstructor.class));
+    }
+
+    @Test
+    void doesNotSupportClassesWithoutNoArgConstructor() {
+        assertFalse(instantiation.supports(WithoutNoArgConstructor.class));
+    }
+
+    @Test
+    void instantiatesClassesHavingAccessibleNoArgConstructor() throws Exception {
+        Object instance = instantiation.newInstance(WithAccessibleNoArgConstructor.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    @Test
+    void instantiatesClassesHavingPrivateNoArgConstructor() throws Exception {
+        Object instance = instantiation.newInstance(WithPrivateNoArgConstructor.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+}
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiationTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiationTest.java
new file mode 100644
index 0000000..fbcb148
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/SerializableInstantiationTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.lenient;
+
+import java.io.Serializable;
+import org.apache.ignite.internal.network.serialization.ClassDescriptor;
+import org.apache.ignite.internal.network.serialization.ClassIndexedDescriptors;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Tests for {@link SerializableInstantiation}.
+ */
+@ExtendWith(MockitoExtension.class)
+class SerializableInstantiationTest {
+    @Mock
+    private ClassIndexedDescriptors descriptors;
+    @Mock
+    private ClassDescriptor descriptor;
+
+    private Instantiation instantiation;
+
+    @BeforeEach
+    void initMocks() {
+        lenient().when(descriptors.getRequiredDescriptor(any())).thenReturn(descriptor);
+    }
+
+    @BeforeEach
+    void createInstantiation() {
+        instantiation = new SerializableInstantiation(descriptors);
+    }
+
+    @Test
+    void doesNotSupportNonSerializableClasses() {
+        assertFalse(instantiation.supports(NotSerializable.class));
+    }
+
+    @Test
+    void supportsSerializableClasses() {
+        assertTrue(instantiation.supports(SerializableWithoutNoArgConstructor.class));
+    }
+
+    @Test
+    void doesNotSupportClassesWithWriteReplace() {
+        doReturn(true).when(descriptor).hasWriteReplace();
+
+        assertFalse(instantiation.supports(WithWriteReplace.class));
+    }
+
+    @Test
+    void doesNotSupportClassesWithReadResolve() {
+        doReturn(true).when(descriptor).hasReadResolve();
+
+        assertFalse(instantiation.supports(WithReadResolve.class));
+    }
+
+    // TODO: IGNITE-16165 - test that it does not support instantiation of Serializable classes with writeObject()/readObject()
+
+    @Test
+    void instantiatesSerializableClassesWithoutNoArgConstructor() throws Exception {
+        Object instance = instantiation.newInstance(SerializableWithoutNoArgConstructor.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    @Test
+    void instantiatesSerializableClassesWithFields() throws Exception {
+        Object instance = instantiation.newInstance(SerializableWithFields.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    @Test
+    void instantiatesSerializableClassesWithNonSerializableParents() throws Exception {
+        Object instance = instantiation.newInstance(SerializableWithNonSerializableParent.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    @Test
+    void instantiatesSerializableClassesWithSerializableParents() throws Exception {
+        Object instance = instantiation.newInstance(SerializableWithSerializableParent.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    private static class NotSerializable {
+    }
+
+    private static class SerializableWithoutNoArgConstructor implements Serializable {
+        @SuppressWarnings("unused")
+        private SerializableWithoutNoArgConstructor(int ignored) {
+        }
+    }
+
+    private static class SerializableWithFields implements Serializable {
+        @SuppressWarnings({"FieldCanBeLocal", "unused"})
+        private final int value;
+
+        private SerializableWithFields(int value) {
+            this.value = value;
+        }
+    }
+
+    private static class NonSerializableParent {
+        public NonSerializableParent() {
+        }
+    }
+
+    private static class SerializableWithNonSerializableParent extends NonSerializableParent implements Serializable {
+        @SuppressWarnings("unused")
+        private SerializableWithNonSerializableParent(int ignored) {
+        }
+    }
+
+    private static class SerializableParent implements Serializable {
+    }
+
+    private static class SerializableWithSerializableParent extends SerializableParent implements Serializable {
+        @SuppressWarnings("unused")
+        private SerializableWithSerializableParent(int ignored) {
+        }
+    }
+
+    private static class WithWriteReplace implements Serializable {
+        private Object writeReplace() {
+            return this;
+        }
+    }
+
+    private static class WithReadResolve implements Serializable {
+        private Object readResolve() {
+            return this;
+        }
+    }
+}
diff --git a/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiationTest.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiationTest.java
new file mode 100644
index 0000000..7beb536
--- /dev/null
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/UnsafeInstantiationTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.ignite.internal.network.serialization.marshal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class UnsafeInstantiationTest {
+    private final Instantiation instantiation = new UnsafeInstantiation();
+
+    @Test
+    void supportsClassesHavingNoArgConstructor() {
+        assertTrue(instantiation.supports(WithPrivateNoArgConstructor.class));
+    }
+
+    @Test
+    void supportsClassesWithoutNoArgConstructor() {
+        assertTrue(instantiation.supports(WithoutNoArgConstructor.class));
+    }
+
+    @Test
+    void instantiatesClassesHavingNoArgConstructor() throws Exception {
+        Object instance = instantiation.newInstance(WithPrivateNoArgConstructor.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+
+    @Test
+    void instantiatesClassesWithoutNoArgConstructor() throws Exception {
+        Object instance = instantiation.newInstance(WithoutNoArgConstructor.class);
+
+        assertThat(instance, is(notNullValue()));
+    }
+}
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithAccessibleNoArgConstructor.java
similarity index 63%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithAccessibleNoArgConstructor.java
index d3d75b9..48a8949 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithAccessibleNoArgConstructor.java
@@ -17,20 +17,5 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import java.io.DataOutput;
-import java.io.IOException;
-
-/**
- * Knows how to write a value to a {@link DataOutput}.
- */
-interface ValueWriter<T> {
-    /**
-     * Writes the given value to a {@link DataOutput}.
-     *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
-     */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+class WithAccessibleNoArgConstructor {
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithPrivateNoArgConstructor.java
similarity index 63%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithPrivateNoArgConstructor.java
index d3d75b9..913d9d2 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithPrivateNoArgConstructor.java
@@ -17,20 +17,7 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import java.io.DataOutput;
-import java.io.IOException;
-
-/**
- * Knows how to write a value to a {@link DataOutput}.
- */
-interface ValueWriter<T> {
-    /**
-     * Writes the given value to a {@link DataOutput}.
-     *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
-     */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+class WithPrivateNoArgConstructor {
+    private WithPrivateNoArgConstructor() {
+    }
 }
diff --git a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithoutNoArgConstructor.java
similarity index 63%
copy from modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
copy to modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithoutNoArgConstructor.java
index d3d75b9..0516176 100644
--- a/modules/network/src/main/java/org/apache/ignite/internal/network/serialization/marshal/ValueWriter.java
+++ b/modules/network/src/test/java/org/apache/ignite/internal/network/serialization/marshal/WithoutNoArgConstructor.java
@@ -17,20 +17,10 @@
 
 package org.apache.ignite.internal.network.serialization.marshal;
 
-import java.io.DataOutput;
-import java.io.IOException;
+class WithoutNoArgConstructor {
+    int value;
 
-/**
- * Knows how to write a value to a {@link DataOutput}.
- */
-interface ValueWriter<T> {
-    /**
-     * Writes the given value to a {@link DataOutput}.
-     *
-     * @param value  value to write
-     * @param output where to write to
-     * @throws IOException      if an I/O problem occurs
-     * @throws MarshalException if another problem occurs
-     */
-    void write(T value, DataOutput output) throws IOException, MarshalException;
+    public WithoutNoArgConstructor(int value) {
+        this.value = value;
+    }
 }