Repository: riok/mapperly (2026-03-27T08:59:10Z)

Issue:
[MapperConstructor] does not override copy constructor selection in UseDeepCloning mode
**Please do the checklist before filing an issue:**
- [x] I have read the [documentation](https://mapperly.riok.app), including the [FAQ](https://mapperly.riok.app/docs/getting-started/faq/)
- [ ] I can reproduce the bug using the latest prerelease version (tested with 4.2.1; have not yet verified against v5.0.0-next.3)
- [x] I have searched existing [discussion](https://github.com/riok/mapperly/discussions) and [issue](https://github.com/riok/mapperly/issues) to avoid duplicates

**Describe the bug**

When `UseDeepCloning = true`, Mapperly selects copy constructors (explicit or record-synthesized) over constructors marked with `[MapperConstructor]`. This causes `[MapProperty(Use = ...)]` annotations to be silently ignored, since the copy constructor bypasses property-by-property mapping entirely.

`[MapperConstructor]` should be the definitive override for constructor selection, but it is not respected in at least two cases:

1. **Class with explicit copy constructor** — `[MapperConstructor]` on the parameterless constructor is ignored; the copy constructor is used instead.
2. **Record with synthesized copy constructor** — `[MapperConstructor]` on a parameterless constructor is ignored; the record's compiler-generated copy constructor (via `UnsafeAccessor`) is used instead.

**Declaration code**

Case 1 — Class:
```csharp
public class Document
{
    [MapperConstructor]
    public Document() { }

    public Document(Document other)
    {
        Title = other.Title;
        AuthorId = other.AuthorId;
    }

    public required string Title { get; init; }
    public string? AuthorId { get; init; }
}

[Mapper(UseDeepCloning = true)]
public partial class DocumentCloner
{
    [MapProperty(nameof(Document.AuthorId), nameof(Document.AuthorId), Use = nameof(RemapId))]
    [MapProperty(nameof(Document.Title), nameof(Document.Title), Use = nameof(Transform))]
    public partial Document Clone(Document source);

    [UserMapping(Default = false)]
    private string? RemapId(string? id) => id is null ? null : $"remapped-{id}";

    [UserMapping(Default = false)]
    private string Transform(string value) => $"transformed-{value}";
}
```

Case 2 — Record (same mapper):
```csharp
public record Document
{
    [MapperConstructor]
    public Document() { }

    public required string Title { get; init; }
    public string? AuthorId { get; init; }
}
```

**Actual relevant generated code**

Case 1 (class):
```csharp
public partial Document Clone(Document source)
{
    return new Document(source); // Copy constructor used — [MapperConstructor] and [MapProperty] ignored
}
```

Case 2 (record):
```csharp
public partial Document Clone(Document source)
{
    return UnsafeAccessor.CreateDocument(source); // Record copy constructor via UnsafeAccessor — same problem
}
```

**Expected relevant generated code**

Both cases should respect `[MapperConstructor]` and produce:
```csharp
public partial Document Clone(Document source)
{
    var target = new Document() // [MapperConstructor] constructor used
    {
        Title = Transform(source.Title),       // [MapProperty] applied
        AuthorId = RemapId(source.AuthorId)    // [MapProperty] applied
    };
    return target;
}
```

**Reported relevant diagnostics**
* No diagnostics reported — the bug is silent. No warnings that `[MapperConstructor]` or `[MapProperty]` annotations are being ignored.

**Environment (please complete the following information):**
- Mapperly Version: 4.2.1
- Nullable reference types: enabled
- .NET Version: .NET 8.0
- Target Framework: net8.0
- C# Language Version: preview
- IDE: JetBrains Rider
- OS: Windows 11

**Additional context**

This makes it impossible to use `[MapProperty(Use = ...)]` for property transformations during deep cloning on any type that has a copy constructor. The workaround is to remove the copy constructor entirely, which is not possible for records (synthesized copy constructor cannot be removed).

A possible fix: when `[MapperConstructor]` is present on a constructor, it should take absolute priority over any copy constructor, including record-synthesized ones. At minimum, a diagnostic warning should be emitted when `[MapProperty]` annotations are present but will be ignored due to copy constructor selection.


Discussion:
Thank you! 



Patch:
diff --git a/docs/docs/breaking-changes/5-0.md b/docs/docs/breaking-changes/5-0.md
index 4428a5e142..e6a26b7dfd 100644
--- a/docs/docs/breaking-changes/5-0.md
+++ b/docs/docs/breaking-changes/5-0.md
@@ -18,34 +18,7 @@ description: How to upgrade to Mapperly 5.0 and a list of all its breaking chang
 - Inaccessible members from other assemblies are now included in the mapping process if `IncludedMembers` or `IncludedConstructors` options are configured to include them.
 - Constructor mappings with nullable source types no longer generate null guards when the constructor parameter accepts null, see below.
 - `MaybeNull` attributes are now respected for fields, properties and constructor parameters.
-
-## Constructor mappings with nullable source skip null guard
-
-When mapping a nullable source type to a target type via a single-parameter constructor,
-Mapperly previously always generated a null guard (throwing `ArgumentNullException` or using a fallback value).
-Starting with v5.0, if the constructor parameter accepts the nullable source type, Mapperly passes the value directly without a null guard.
-
-Previously:
-
-```csharp
-public record Dto(int[]? Array);
-
-// Generated:
-public partial Dto Map(int[]? source)
-{
-    return source == null ? throw new ArgumentNullException(nameof(source)) : new Dto(source);
-}
-```
-
-Now:
-
-```csharp
-// Generated:
-public partial Dto Map(int[]? source)
-{
-    return new Dto(source);
-}
-```
+- Copy constructors are no longer selected over constructors annotated with `[MapperConstructor]` or when `[MapProperty]` configurations are present.
 
 ## Inaccessible members from other assemblies included
 
@@ -174,3 +147,74 @@ Which will result in:
 ```
 
 See also the [full-nameof documentation](../configuration/full-nameof.md).
+
+## Constructor mappings with nullable source skip null guard
+
+When mapping a nullable source type to a target type via a single-parameter constructor,
+Mapperly previously always generated a null guard (throwing `ArgumentNullException` or using a fallback value).
+Starting with v5.0, if the constructor parameter accepts the nullable source type, Mapperly passes the value directly without a null guard.
+
+Previously:
+
+```csharp
+public record Dto(int[]? Array);
+
+// Generated:
+public partial Dto Map(int[]? source)
+{
+    return source == null ? throw new ArgumentNullException(nameof(source)) : new Dto(source);
+}
+```
+
+Now:
+
+```csharp
+// Generated:
+public partial Dto Map(int[]? source)
+{
+    return new Dto(source);
+}
+```
+
+## Constructor selection
+
+Previously, when a type had a copy constructor (a single-parameter constructor accepting the same type),
+Mapperly always used it, even when `[MapperConstructor]` was placed on a different constructor or `[MapProperty]` configurations were present.
+This caused `[MapProperty(Use = ...)]` annotations to be silently ignored.
+
+Starting with v5.0, copy constructors are skipped when:
+
+- `[MapperConstructor]` is placed on a different constructor.
+- `[MapProperty]` configurations are present on a same-type mapping (e.g. deep cloning with property transformations).
+
+Previously:
+
+```csharp
+public class Document
+{
+    [MapperConstructor]
+    public Document() { }
+    public Document(Document other) { Title = other.Title; }
+    public string Title { get; init; }
+}
+
+// Generated (copy constructor used, [MapperConstructor] ignored):
+public partial Document Clone(Document source)
+{
+    return new Document(source);
+}
+```
+
+Now:
+
+```csharp
+// Generated ([MapperConstructor] respected):
+public partial Document Clone(Document source)
+{
+    var target = new Document()
+    {
+        Title = source.Title,
+    };
+    return target;
+}
+```
diff --git a/src/Riok.Mapperly/Descriptors/MappingBuilders/CtorMappingBuilder.cs b/src/Riok.Mapperly/Descriptors/MappingBuilders/CtorMappingBuilder.cs
index 8cbec2d099..794cc3e30c 100644
--- a/src/Riok.Mapperly/Descriptors/MappingBuilders/CtorMappingBuilder.cs
+++ b/src/Riok.Mapperly/Descriptors/MappingBuilders/CtorMappingBuilder.cs
@@ -18,13 +18,24 @@ public static class CtorMappingBuilder
         if (ctx.Target.TryGetNonNullable(out _))
             return null;
 
+        // If e member mappings are configured (e.g. [MapProperty]),
+        // do not use it as it bypasses property-by-property mapping
+        if (ctx.Configuration.Members.ExplicitMappings.Count > 0)
+            return null;
+
         if (ctx.Target is not INamedTypeSymbol namedTarget)
             return null;
 
+        // resolve ctors which have the source as single argument
         var ctor = FindSingleArgCtor(namedTarget, ctx.Source, ctx.SymbolAccessor);
         if (ctor == null)
             return null;
 
+        // if another constructor is explicitly marked with [MapperConstructor],
+        // do not use the copy constructor and let the member mapping builder handle it
+        if (HasMapperConstructorOnDifferentCtor(namedTarget, ctor, ctx.SymbolAccessor))
+            return null;
+
         return new CtorMapping(ctx.Source, ctx.Target, ctx.InstanceConstructors.BuildForConstructor(ctor));
     }
 
@@ -36,11 +47,21 @@ public static class CtorMappingBuilder
         if (ctx.Target is not INamedTypeSymbol namedTarget)
             return null;
 
+        // If e member mappings are configured (e.g. [MapProperty]),
+        // do not use it as it bypasses property-by-property mapping
+        if (ctx.Configuration.Members.ExplicitMappings.Count > 0)
+            return null;
+
         // resolve ctors which have the source as single argument
         var ctor = FindSingleArgCtor(namedTarget, ctx.Source, ctx.SymbolAccessor);
         if (ctor == null)
             return null;
 
+        // if another constructor is explicitly marked with [MapperConstructor],
+        // do not use the copy constructor and let the member mapping builder handle it
+        if (HasMapperConstructorOnDifferentCtor(namedTarget, ctor, ctx.SymbolAccessor))
+            return null;
+
         return new CtorMapping(ctx.Source, ctx.Target, ctx.InstanceConstructors.BuildForConstructor(ctor));
     }
 
@@ -52,4 +73,13 @@ public static class CtorMappingBuilder
                 && SymbolEqualityComparer.Default.Equals(m.Parameters[0].Type.NonNullable(), sourceType.NonNullable())
                 && sourceType.HasSameOrStricterNullability(m.Parameters[0].Type)
             );
+
+    private static bool HasMapperConstructorOnDifferentCtor(
+        INamedTypeSymbol target,
+        IMethodSymbol copyCtor,
+        SymbolAccessor symbolAccessor
+    ) =>
+        target.InstanceConstructors.Any(ctor =>
+            !SymbolEqualityComparer.Default.Equals(ctor, copyCtor) && symbolAccessor.HasAttribute<MapperConstructorAttribute>(ctor)
+        );
 }


Test patch:
diff --git a/test/Riok.Mapperly.Tests/Mapping/CtorTest.cs b/test/Riok.Mapperly.Tests/Mapping/CtorTest.cs
index e0361d384a..e6b28f15d3 100644
--- a/test/Riok.Mapperly.Tests/Mapping/CtorTest.cs
+++ b/test/Riok.Mapperly.Tests/Mapping/CtorTest.cs
@@ -183,4 +183,186 @@ public void NullableClassSourceCtorWithNullableClassParam()
         var source = TestSourceBuilder.Mapping("A?", "B", "class A { }", "class B { public B(A? a) {} }");
         TestHelper.GenerateMapper(source).Should().HaveSingleMethodBody("return new global::B(source);");
     }
+
+    [Fact]
+    public void DeepCloneClassWithCopyCtorAndMapperConstructorShouldUseAttributedCtor()
+    {
+        var source = TestSourceBuilder.MapperWithBodyAndTypes(
+            """
+            [MapProperty(nameof(A.Value), nameof(A.Value), Use = nameof(Transform))]
+            public partial A Clone(A source);
+
+            [UserMapping(Default = false)]
+            private string Transform(string value) => $"transformed-{value}";
+            """,
+            TestSourceBuilderOptions.WithDeepCloning,
+            """
+            class A
+            {
+                [MapperConstructor]
+                public A() { }
+                public A(A other) { Value = other.Value; }
+                public required string Value { get; init; }
+            }
+            """
+        );
+        TestHelper
+            .GenerateMapper(source)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                var target = new global::A()
+                {
+                    Value = Transform(source.Value),
+                };
+                return target;
+                """
+            );
+    }
+
+    [Fact]
+    public void MapperConstructorShouldOverrideCopyCtorWithoutDeepCloning()
+    {
+        var source = TestSourceBuilder.Mapping(
+            "A",
+            "B",
+            """
+            class A
+            {
+                public int Value { get; set; }
+            }
+            """,
+            """
+            class B
+            {
+                [MapperConstructor]
+                public B() { }
+                public B(A other) { Value = other.Value; }
+                public int Value { get; set; }
+            }
+            """
+        );
+        TestHelper
+            .GenerateMapper(source)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                var target = new global::B();
+                target.Value = source.Value;
+                return target;
+                """
+            );
+    }
+
+    [Fact]
+    public void MapPropertyShouldOverrideCopyCtorSelection()
+    {
+        var source = TestSourceBuilder.MapperWithBodyAndTypes(
+            """
+            [MapProperty(nameof(A.Value), nameof(A.Value), Use = nameof(Transform))]
+            public partial A Clone(A source);
+
+            [UserMapping(Default = false)]
+            private string Transform(string value) => $"transformed-{value}";
+            """,
+            TestSourceBuilderOptions.WithDeepCloning,
+            """
+            class A
+            {
+                public A() { }
+                public A(A other) { Value = other.Value; }
+                public required string Value { get; init; }
+            }
+            """
+        );
+        TestHelper
+            .GenerateMapper(source)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                var target = new global::A()
+                {
+                    Value = Transform(source.Value),
+                };
+                return target;
+                """
+            );
+    }
+
+    [Fact]
+    public void NullableSourceMapperConstructorShouldOverrideCopyCtorSelection()
+    {
+        var source = TestSourceBuilder.MapperWithBodyAndTypes(
+            "public partial B Map(A? source);",
+            """
+            class A
+            {
+                public int Value { get; set; }
+            }
+            """,
+            """
+            class B
+            {
+                [MapperConstructor]
+                public B() { }
+                public B(A value) { Value = value.Value; }
+                public int Value { get; set; }
+            }
+            """
+        );
+        TestHelper
+            .GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                if (source == null)
+                    throw new global::System.ArgumentNullException(nameof(source));
+                var target = new global::B();
+                target.Value = source.Value;
+                return target;
+                """
+            );
+    }
+
+    [Fact]
+    public void NullableSourceMapPropertyShouldOverrideCopyCtorSelection()
+    {
+        var source = TestSourceBuilder.MapperWithBodyAndTypes(
+            """
+            [MapProperty(nameof(A.Value), nameof(B.Value), Use = nameof(Transform))]
+            public partial B Map(A? source);
+
+            [UserMapping(Default = false)]
+            private string Transform(string value) => $"transformed-{value}";
+            """,
+            """
+            class A
+            {
+                public required string Value { get; init; }
+            }
+            """,
+            """
+            class B
+            {
+                public B() { }
+                public B(A value) { Value = value.Value; }
+                public required string Value { get; init; }
+            }
+            """
+        );
+        TestHelper
+            .GenerateMapper(source, TestHelperOptions.AllowDiagnostics)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                if (source == null)
+                    throw new global::System.ArgumentNullException(nameof(source));
+                var target = new global::B()
+                {
+                    Value = Transform(source.Value),
+                };
+                return target;
+                """
+            );
+    }
 }
diff --git a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs
index 9c00d31e65..9a94d5ad10 100644
--- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs
+++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyFromSourceTest.cs
@@ -137,7 +137,29 @@ public void ToConstructorParameter()
             .Should()
             .HaveSingleMethodBody(
                 """
-                return new global::B(source);
+                var target = new global::B(source);
+                return target;
+                """
+            );
+    }
+
+    [Fact]
+    public void ToConstructorParameterWithProperties()
+    {
+        var source = TestSourceBuilder.MapperWithBodyAndTypes(
+            "[MapPropertyFromSource(\"value\")] partial B Map(A source);",
+            "class A { public int IntValue { get; set; } public int IntValue2 { get; set;} }",
+            "class B { public B(A value, int intValue) { public int IntValue2 { get; set; } } }"
+        );
+
+        TestHelper
+            .GenerateMapper(source)
+            .Should()
+            .HaveSingleMethodBody(
+                """
+                var target = new global::B(source, source.IntValue);
+                target.IntValue2 = source.IntValue2;
+                return target;
                 """
             );
     }