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

[ignite-3] branch main updated: IGNITE-16355 .NET: Support value types in the Table API (#1190)

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

ptupitsyn 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 c4c6821c4b IGNITE-16355 .NET: Support value types in the Table API (#1190)
c4c6821c4b is described below

commit c4c6821c4b1b66e0a7af59e8af4ba665e0656097
Author: Pavel Tupitsyn <pt...@apache.org>
AuthorDate: Wed Oct 12 12:56:34 2022 +0300

    IGNITE-16355 .NET: Support value types in the Table API (#1190)
    
    * Remove `where T : class` constraint from all APIs.
    * Use `Option<T>` as a return type for APIs which can return "no value" results, such as `Get`, `GetAndUpsert`, `GetAndDelete`.
        * It is not possible to handle nullable value and reference types in a generic way, so we have to use a common wrapper. `Option<T>` also clearly indicates which APIs always return something and which don't.
    * Fix `ObjectSerializerHandler` to support value types.
---
 .../Apache.Ignite.Tests/Compute/ComputeTests.cs    |   4 +
 .../dotnet/Apache.Ignite.Tests/OptionTests.cs      | 105 ++++++++++++++
 .../dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs     |   2 +-
 .../dotnet/Apache.Ignite.Tests/Table/PocoStruct.cs |  23 +++
 .../Table/RecordViewBinaryTests.cs                 | 132 ++++++++---------
 .../Table/RecordViewDefaultMappingTest.cs          |   6 +-
 .../Table/RecordViewPocoTests.cs                   | 158 +++++++++++++--------
 .../Serialization/ObjectSerializerHandlerTests.cs  |   1 -
 .../Transactions/TransactionsTests.cs              |  32 ++---
 .../dotnet/Apache.Ignite/Apache.Ignite.csproj      |   1 +
 .../dotnet/Apache.Ignite/Compute/ICompute.cs       |   3 +-
 .../Internal/Common/IgniteArgumentCheck.cs         |  19 +--
 .../Apache.Ignite/Internal/Compute/Compute.cs      |   4 +-
 .../dotnet/Apache.Ignite/Internal/Sql/Sql.cs       |   1 -
 .../Apache.Ignite/Internal/Table/RecordView.cs     |  13 +-
 .../Serialization/IRecordSerializerHandler.cs      |   3 +-
 .../Table/Serialization/ObjectSerializerHandler.cs |  32 +++--
 .../Table/Serialization/RecordSerializer.cs        |  17 +--
 .../dotnet/Apache.Ignite/Internal/Table/Table.cs   |   5 +-
 modules/platforms/dotnet/Apache.Ignite/Option.cs   | 111 +++++++++++++++
 .../dotnet/Apache.Ignite/Sql/IResultSet.cs         |   1 -
 modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs |   3 +-
 .../dotnet/Apache.Ignite/Table/IRecordView.cs      |  11 +-
 .../platforms/dotnet/Apache.Ignite/Table/ITable.cs |   3 +-
 24 files changed, 471 insertions(+), 219 deletions(-)

diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
index f81e83cc64..ee2a9584e0 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
@@ -226,9 +226,13 @@ namespace Apache.Ignite.Tests.Compute
             var keyPoco = new Poco { Key = key };
             var resNodeName2 = await Client.Compute.ExecuteColocatedAsync<string, Poco>(TableName, keyPoco, NodeNameJob);
 
+            var keyPocoStruct = new PocoStruct(key, null);
+            var resNodeName3 = await Client.Compute.ExecuteColocatedAsync<string, PocoStruct>(TableName, keyPocoStruct, NodeNameJob);
+
             var expectedNodeName = PlatformTestNodeRunner + nodeName;
             Assert.AreEqual(expectedNodeName, resNodeName);
             Assert.AreEqual(expectedNodeName, resNodeName2);
+            Assert.AreEqual(expectedNodeName, resNodeName3);
         }
 
         [Test]
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs
new file mode 100644
index 0000000000..955b8f8414
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs
@@ -0,0 +1,105 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite.Tests;
+
+using System;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests for <see cref="Option{T}"/>.
+/// </summary>
+public sealed class OptionTests
+{
+    [Test]
+    public void TestDefaultValueEqualsNone()
+    {
+        Assert.AreEqual(default(Option<int>), Option.None<int>());
+        Assert.IsTrue(Option.None<int>() == default);
+
+        Assert.AreEqual(default(Option<string>), Option.None<string>());
+        Assert.IsTrue(Option.None<string>() == default);
+    }
+
+    [Test]
+    public void TestNoneValueThrows()
+    {
+        var ex = Assert.Throws<InvalidOperationException>(() =>
+        {
+            _ = Option.None<int>().Value;
+        });
+
+        Assert.AreEqual("Value is not present. Check HasValue property before accessing Value.", ex!.Message);
+    }
+
+    [Test]
+    public void TestEquality()
+    {
+        Assert.AreEqual(Option.Some(123), (Option<int>)123);
+        Assert.AreNotEqual(Option.Some(123), Option.Some(124));
+    }
+
+    [Test]
+    public void TestSomeReferenceTypeDeconstruct()
+    {
+        var (val, hasVal) = Option.Some("abc");
+
+        Assert.IsTrue(hasVal);
+        Assert.AreEqual("abc", val);
+    }
+
+    [Test]
+    public void TestNoneReferenceTypeDeconstruct()
+    {
+        var (val, hasVal) = Option.None<string>();
+
+        Assert.IsFalse(hasVal);
+        Assert.IsNull(val);
+    }
+
+    [Test]
+    public void TestSomeValueTypeDeconstruct()
+    {
+        var (val, hasVal) = Option.Some(123L);
+
+        Assert.IsTrue(hasVal);
+        Assert.AreEqual(123L, val);
+    }
+
+    [Test]
+    public void TestNoneValueTypeDeconstruct()
+    {
+        var (val, hasVal) = Option.None<long>();
+
+        Assert.IsFalse(hasVal);
+        Assert.AreEqual(0L, val);
+    }
+
+    [Test]
+    public void TestNoneToString()
+    {
+        Assert.AreEqual("Option { HasValue = False }", Option.None<int>().ToString());
+        Assert.AreEqual("Option { HasValue = False }", Option.None<string>().ToString());
+    }
+
+    [Test]
+    public void TestSomeToString()
+    {
+        Assert.AreEqual("Option { HasValue = True, Value = 123 }", Option.Some(123).ToString());
+        Assert.AreEqual("Option { HasValue = True, Value = Foo }", Option.Some("Foo").ToString());
+    }
+}
\ No newline at end of file
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
index e06a4b6563..6a4626d8bf 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
@@ -168,7 +168,7 @@ namespace Apache.Ignite.Tests.Sql
             var table = await Client.Tables.GetTableAsync("TEST");
             var res = await table!.RecordBinaryView.GetAsync(null, new IgniteTuple { ["ID"] = 1 });
 
-            Assert.AreEqual("s-1", res!["VAL"]);
+            Assert.AreEqual("s-1", res.Value["VAL"]);
         }
 
         [Test]
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PocoStruct.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PocoStruct.cs
new file mode 100644
index 0000000000..21790df3f4
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PocoStruct.cs
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite.Tests.Table;
+
+/// <summary>
+/// Test user struct.
+/// </summary>
+public record struct PocoStruct(long Key, string? Val, string? UnmappedStr = null);
\ No newline at end of file
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
index b9a000ac9f..5264aa42ff 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
@@ -44,12 +44,12 @@ namespace Apache.Ignite.Tests.Table
             await TupleView.UpsertAsync(null, GetTuple(1, "foo"));
 
             var keyTuple = GetTuple(1);
-            var resTuple = (await TupleView.GetAsync(null, keyTuple))!;
+            var (value, hasValue) = await TupleView.GetAsync(null, keyTuple);
 
-            Assert.IsNotNull(resTuple);
-            Assert.AreEqual(2, resTuple.FieldCount);
-            Assert.AreEqual(1L, resTuple["key"]);
-            Assert.AreEqual("foo", resTuple["val"]);
+            Assert.IsTrue(hasValue);
+            Assert.AreEqual(2, value.FieldCount);
+            Assert.AreEqual(1L, value["key"]);
+            Assert.AreEqual("foo", value["val"]);
         }
 
         [Test]
@@ -58,10 +58,10 @@ namespace Apache.Ignite.Tests.Table
             var key = GetTuple(1);
 
             await TupleView.UpsertAsync(null, GetTuple(1, "foo"));
-            Assert.AreEqual("foo", (await TupleView.GetAsync(null, key))![1]);
+            Assert.AreEqual("foo", (await TupleView.GetAsync(null, key)).Value[1]);
 
             await TupleView.UpsertAsync(null, GetTuple(1, "bar"));
-            Assert.AreEqual("bar", (await TupleView.GetAsync(null, key))![1]);
+            Assert.AreEqual("bar", (await TupleView.GetAsync(null, key)).Value[1]);
         }
 
         [Test]
@@ -71,8 +71,8 @@ namespace Apache.Ignite.Tests.Table
 
             var res = await TupleView.GetAsync(null, GetTuple(CustomTestIgniteTuple.Key));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual(CustomTestIgniteTuple.Value, res![1]);
+            Assert.IsTrue(res.HasValue);
+            Assert.AreEqual(CustomTestIgniteTuple.Value, res.Value[1]);
         }
 
         [Test]
@@ -88,43 +88,43 @@ namespace Apache.Ignite.Tests.Table
         [Test]
         public async Task TestGetAndUpsertNonExistentRecordReturnsNull()
         {
-            IIgniteTuple? res = await TupleView.GetAndUpsertAsync(null, GetTuple(2, "2"));
+            Option<IIgniteTuple> res = await TupleView.GetAndUpsertAsync(null, GetTuple(2, "2"));
 
-            Assert.IsNull(res);
-            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(2)))![1]);
+            Assert.IsFalse(res.HasValue);
+            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(2))).Value[1]);
         }
 
         [Test]
         public async Task TestGetAndUpsertExistingRecordOverwritesAndReturns()
         {
             await TupleView.UpsertAsync(null, GetTuple(2, "2"));
-            IIgniteTuple? res = await TupleView.GetAndUpsertAsync(null, GetTuple(2, "22"));
+            Option<IIgniteTuple> res = await TupleView.GetAndUpsertAsync(null, GetTuple(2, "22"));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual(2, res![0]);
-            Assert.AreEqual("2", res[1]);
-            Assert.AreEqual("22", (await TupleView.GetAsync(null, GetTuple(2)))![1]);
+            Assert.IsTrue(res.HasValue);
+            Assert.AreEqual(2, res.Value[0]);
+            Assert.AreEqual("2", res.Value[1]);
+            Assert.AreEqual("22", (await TupleView.GetAsync(null, GetTuple(2))).Value[1]);
         }
 
         [Test]
         public async Task TestGetAndDeleteNonExistentRecordReturnsNull()
         {
-            IIgniteTuple? res = await TupleView.GetAndDeleteAsync(null, GetTuple(2, "2"));
+            Option<IIgniteTuple> res = await TupleView.GetAndDeleteAsync(null, GetTuple(2, "2"));
 
-            Assert.IsNull(res);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(2)));
+            Assert.IsFalse(res.HasValue);
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
         }
 
         [Test]
         public async Task TestGetAndDeleteExistingRecordRemovesAndReturns()
         {
             await TupleView.UpsertAsync(null, GetTuple(2, "2"));
-            IIgniteTuple? res = await TupleView.GetAndDeleteAsync(null, GetTuple(2));
+            var (res, hasRes) = await TupleView.GetAndDeleteAsync(null, GetTuple(2));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual(2, res![0]);
+            Assert.IsTrue(hasRes);
+            Assert.AreEqual(2, res[0]);
             Assert.AreEqual("2", res[1]);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(2)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
         }
 
         [Test]
@@ -133,7 +133,7 @@ namespace Apache.Ignite.Tests.Table
             var res = await TupleView.InsertAsync(null, GetTuple(1, "1"));
 
             Assert.IsTrue(res);
-            Assert.IsTrue(await TupleView.GetAsync(null, GetTuple(1)) != null);
+            Assert.IsTrue((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
@@ -143,7 +143,7 @@ namespace Apache.Ignite.Tests.Table
             var res = await TupleView.InsertAsync(null, GetTuple(1, "2"));
 
             Assert.IsFalse(res);
-            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1)))![1]);
+            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1))).Value[1]);
         }
 
         [Test]
@@ -158,7 +158,7 @@ namespace Apache.Ignite.Tests.Table
             await TupleView.UpsertAsync(null, GetTuple(1, "1"));
 
             Assert.IsTrue(await TupleView.DeleteAsync(null, GetTuple(1)));
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsTrue(await TupleView.GetAsync(null, GetTuple(1)) is { HasValue: false });
         }
 
         [Test]
@@ -174,7 +174,7 @@ namespace Apache.Ignite.Tests.Table
 
             Assert.IsFalse(await TupleView.DeleteExactAsync(null, GetTuple(1)));
             Assert.IsFalse(await TupleView.DeleteExactAsync(null, GetTuple(1, "2")));
-            Assert.IsNotNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsTrue((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
@@ -183,7 +183,7 @@ namespace Apache.Ignite.Tests.Table
             await TupleView.UpsertAsync(null, GetTuple(1, "1"));
 
             Assert.IsTrue(await TupleView.DeleteExactAsync(null, GetTuple(1, "1")));
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
@@ -192,7 +192,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await TupleView.ReplaceAsync(null, GetTuple(1, "1"));
 
             Assert.IsFalse(res);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
@@ -202,27 +202,27 @@ namespace Apache.Ignite.Tests.Table
             bool res = await TupleView.ReplaceAsync(null, GetTuple(1, "2"));
 
             Assert.IsTrue(res);
-            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(1)))![1]);
+            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(1))).Value[1]);
         }
 
         [Test]
         public async Task TestGetAndReplaceNonExistentRecordReturnsNullDoesNotCreateRecord()
         {
-            IIgniteTuple? res = await TupleView.GetAndReplaceAsync(null, GetTuple(1, "1"));
+            Option<IIgniteTuple> res = await TupleView.GetAndReplaceAsync(null, GetTuple(1, "1"));
 
-            Assert.IsNull(res);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsFalse(res.HasValue);
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
         public async Task TestGetAndReplaceExistingRecordReturnsOldOverwrites()
         {
             await TupleView.UpsertAsync(null, GetTuple(1, "1"));
-            IIgniteTuple? res = await TupleView.GetAndReplaceAsync(null, GetTuple(1, "2"));
+            Option<IIgniteTuple> res = await TupleView.GetAndReplaceAsync(null, GetTuple(1, "2"));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual("1", res![1]);
-            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(1)))![1]);
+            Assert.IsTrue(res.HasValue);
+            Assert.AreEqual("1", res.Value[1]);
+            Assert.AreEqual("2", (await TupleView.GetAsync(null, GetTuple(1))).Value[1]);
         }
 
         [Test]
@@ -231,7 +231,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await TupleView.ReplaceAsync(null, GetTuple(1, "1"), GetTuple(1, "2"));
 
             Assert.IsFalse(res);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
         }
 
         [Test]
@@ -241,7 +241,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await TupleView.ReplaceAsync(null, GetTuple(1, "11"), GetTuple(1, "22"));
 
             Assert.IsFalse(res);
-            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1)))![1]);
+            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1))).Value[1]);
         }
 
         [Test]
@@ -251,7 +251,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await TupleView.ReplaceAsync(null, GetTuple(1, "1"), GetTuple(1, "22"));
 
             Assert.IsTrue(res);
-            Assert.AreEqual("22", (await TupleView.GetAsync(null, GetTuple(1)))![1]);
+            Assert.AreEqual("22", (await TupleView.GetAsync(null, GetTuple(1))).Value[1]);
         }
 
         [Test]
@@ -265,7 +265,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await TupleView.GetAsync(null, GetTuple(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res![1]);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value[1]);
             }
         }
 
@@ -283,7 +283,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await TupleView.GetAsync(null, GetTuple(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res![1]);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value[1]);
             }
         }
 
@@ -300,7 +300,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await TupleView.GetAsync(null, GetTuple(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res![1]);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value[1]);
             }
         }
 
@@ -327,13 +327,13 @@ namespace Apache.Ignite.Tests.Table
             {
                 var res = await TupleView.GetAsync(null, GetTuple(id));
 
-                if (existing.TryGetValue(res![0]!, out var old))
+                if (existing.TryGetValue(res.Value[0]!, out var old))
                 {
-                    Assert.AreEqual(old[1], res[1]);
+                    Assert.AreEqual(old[1], res.Value[1]);
                 }
                 else
                 {
-                    Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res[1]);
+                    Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value[1]);
                 }
             }
         }
@@ -362,15 +362,15 @@ namespace Apache.Ignite.Tests.Table
 
             // TODO: Key order should be preserved by the server (IGNITE-16004).
             var res = await TupleView.GetAllAsync(null, Enumerable.Range(9, 4).Select(x => GetTuple(x)));
-            var resArr = res.OrderBy(x => x?[0]).ToArray();
+            var resArr = res.OrderBy(x => x.Value[0]).ToArray();
 
             Assert.AreEqual(2, res.Count);
 
-            Assert.AreEqual(9, resArr[0]![0]);
-            Assert.AreEqual("9", resArr[0]![1]);
+            Assert.AreEqual(9, resArr[0].Value[0]);
+            Assert.AreEqual("9", resArr[0].Value[1]);
 
-            Assert.AreEqual(10, resArr[1]![0]);
-            Assert.AreEqual("10", resArr[1]![1]);
+            Assert.AreEqual(10, resArr[1].Value[0]);
+            Assert.AreEqual("10", resArr[1].Value[1]);
         }
 
         [Test]
@@ -412,8 +412,8 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await TupleView.DeleteAllAsync(null, new[] { GetTuple(1), GetTuple(2) });
 
             Assert.AreEqual(0, skipped.Count);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(2)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
         }
 
         [Test]
@@ -424,9 +424,9 @@ namespace Apache.Ignite.Tests.Table
 
             Assert.AreEqual(1, skipped.Count);
             Assert.AreEqual(4, skipped[0][0]);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(2)));
-            Assert.IsNotNull(await TupleView.GetAsync(null, GetTuple(3)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
+            Assert.IsTrue((await TupleView.GetAsync(null, GetTuple(3))).HasValue);
         }
 
         [Test]
@@ -454,8 +454,8 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await TupleView.DeleteAllExactAsync(null, new[] { GetTuple(1, "1"), GetTuple(2, "2") });
 
             Assert.AreEqual(0, skipped.Count);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(2)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
         }
 
         [Test]
@@ -465,9 +465,9 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await TupleView.DeleteAllExactAsync(null, new[] { GetTuple(1, "1"), GetTuple(2, "22") });
 
             Assert.AreEqual(1, skipped.Count);
-            Assert.IsNull(await TupleView.GetAsync(null, GetTuple(1)));
-            Assert.IsNotNull(await TupleView.GetAsync(null, GetTuple(2)));
-            Assert.IsNotNull(await TupleView.GetAsync(null, GetTuple(3)));
+            Assert.IsFalse((await TupleView.GetAsync(null, GetTuple(1))).HasValue);
+            Assert.IsTrue((await TupleView.GetAsync(null, GetTuple(2))).HasValue);
+            Assert.IsTrue((await TupleView.GetAsync(null, GetTuple(3))).HasValue);
         }
 
         [Test]
@@ -529,9 +529,9 @@ namespace Apache.Ignite.Tests.Table
             await TupleView.UpsertAsync(null, tuple);
 
             var keyTuple = new IgniteTuple { [KeyCol] = key };
-            var resTuple = (await TupleView.GetAsync(null, keyTuple))!;
+            var (resTuple, resTupleHasValue) = await TupleView.GetAsync(null, keyTuple);
 
-            Assert.IsNotNull(resTuple);
+            Assert.IsTrue(resTupleHasValue);
             Assert.AreEqual(2, resTuple.FieldCount);
             Assert.AreEqual(key, resTuple["key"]);
             Assert.AreEqual(val, resTuple["val"]);
@@ -566,9 +566,9 @@ namespace Apache.Ignite.Tests.Table
 
             await tupleView.UpsertAsync(null, tuple);
 
-            var res = await tupleView.GetAsync(null, tuple);
+            var res = (await tupleView.GetAsync(null, tuple)).Value;
 
-            Assert.AreEqual(tuple["Blob"], res!["Blob"]);
+            Assert.AreEqual(tuple["Blob"], res["Blob"]);
             Assert.AreEqual(tuple["Date"], res["Date"]);
             Assert.AreEqual(tuple["Decimal"], res["Decimal"]);
             Assert.AreEqual(tuple["Double"], res["Double"]);
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
index 5c4922451d..639584283d 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
@@ -74,11 +74,7 @@ namespace Apache.Ignite.Tests.Table
             Assert.AreEqual("2", res.Val);
         }
 
-        private T Get<T>(T key)
-            where T : class
-        {
-            return Table.GetRecordView<T>().GetAsync(null, key).GetAwaiter().GetResult()!;
-        }
+        private T Get<T>(T key) => Table.GetRecordView<T>().GetAsync(null, key).GetAwaiter().GetResult().Value;
 
         private class FieldsTest
         {
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
index 6da8f826dc..3db41a988d 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
@@ -43,10 +43,9 @@ namespace Apache.Ignite.Tests.Table
             await PocoView.UpsertAsync(null, GetPoco(1, "foo"));
 
             var keyTuple = GetPoco(1);
-            var resTuple = (await PocoView.GetAsync(null, keyTuple))!;
-
-            Assert.IsNotNull(resTuple);
+            var (resTuple, hasValue) = await PocoView.GetAsync(null, keyTuple);
 
+            Assert.IsTrue(hasValue);
             Assert.AreEqual(1L, resTuple.Key);
             Assert.AreEqual("foo", resTuple.Val);
 
@@ -54,16 +53,46 @@ namespace Apache.Ignite.Tests.Table
             Assert.AreEqual(default(Guid), resTuple.UnmappedId);
         }
 
+        [Test]
+        public async Task TestUpsertGetValueType()
+        {
+            var pocoView = Table.GetRecordView<PocoStruct>();
+
+            await pocoView.UpsertAsync(null, new PocoStruct(1, "foo"));
+
+            var keyTuple = new PocoStruct(1, null);
+            var (resTuple, hasValue) = await pocoView.GetAsync(null, keyTuple);
+
+            Assert.IsTrue(hasValue);
+            Assert.AreEqual(1L, resTuple.Key);
+            Assert.AreEqual("foo", resTuple.Val);
+            Assert.IsNull(resTuple.UnmappedStr);
+        }
+
+        [Test]
+        public async Task TestGetMissingRowValueType()
+        {
+            var pocoView = Table.GetRecordView<PocoStruct>();
+
+            var keyTuple = new PocoStruct(1, null);
+            var (resTuple, hasValue) = await pocoView.GetAsync(null, keyTuple);
+
+            Assert.IsFalse(hasValue);
+            Assert.AreEqual(0L, resTuple.Key);
+            Assert.IsNull(resTuple.Val);
+            Assert.IsNull(resTuple.UnmappedStr);
+        }
+
         [Test]
         public async Task TestUpsertOverridesPreviousValue()
         {
             var key = GetPoco(1);
 
             await PocoView.UpsertAsync(null, GetPoco(1, "foo"));
-            Assert.AreEqual("foo", (await PocoView.GetAsync(null, key))!.Val);
+            Assert.AreEqual("foo", (await PocoView.GetAsync(null, key)).Value.Val);
 
             await PocoView.UpsertAsync(null, GetPoco(1, "bar"));
-            Assert.AreEqual("bar", (await PocoView.GetAsync(null, key))!.Val);
+            Assert.AreEqual("bar", (await PocoView.GetAsync(null, key)).Value.Val);
         }
 
         [Test]
@@ -79,43 +108,43 @@ namespace Apache.Ignite.Tests.Table
         [Test]
         public async Task TestGetAndUpsertNonExistentRecordReturnsNull()
         {
-            Poco? res = await PocoView.GetAndUpsertAsync(null, GetPoco(2, "2"));
+            Option<Poco> res = await PocoView.GetAndUpsertAsync(null, GetPoco(2, "2"));
 
-            Assert.IsNull(res);
-            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(2)))!.Val);
+            Assert.IsFalse(res.HasValue);
+            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(2))).Value.Val);
         }
 
         [Test]
         public async Task TestGetAndUpsertExistingRecordOverwritesAndReturns()
         {
             await PocoView.UpsertAsync(null, GetPoco(2, "2"));
-            Poco? res = await PocoView.GetAndUpsertAsync(null, GetPoco(2, "22"));
+            var (res, hasRes) = await PocoView.GetAndUpsertAsync(null, GetPoco(2, "22"));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual(2, res!.Key);
+            Assert.IsTrue(hasRes);
+            Assert.AreEqual(2, res.Key);
             Assert.AreEqual("2", res.Val);
-            Assert.AreEqual("22", (await PocoView.GetAsync(null, GetPoco(2)))!.Val);
+            Assert.AreEqual("22", (await PocoView.GetAsync(null, GetPoco(2))).Value.Val);
         }
 
         [Test]
         public async Task TestGetAndDeleteNonExistentRecordReturnsNull()
         {
-            Poco? res = await PocoView.GetAndDeleteAsync(null, GetPoco(2, "2"));
+            Option<Poco> res = await PocoView.GetAndDeleteAsync(null, GetPoco(2, "2"));
 
-            Assert.IsNull(res);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(2)));
+            Assert.IsFalse(res.HasValue);
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
         }
 
         [Test]
         public async Task TestGetAndDeleteExistingRecordRemovesAndReturns()
         {
             await PocoView.UpsertAsync(null, GetPoco(2, "2"));
-            Poco? res = await PocoView.GetAndDeleteAsync(null, GetPoco(2));
+            var (res, hasRes) = await PocoView.GetAndDeleteAsync(null, GetPoco(2));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual(2, res!.Key);
+            Assert.IsTrue(hasRes);
+            Assert.AreEqual(2, res.Key);
             Assert.AreEqual("2", res.Val);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(2)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
         }
 
         [Test]
@@ -124,7 +153,7 @@ namespace Apache.Ignite.Tests.Table
             var res = await PocoView.InsertAsync(null, GetPoco(1, "1"));
 
             Assert.IsTrue(res);
-            Assert.IsTrue(await PocoView.GetAsync(null, GetPoco(1)) != null);
+            Assert.IsTrue((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
         }
 
         [Test]
@@ -134,7 +163,7 @@ namespace Apache.Ignite.Tests.Table
             var res = await PocoView.InsertAsync(null, GetPoco(1, "2"));
 
             Assert.IsFalse(res);
-            Assert.AreEqual("1", (await PocoView.GetAsync(null, GetPoco(1)))!.Val);
+            Assert.AreEqual("1", (await PocoView.GetAsync(null, GetPoco(1))).Value.Val);
         }
 
         [Test]
@@ -149,7 +178,7 @@ namespace Apache.Ignite.Tests.Table
             await PocoView.UpsertAsync(null, GetPoco(1, "1"));
 
             Assert.IsTrue(await PocoView.DeleteAsync(null, GetPoco(1)));
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
         }
 
         [Test]
@@ -165,7 +194,7 @@ namespace Apache.Ignite.Tests.Table
 
             Assert.IsFalse(await PocoView.DeleteExactAsync(null, GetPoco(1)));
             Assert.IsFalse(await PocoView.DeleteExactAsync(null, GetPoco(1, "2")));
-            Assert.IsNotNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsTrue(await PocoView.GetAsync(null, GetPoco(1)) is { HasValue: true });
         }
 
         [Test]
@@ -174,16 +203,17 @@ namespace Apache.Ignite.Tests.Table
             await PocoView.UpsertAsync(null, GetPoco(1, "1"));
 
             Assert.IsTrue(await PocoView.DeleteExactAsync(null, GetPoco(1, "1")));
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
         }
 
         [Test]
         public async Task TestReplaceNonExistentRecordReturnsFalseDoesNotCreateRecord()
         {
             bool res = await PocoView.ReplaceAsync(null, GetPoco(1, "1"));
+            Option<Poco> res2 = await PocoView.GetAsync(null, GetPoco(1));
 
             Assert.IsFalse(res);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsFalse(res2.HasValue);
         }
 
         [Test]
@@ -193,36 +223,38 @@ namespace Apache.Ignite.Tests.Table
             bool res = await PocoView.ReplaceAsync(null, GetPoco(1, "2"));
 
             Assert.IsTrue(res);
-            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(1)))!.Val);
+            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(1))).Value.Val);
         }
 
         [Test]
         public async Task TestGetAndReplaceNonExistentRecordReturnsNullDoesNotCreateRecord()
         {
-            Poco? res = await PocoView.GetAndReplaceAsync(null, GetPoco(1, "1"));
+            Option<Poco> res = await PocoView.GetAndReplaceAsync(null, GetPoco(1, "1"));
+            Option<Poco> res2 = await PocoView.GetAsync(null, GetPoco(1));
 
-            Assert.IsNull(res);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsFalse(res.HasValue);
+            Assert.IsFalse(res2.HasValue);
         }
 
         [Test]
         public async Task TestGetAndReplaceExistingRecordReturnsOldOverwrites()
         {
             await PocoView.UpsertAsync(null, GetPoco(1, "1"));
-            Poco? res = await PocoView.GetAndReplaceAsync(null, GetPoco(1, "2"));
+            var (res, hasRes) = await PocoView.GetAndReplaceAsync(null, GetPoco(1, "2"));
 
-            Assert.IsNotNull(res);
-            Assert.AreEqual("1", res!.Val);
-            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(1)))!.Val);
+            Assert.IsTrue(hasRes);
+            Assert.AreEqual("1", res.Val);
+            Assert.AreEqual("2", (await PocoView.GetAsync(null, GetPoco(1))).Value.Val);
         }
 
         [Test]
         public async Task TestReplaceExactNonExistentRecordReturnsFalseDoesNotCreateRecord()
         {
             bool res = await PocoView.ReplaceAsync(null, GetPoco(1, "1"), GetPoco(1, "2"));
+            Option<Poco> res2 = await PocoView.GetAsync(null, GetPoco(1));
 
             Assert.IsFalse(res);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
+            Assert.IsFalse(res2.HasValue);
         }
 
         [Test]
@@ -232,7 +264,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await PocoView.ReplaceAsync(null, GetPoco(1, "11"), GetPoco(1, "22"));
 
             Assert.IsFalse(res);
-            Assert.AreEqual("1", (await PocoView.GetAsync(null, GetPoco(1)))!.Val);
+            Assert.AreEqual("1", (await PocoView.GetAsync(null, GetPoco(1))).Value.Val);
         }
 
         [Test]
@@ -242,7 +274,7 @@ namespace Apache.Ignite.Tests.Table
             bool res = await PocoView.ReplaceAsync(null, GetPoco(1, "1"), GetPoco(1, "22"));
 
             Assert.IsTrue(res);
-            Assert.AreEqual("22", (await PocoView.GetAsync(null, GetPoco(1)))!.Val);
+            Assert.AreEqual("22", (await PocoView.GetAsync(null, GetPoco(1))).Value.Val);
         }
 
         [Test]
@@ -256,7 +288,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await PocoView.GetAsync(null, GetPoco(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res!.Val);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value.Val);
             }
         }
 
@@ -274,7 +306,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await PocoView.GetAsync(null, GetPoco(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res!.Val);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value.Val);
             }
         }
 
@@ -291,7 +323,7 @@ namespace Apache.Ignite.Tests.Table
             foreach (var id in ids)
             {
                 var res = await PocoView.GetAsync(null, GetPoco(id));
-                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res!.Val);
+                Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value.Val);
             }
         }
 
@@ -318,13 +350,13 @@ namespace Apache.Ignite.Tests.Table
             {
                 var res = await PocoView.GetAsync(null, GetPoco(id));
 
-                if (existing.TryGetValue(res!.Key, out var old))
+                if (existing.TryGetValue(res.Value.Key, out var old))
                 {
-                    Assert.AreEqual(old.Val, res.Val);
+                    Assert.AreEqual(old.Val, res.Value.Val);
                 }
                 else
                 {
-                    Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Val);
+                    Assert.AreEqual(id.ToString(CultureInfo.InvariantCulture), res.Value.Val);
                 }
             }
         }
@@ -353,15 +385,15 @@ namespace Apache.Ignite.Tests.Table
 
             // TODO: Key order should be preserved by the server (IGNITE-16004).
             var res = await PocoView.GetAllAsync(null, Enumerable.Range(9, 4).Select(x => GetPoco(x)));
-            var resArr = res.OrderBy(x => x?.Key).ToArray();
+            var resArr = res.OrderBy(x => x.Value.Key).ToArray();
 
             Assert.AreEqual(2, res.Count);
 
-            Assert.AreEqual(9, resArr[0]!.Key);
-            Assert.AreEqual("9", resArr[0]!.Val);
+            Assert.AreEqual(9, resArr[0].Value.Key);
+            Assert.AreEqual("9", resArr[0].Value.Val);
 
-            Assert.AreEqual(10, resArr[1]!.Key);
-            Assert.AreEqual("10", resArr[1]!.Val);
+            Assert.AreEqual(10, resArr[1].Value.Key);
+            Assert.AreEqual("10", resArr[1].Value.Val);
         }
 
         [Test]
@@ -403,8 +435,8 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await PocoView.DeleteAllAsync(null, new[] { GetPoco(1), GetPoco(2) });
 
             Assert.AreEqual(0, skipped.Count);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(2)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
         }
 
         [Test]
@@ -415,9 +447,9 @@ namespace Apache.Ignite.Tests.Table
 
             Assert.AreEqual(1, skipped.Count);
             Assert.AreEqual(4, skipped[0].Key);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(2)));
-            Assert.IsNotNull(await PocoView.GetAsync(null, GetPoco(3)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
+            Assert.IsTrue((await PocoView.GetAsync(null, GetPoco(3))).HasValue);
         }
 
         [Test]
@@ -445,8 +477,8 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await PocoView.DeleteAllExactAsync(null, new[] { GetPoco(1, "1"), GetPoco(2, "2") });
 
             Assert.AreEqual(0, skipped.Count);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(2)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
         }
 
         [Test]
@@ -456,9 +488,9 @@ namespace Apache.Ignite.Tests.Table
             var skipped = await PocoView.DeleteAllExactAsync(null, new[] { GetPoco(1, "1"), GetPoco(2, "22") });
 
             Assert.AreEqual(1, skipped.Count);
-            Assert.IsNull(await PocoView.GetAsync(null, GetPoco(1)));
-            Assert.IsNotNull(await PocoView.GetAsync(null, GetPoco(2)));
-            Assert.IsNotNull(await PocoView.GetAsync(null, GetPoco(3)));
+            Assert.IsFalse((await PocoView.GetAsync(null, GetPoco(1))).HasValue);
+            Assert.IsTrue((await PocoView.GetAsync(null, GetPoco(2))).HasValue);
+            Assert.IsTrue((await PocoView.GetAsync(null, GetPoco(3))).HasValue);
         }
 
         [Test]
@@ -520,9 +552,9 @@ namespace Apache.Ignite.Tests.Table
             await PocoView.UpsertAsync(null, poco);
 
             var keyTuple = new Poco { Key = key };
-            var resTuple = (await PocoView.GetAsync(null, keyTuple))!;
+            var (resTuple, resTupleHasValue) = await PocoView.GetAsync(null, keyTuple);
 
-            Assert.IsNotNull(resTuple);
+            Assert.IsTrue(resTupleHasValue);
             Assert.AreEqual(key, resTuple.Key);
             Assert.AreEqual(val, resTuple.Val);
         }
@@ -558,9 +590,9 @@ namespace Apache.Ignite.Tests.Table
 
             await pocoView.UpsertAsync(null, poco);
 
-            var res = await pocoView.GetAsync(null, new Poco2 { Id = -1 });
+            var res = (await pocoView.GetAsync(null, new Poco2 { Id = -1 })).Value;
 
-            Assert.AreEqual(poco.Prop1, res!.Prop1);
+            Assert.AreEqual(poco.Prop1, res.Prop1);
             Assert.AreEqual(poco.Prop2, res.Prop2);
             Assert.AreEqual(poco.Prop3, res.Prop3);
             Assert.AreEqual(poco.Prop4, res.Prop4);
@@ -599,9 +631,9 @@ namespace Apache.Ignite.Tests.Table
 
             await pocoView.UpsertAsync(null, poco);
 
-            var res = await pocoView.GetAsync(null, poco);
+            var res = (await pocoView.GetAsync(null, poco)).Value;
 
-            Assert.AreEqual(poco.Blob, res!.Blob);
+            Assert.AreEqual(poco.Blob, res.Blob);
             Assert.AreEqual(poco.Date, res.Date);
             Assert.AreEqual(poco.Decimal, res.Decimal);
             Assert.AreEqual(poco.Double, res.Double);
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ObjectSerializerHandlerTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ObjectSerializerHandlerTests.cs
index 1d276584e1..a096224b81 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ObjectSerializerHandlerTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ObjectSerializerHandlerTests.cs
@@ -117,7 +117,6 @@ namespace Apache.Ignite.Tests.Table.Serialization
         }
 
         private static byte[] Write<T>(T obj, bool keyOnly = false)
-            where T : class
         {
             var handler = new ObjectSerializerHandler<T>();
 
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Transactions/TransactionsTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Transactions/TransactionsTests.cs
index 7d373ced17..b6f339d7b9 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Transactions/TransactionsTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Transactions/TransactionsTests.cs
@@ -46,38 +46,38 @@ namespace Apache.Ignite.Tests.Transactions
             Assert.IsFalse(await TupleView.DeleteExactAsync(tx, GetTuple(1, "1")));
 
             Assert.IsFalse(await TupleView.InsertAsync(tx, GetTuple(1, "111")));
-            Assert.AreEqual(GetTuple(1, "22"), await TupleView.GetAsync(tx, key));
-            Assert.AreEqual(GetTuple(1, "22"), await TupleView.GetAndUpsertAsync(tx, GetTuple(1, "33")));
-            Assert.AreEqual(GetTuple(1, "33"), await TupleView.GetAndReplaceAsync(tx, GetTuple(1, "44")));
+            Assert.AreEqual(GetTuple(1, "22"), (await TupleView.GetAsync(tx, key)).Value);
+            Assert.AreEqual(GetTuple(1, "22"), (await TupleView.GetAndUpsertAsync(tx, GetTuple(1, "33"))).Value);
+            Assert.AreEqual(GetTuple(1, "33"), (await TupleView.GetAndReplaceAsync(tx, GetTuple(1, "44"))).Value);
             Assert.IsTrue(await TupleView.ReplaceAsync(tx, GetTuple(1, "55")));
-            Assert.AreEqual(GetTuple(1, "55"), await TupleView.GetAndDeleteAsync(tx, key));
+            Assert.AreEqual(GetTuple(1, "55"), (await TupleView.GetAndDeleteAsync(tx, key)).Value);
             Assert.IsFalse(await TupleView.DeleteAsync(tx, key));
 
             await TupleView.UpsertAllAsync(tx, new[] { GetTuple(1, "6"), GetTuple(2, "7") });
             Assert.AreEqual(2, (await TupleView.GetAllAsync(tx, new[] { key, GetTuple(2), GetTuple(3) })).Count);
 
             var insertAllRes = await TupleView.InsertAllAsync(tx, new[] { GetTuple(1, "8"), GetTuple(3, "9") });
-            Assert.AreEqual(GetTuple(1, "6"), await TupleView.GetAsync(tx, key));
+            Assert.AreEqual(GetTuple(1, "6"), (await TupleView.GetAsync(tx, key)).Value);
             Assert.AreEqual(GetTuple(1, "8"), insertAllRes.Single());
 
             Assert.IsFalse(await TupleView.ReplaceAsync(tx, GetTuple(-1)));
             Assert.IsTrue(await TupleView.ReplaceAsync(tx, GetTuple(1, "10")));
-            Assert.AreEqual(GetTuple(1, "10"), await TupleView.GetAsync(tx, key));
+            Assert.AreEqual(GetTuple(1, "10"), (await TupleView.GetAsync(tx, key)).Value);
 
             Assert.IsFalse(await TupleView.ReplaceAsync(tx, GetTuple(1, "1"), GetTuple(1, "11")));
             Assert.IsTrue(await TupleView.ReplaceAsync(tx, GetTuple(1, "10"), GetTuple(1, "12")));
-            Assert.AreEqual(GetTuple(1, "12"), await TupleView.GetAsync(tx, key));
+            Assert.AreEqual(GetTuple(1, "12"), (await TupleView.GetAsync(tx, key)).Value);
 
             var deleteAllRes = await TupleView.DeleteAllAsync(tx, new[] { GetTuple(3), GetTuple(4) });
             Assert.AreEqual(4, deleteAllRes.Single()[0]);
-            Assert.IsNull(await TupleView.GetAsync(tx, GetTuple(3)));
+            Assert.IsFalse((await TupleView.GetAsync(tx, GetTuple(3))).HasValue);
 
             var deleteAllExactRes = await TupleView.DeleteAllAsync(tx, new[] { GetTuple(1, "12"), GetTuple(5) });
             Assert.AreEqual(5, deleteAllExactRes.Single()[0]);
-            Assert.IsNull(await TupleView.GetAsync(tx, key));
+            Assert.IsFalse((await TupleView.GetAsync(tx, key)).HasValue);
 
             await tx.RollbackAsync();
-            Assert.AreEqual(GetTuple(1, "1"), await TupleView.GetAsync(null, key));
+            Assert.AreEqual(GetTuple(1, "1"), (await TupleView.GetAsync(null, key)).Value);
         }
 
         [Test]
@@ -88,7 +88,7 @@ namespace Apache.Ignite.Tests.Transactions
             await tx.CommitAsync();
 
             var res = await TupleView.GetAsync(null, GetTuple(1));
-            Assert.AreEqual("2", res![ValCol]);
+            Assert.AreEqual("2", res.Value[ValCol]);
         }
 
         [Test]
@@ -98,8 +98,8 @@ namespace Apache.Ignite.Tests.Transactions
             await TupleView.UpsertAsync(tx, GetTuple(1, "2"));
             await tx.RollbackAsync();
 
-            var res = await TupleView.GetAsync(null, GetTuple(1));
-            Assert.IsNull(res);
+            var (_, hasValue) = await TupleView.GetAsync(null, GetTuple(1));
+            Assert.IsFalse(hasValue);
         }
 
         [Test]
@@ -111,8 +111,8 @@ namespace Apache.Ignite.Tests.Transactions
                 await tx.RollbackAsync();
             }
 
-            var res = await TupleView.GetAsync(null, GetTuple(1));
-            Assert.IsNull(res);
+            var (_, hasValue) = await TupleView.GetAsync(null, GetTuple(1));
+            Assert.IsFalse(hasValue);
         }
 
         [Test]
@@ -169,7 +169,7 @@ namespace Apache.Ignite.Tests.Transactions
                 await table!.RecordBinaryView.UpsertAsync(tx, GetTuple(1, "2"));
             }
 
-            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1)))![ValCol]);
+            Assert.AreEqual("1", (await TupleView.GetAsync(null, GetTuple(1))).Value[ValCol]);
         }
 
         [Test]
diff --git a/modules/platforms/dotnet/Apache.Ignite/Apache.Ignite.csproj b/modules/platforms/dotnet/Apache.Ignite/Apache.Ignite.csproj
index 35905d96f2..6922504ce9 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Apache.Ignite.csproj
+++ b/modules/platforms/dotnet/Apache.Ignite/Apache.Ignite.csproj
@@ -29,6 +29,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <PackageReference Include="JetBrains.Annotations" Version="2022.1.0" PrivateAssets="all" />
     <PackageReference Include="MessagePack" Version="[2.1.80,)" />
     <PackageReference Include="NodaTime" Version="[3.*,)" />
   </ItemGroup>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs b/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
index 8c893bd6d3..7ab817b05f 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
@@ -58,8 +58,7 @@ namespace Apache.Ignite.Compute
         /// <typeparam name="T">Job result type.</typeparam>
         /// <typeparam name="TKey">Key type.</typeparam>
         /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
-        Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args)
-            where TKey : class; // TODO: Remove class constraint (IGNITE-16355)
+        Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args);
 
         /// <summary>
         /// Executes a compute job represented by the given class on all of the specified nodes.
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Common/IgniteArgumentCheck.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Common/IgniteArgumentCheck.cs
index c3bdae4ef8..6242144b33 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Common/IgniteArgumentCheck.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Common/IgniteArgumentCheck.cs
@@ -19,7 +19,7 @@
 namespace Apache.Ignite.Internal.Common
 {
     using System;
-    using System.Collections.Generic;
+    using JetBrains.Annotations;
 
     /// <summary>
     /// Arguments check helpers.
@@ -31,7 +31,8 @@ namespace Apache.Ignite.Internal.Common
         /// </summary>
         /// <param name="arg">The argument.</param>
         /// <param name="argName">Name of the argument.</param>
-        public static void NotNull(object arg, string argName)
+        /// <typeparam name="T">Arg type.</typeparam>
+        public static void NotNull<T>([NoEnumeration] T arg, string argName)
         {
             if (arg == null)
             {
@@ -55,20 +56,6 @@ namespace Apache.Ignite.Internal.Common
             return arg;
         }
 
-        /// <summary>
-        /// Throws an ArgumentException if specified arg is null or empty string.
-        /// </summary>
-        /// <param name="collection">The collection.</param>
-        /// <param name="argName">Name of the argument.</param>
-        /// <typeparam name="T">Type.</typeparam>
-        public static void NotNullOrEmpty<T>(ICollection<T> collection, string? argName)
-        {
-            if (collection == null || collection.Count == 0)
-            {
-                throw new ArgumentException($"'{argName}' argument should not be null or empty.", argName);
-            }
-        }
-
         /// <summary>
         /// Throws an ArgumentException if specified condition is false.
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
index f8985ef3fc..24b0ada1d7 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
@@ -76,8 +76,7 @@ namespace Apache.Ignite.Internal.Compute
                 .ConfigureAwait(false);
 
         /// <inheritdoc/>
-        public async Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args)
-            where TKey : class =>
+        public async Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args) =>
             await ExecuteColocatedAsync<T, TKey>(
                     tableName,
                     key,
@@ -199,7 +198,6 @@ namespace Apache.Ignite.Internal.Compute
             Func<Table, IRecordSerializerHandler<TKey>> serializerHandlerFunc,
             string jobClassName,
             params object[] args)
-            where TKey : class
         {
             // TODO: IGNITE-16990 - implement partition awareness.
             IgniteArgumentCheck.NotNull(tableName, nameof(tableName));
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
index 8b0e624f5b..b07e3a22e8 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
@@ -91,7 +91,6 @@ namespace Apache.Ignite.Internal.Sql
 
         /// <inheritdoc/>
         public Task<IResultSet<T>> ExecuteAsync<T>(ITransaction? transaction, SqlStatement statement, params object[] args)
-            where T : class
         {
             // TODO: IGNITE-17333 SQL ResultSet object mapping
             throw new NotSupportedException();
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
index 0e6b9c2dff..f0bd09aab9 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
@@ -33,7 +33,6 @@ namespace Apache.Ignite.Internal.Table
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     internal sealed class RecordView<T> : IRecordView<T>
-        where T : class
     {
         /** Table. */
         private readonly Table _table;
@@ -58,7 +57,7 @@ namespace Apache.Ignite.Internal.Table
         public RecordSerializer<T> RecordSerializer => _ser;
 
         /// <inheritdoc/>
-        public async Task<T?> GetAsync(ITransaction? transaction, T key)
+        public async Task<Option<T>> GetAsync(ITransaction? transaction, T key)
         {
             IgniteArgumentCheck.NotNull(key, nameof(key));
 
@@ -69,7 +68,7 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<IList<T?>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys)
+        public async Task<IList<Option<T>>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys)
         {
             IgniteArgumentCheck.NotNull(keys, nameof(keys));
 
@@ -77,7 +76,7 @@ namespace Apache.Ignite.Internal.Table
 
             if (!iterator.MoveNext())
             {
-                return Array.Empty<T>();
+                return Array.Empty<Option<T>>();
             }
 
             var schema = await _table.GetLatestSchemaAsync().ConfigureAwait(false);
@@ -123,7 +122,7 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<T?> GetAndUpsertAsync(ITransaction? transaction, T record)
+        public async Task<Option<T>> GetAndUpsertAsync(ITransaction? transaction, T record)
         {
             IgniteArgumentCheck.NotNull(record, nameof(record));
 
@@ -192,7 +191,7 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<T?> GetAndReplaceAsync(ITransaction? transaction, T record)
+        public async Task<Option<T>> GetAndReplaceAsync(ITransaction? transaction, T record)
         {
             IgniteArgumentCheck.NotNull(record, nameof(record));
 
@@ -221,7 +220,7 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<T?> GetAndDeleteAsync(ITransaction? transaction, T key)
+        public async Task<Option<T>> GetAndDeleteAsync(ITransaction? transaction, T key)
         {
             IgniteArgumentCheck.NotNull(key, nameof(key));
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/IRecordSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/IRecordSerializerHandler.cs
index 2b33037bad..da30a46ffc 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/IRecordSerializerHandler.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/IRecordSerializerHandler.cs
@@ -24,7 +24,6 @@ namespace Apache.Ignite.Internal.Table.Serialization
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     internal interface IRecordSerializerHandler<T>
-        where T : class
     {
         /// <summary>
         /// Reads a record.
@@ -42,7 +41,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <param name="schema">Schema.</param>
         /// <param name="key">Key part.</param>
         /// <returns>Resulting record with key and value parts.</returns>
-        T? ReadValuePart(ref MessagePackReader reader, Schema schema, T key);
+        T ReadValuePart(ref MessagePackReader reader, Schema schema, T key);
 
         /// <summary>
         /// Writes a record.
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
index 914bf87bfd..f518175d00 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
@@ -30,7 +30,6 @@ namespace Apache.Ignite.Internal.Table.Serialization
     /// </summary>
     /// <typeparam name="T">Object type.</typeparam>
     internal class ObjectSerializerHandler<T> : IRecordSerializerHandler<T>
-        where T : class
     {
         private readonly ConcurrentDictionary<(int, bool), WriteDelegate<T>> _writers = new();
 
@@ -159,7 +158,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 skipVisibility: true);
 
             var il = method.GetILGenerator();
-            il.DeclareLocal(type);
+            var local = il.DeclareLocal(type);
 
             il.Emit(OpCodes.Ldtoken, type);
             il.Emit(OpCodes.Call, ReflectionUtils.GetTypeFromHandleMethod);
@@ -175,7 +174,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 var col = columns[i];
                 var fieldInfo = type.GetFieldIgnoreCase(col.Name);
 
-                EmitFieldRead(fieldInfo, il, col, i);
+                EmitFieldRead(fieldInfo, il, col, i, local);
             }
 
             il.Emit(OpCodes.Ldloc_0); // res
@@ -196,13 +195,20 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 skipVisibility: true);
 
             var il = method.GetILGenerator();
-            il.DeclareLocal(type);
+            var local = il.DeclareLocal(type);
 
-            il.Emit(OpCodes.Ldtoken, type);
-            il.Emit(OpCodes.Call, ReflectionUtils.GetTypeFromHandleMethod);
-            il.Emit(OpCodes.Call, ReflectionUtils.GetUninitializedObjectMethod);
-
-            il.Emit(OpCodes.Stloc_0); // T res
+            if (type.IsValueType)
+            {
+                il.Emit(OpCodes.Ldloca_S, local);
+                il.Emit(OpCodes.Initobj, type);
+            }
+            else
+            {
+                il.Emit(OpCodes.Ldtoken, type);
+                il.Emit(OpCodes.Call, ReflectionUtils.GetTypeFromHandleMethod);
+                il.Emit(OpCodes.Call, ReflectionUtils.GetUninitializedObjectMethod);
+                il.Emit(OpCodes.Stloc_0); // T res
+            }
 
             var columns = schema.Columns;
 
@@ -215,7 +221,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 {
                     if (fieldInfo != null)
                     {
-                        il.Emit(OpCodes.Ldloc_0); // res
+                        il.Emit(type.IsValueType ? OpCodes.Ldloca_S : OpCodes.Ldloc, local); // res
                         il.Emit(OpCodes.Ldarg_1); // key
                         il.Emit(OpCodes.Ldfld, fieldInfo);
                         il.Emit(OpCodes.Stfld, fieldInfo);
@@ -224,7 +230,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                     continue;
                 }
 
-                EmitFieldRead(fieldInfo, il, col, i - schema.KeyColumnCount);
+                EmitFieldRead(fieldInfo, il, col, i - schema.KeyColumnCount, local);
             }
 
             il.Emit(OpCodes.Ldloc_0); // res
@@ -233,7 +239,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
             return (ReadValuePartDelegate<T>)method.CreateDelegate(typeof(ReadValuePartDelegate<T>));
         }
 
-        private static void EmitFieldRead(FieldInfo? fieldInfo, ILGenerator il, Column col, int elemIdx)
+        private static void EmitFieldRead(FieldInfo? fieldInfo, ILGenerator il, Column col, int elemIdx, LocalBuilder local)
         {
             if (fieldInfo == null)
             {
@@ -244,7 +250,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
 
             var readMethod = BinaryTupleMethods.GetReadMethod(fieldInfo.FieldType);
 
-            il.Emit(OpCodes.Ldloc_0); // res
+            il.Emit(fieldInfo.DeclaringType!.IsValueType ? OpCodes.Ldloca_S : OpCodes.Ldloc, local); // res
             il.Emit(OpCodes.Ldarg_0); // reader
             EmitLdcI4(il, elemIdx); // index
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
index 7913304cbe..29412babf5 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
@@ -30,7 +30,6 @@ namespace Apache.Ignite.Internal.Table.Serialization
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     internal class RecordSerializer<T>
-        where T : class
     {
         /** Table. */
         private readonly Table _table;
@@ -61,12 +60,12 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <param name="schema">Schema or null when there is no value.</param>
         /// <param name="key">Key part.</param>
         /// <returns>Resulting record with key and value parts.</returns>
-        public T? ReadValue(PooledBuffer buf, Schema? schema, T key)
+        public Option<T> ReadValue(PooledBuffer buf, Schema? schema, T key)
         {
             if (schema == null)
             {
                 // Null schema means null record.
-                return null;
+                return default;
             }
 
             // Skip schema version.
@@ -113,12 +112,12 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <param name="schema">Schema or null when there is no value.</param>
         /// <param name="keyOnly">Key only mode.</param>
         /// <returns>List of records.</returns>
-        public IList<T?> ReadMultipleNullable(PooledBuffer buf, Schema? schema, bool keyOnly = false)
+        public IList<Option<T>> ReadMultipleNullable(PooledBuffer buf, Schema? schema, bool keyOnly = false)
         {
             if (schema == null)
             {
                 // Null schema means empty collection.
-                return Array.Empty<T?>();
+                return Array.Empty<Option<T>>();
             }
 
             // Skip schema version.
@@ -126,13 +125,15 @@ namespace Apache.Ignite.Internal.Table.Serialization
             r.Skip();
 
             var count = r.ReadInt32();
-            var res = new List<T?>(count);
+            var res = new List<Option<T>>(count);
 
             for (var i = 0; i < count; i++)
             {
-                var hasValue = r.ReadBoolean();
+                Option<T> option = r.ReadBoolean()
+                    ? _handler.Read(ref r, schema, keyOnly)
+                    : default(Option<T>);
 
-                res.Add(hasValue ? _handler.Read(ref r, schema, keyOnly) : null);
+                res.Add(option);
             }
 
             return res;
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
index 904023b2eb..1f64ae8f45 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
@@ -81,9 +81,7 @@ namespace Apache.Ignite.Internal.Table
         internal Guid Id { get; }
 
         /// <inheritdoc/>
-        public IRecordView<T> GetRecordView<T>()
-            where T : class =>
-            GetRecordViewInternal<T>();
+        public IRecordView<T> GetRecordView<T>() => GetRecordViewInternal<T>();
 
         /// <summary>
         /// Gets the record view for the specified type.
@@ -91,7 +89,6 @@ namespace Apache.Ignite.Internal.Table
         /// <typeparam name="T">Record type.</typeparam>
         /// <returns>Record view.</returns>
         internal RecordView<T> GetRecordViewInternal<T>()
-            where T : class
         {
             // ReSharper disable once HeapView.CanAvoidClosure (generics prevent this)
             return (RecordView<T>)_recordViews.GetOrAdd(
diff --git a/modules/platforms/dotnet/Apache.Ignite/Option.cs b/modules/platforms/dotnet/Apache.Ignite/Option.cs
new file mode 100644
index 0000000000..1ed1fb03fd
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Option.cs
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite;
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+/// <summary>
+/// A wrapper that may or may not contain a value of type <typeparamref name="T"/>.
+/// </summary>
+/// <typeparam name="T">Value type.</typeparam>
+public readonly record struct Option<T>
+{
+    private readonly T _value;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="Option{T}"/> struct.
+    /// </summary>
+    /// <param name="value">Value.</param>
+    /// <param name="hasValue">Whether the value is present.</param>
+    [SuppressMessage(
+        "StyleCop.CSharp.DocumentationRules",
+        "SA1642:ConstructorSummaryDocumentationMustBeginWithStandardText",
+        Justification = "False positive.")]
+    private Option(T value, bool hasValue)
+    {
+        _value = value;
+        HasValue = hasValue;
+    }
+
+    /// <summary>
+    /// Gets the value.
+    /// </summary>
+    public T Value => HasValue
+        ? _value
+        : throw new InvalidOperationException("Value is not present. Check HasValue property before accessing Value.");
+
+    /// <summary>
+    /// Gets a value indicating whether the value is present.
+    /// </summary>
+    public bool HasValue { get; }
+
+    /// <summary>
+    /// Wraps a value into an option.
+    /// </summary>
+    /// <param name="value">Value.</param>
+    /// <returns>Wrapped value.</returns>
+    public static implicit operator Option<T>(T value) => new(value, true);
+
+    /// <summary>
+    /// Deconstructs this instance.
+    /// </summary>
+    /// <param name="value">Value.</param>
+    /// <param name="hasValue">Whether the value is present.</param>
+    public void Deconstruct(out T value, out bool hasValue)
+    {
+        value = _value;
+        hasValue = HasValue;
+    }
+
+    private bool PrintMembers(StringBuilder builder)
+    {
+        builder.Append("HasValue = ");
+        builder.Append(HasValue);
+
+        if (HasValue)
+        {
+            builder.Append(", Value = ");
+            builder.Append(_value);
+        }
+
+        return true;
+    }
+}
+
+/// <summary>
+/// Static helpers for <see cref="Option{T}"/>.
+/// </summary>
+public static class Option
+{
+    /// <summary>
+    /// Returns an option of the specified value.
+    /// </summary>
+    /// <param name="val">Value.</param>
+    /// <typeparam name="T">value type.</typeparam>
+    /// <returns>Option of T.</returns>
+    public static Option<T> Some<T>(T val) => val;
+
+    /// <summary>
+    /// Returns an option without a value.
+    /// </summary>
+    /// <typeparam name="T">value type.</typeparam>
+    /// <returns>Option of T.</returns>
+    public static Option<T> None<T>() => default;
+}
\ No newline at end of file
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/IResultSet.cs b/modules/platforms/dotnet/Apache.Ignite/Sql/IResultSet.cs
index f264554a98..8a61bed18c 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Sql/IResultSet.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/IResultSet.cs
@@ -33,7 +33,6 @@ namespace Apache.Ignite.Sql
     /// </summary>
     /// <typeparam name="T">Row type.</typeparam>
     public interface IResultSet<T> : IAsyncEnumerable<T>, IAsyncDisposable, IDisposable
-        where T : class // TODO: Remove class constraint (IGNITE-16355)
     {
         /// <summary>
         /// Gets result set metadata when <see cref="HasRowSet"/> is <c>true</c>, otherwise <c>null</c>.
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
index 70fcf0c36b..ab7bbcfdcf 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
@@ -43,7 +43,6 @@ namespace Apache.Ignite.Sql
         /// <param name="args">Arguments for the statement.</param>
         /// <typeparam name="T">Row type.</typeparam>
         /// <returns>SQL result set.</returns>
-        Task<IResultSet<T>> ExecuteAsync<T>(ITransaction? transaction, SqlStatement statement, params object[] args)
-            where T : class; // TODO: Remove class constraint (IGNITE-16355)
+        Task<IResultSet<T>> ExecuteAsync<T>(ITransaction? transaction, SqlStatement statement, params object[] args);
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs b/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
index 668a0237bc..b13c5295b0 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
@@ -26,7 +26,6 @@ namespace Apache.Ignite.Table
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     public interface IRecordView<T>
-        where T : class // TODO: Remove class constraint (IGNITE-16355)
     {
         /// <summary>
         /// Gets a record by key.
@@ -37,7 +36,7 @@ namespace Apache.Ignite.Table
         /// A <see cref="Task"/> representing the asynchronous operation.
         /// The task result contains a record with all columns.
         /// </returns>
-        Task<T?> GetAsync(ITransaction? transaction, T key);
+        Task<Option<T>> GetAsync(ITransaction? transaction, T key);
 
         /// <summary>
         /// Gets multiple records by keys.
@@ -50,7 +49,7 @@ namespace Apache.Ignite.Table
         /// elements is guaranteed to be the same as the order of <paramref name="keys"/>. If a record does not exist,
         /// the element at the corresponding index of the resulting collection will be <c>null</c>.
         /// </returns>
-        Task<IList<T?>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys);
+        Task<IList<Option<T>>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys);
 
         /// <summary>
         /// Inserts a record into the table if it does not exist or replaces the existing one.
@@ -77,7 +76,7 @@ namespace Apache.Ignite.Table
         /// A <see cref="Task"/> representing the asynchronous operation.
         /// The task result contains replaced record or null if it did not exist.
         /// </returns>
-        Task<T?> GetAndUpsertAsync(ITransaction? transaction, T record);
+        Task<Option<T>> GetAndUpsertAsync(ITransaction? transaction, T record);
 
         /// <summary>
         /// Inserts a record into the table if it does not exist.
@@ -135,7 +134,7 @@ namespace Apache.Ignite.Table
         /// A <see cref="Task"/> representing the asynchronous operation.
         /// The task result contains the previous value for the given key, or <c>null</c> if it did not exist.
         /// </returns>
-        Task<T?> GetAndReplaceAsync(ITransaction? transaction, T record);
+        Task<Option<T>> GetAndReplaceAsync(ITransaction? transaction, T record);
 
         /// <summary>
         /// Deletes a record with the specified key.
@@ -168,7 +167,7 @@ namespace Apache.Ignite.Table
         /// A <see cref="Task"/> representing the asynchronous operation.
         /// The task result contains deleted record or <c>null</c> if it did not exist.
         /// </returns>
-        Task<T?> GetAndDeleteAsync(ITransaction? transaction, T key);
+        Task<Option<T>> GetAndDeleteAsync(ITransaction? transaction, T key);
 
         /// <summary>
         /// Deletes multiple records. If one or more keys do not exist, other records are still deleted.
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
index 82e75c5c63..e6c49a30ad 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
@@ -40,7 +40,6 @@ namespace Apache.Ignite.Table
         /// </summary>
         /// <typeparam name="T">Record type.</typeparam>
         /// <returns>Record view.</returns>
-        public IRecordView<T> GetRecordView<T>() // TODO: Custom mapping (IGNITE-16356)
-            where T : class;
+        public IRecordView<T> GetRecordView<T>(); // TODO: Custom mapping (IGNITE-16356)
     }
 }