Open sandboxFocusImprove this doc

Introducing constructor parameters

Most of the time, an aspect introduces a constructor parameter to retrieve a dependency from a dependency injection framework. In such situations, use the Metalama.Extensions.DependencyInjection framework, as detailed in Injecting dependencies into aspects; DI framework implementations themselves introduce parameters using the method outlined here.

The IntroduceParameter advice supports two mechanisms. Both preserve source compatibility (existing source code that constructs the type keeps compiling), but they differ on other axes:

Adding an optional parameter Adding a required parameter, pulled from a forwarding constructor
Default value Must be a compile-time constant Any expression (DateTime.Now, a factory call, …)
Binary compatibility Not preserved (IL signature changes) Preserved (pre-mutation signature retained)
Constructor count Unchanged One additional constructor per mutated constructor

Adding an optional parameter is the right choice when the type is instantiated by a dependency-injection container and binary compatibility is not a concern. Keeping the constructor count unchanged avoids issues with reflection-based consumers that walk Type.GetConstructors(). Notably, Microsoft.Extensions.DependencyInjection.ActivatorUtilities (used throughout ASP.NET Core for typed controllers, hosted services, and framework factories) requires exactly one applicable constructor and throws when several have all parameters resolvable. Serializers, test fixtures, and object-graph builders each have their own constructor-selection rules and can similarly be disturbed by the extra overload.

Adding a required parameter, pulled from a forwarding constructor is the right choice when the type is part of a public API (binary compatibility matters), when the default value must be a non-constant expression, or when the parameterless constructor must remain callable, for instance because the type is instantiated via Activator.CreateInstance(type), Activator.CreateInstance<T>(), or a new() generic constraint. The dedicated ForwardDefaultConstructor strategy preserves the parameterless constructor specifically for this scenario.

Adding an optional parameter

To append a parameter with a compile-time constant default value, use the IntroduceParameter overload that accepts a defaultValue argument. This method requires the target IConstructor, the name, the type of the new parameter, and the default value.

Because the parameter has a default value, existing callers can omit the new argument, so source compatibility is preserved automatically, without needing a forwarding constructor. However, the default value must be a compile-time constant (a TypedConstant).

The pullStrategy parameter allows you to specify the value passed to this parameter in other constructors that call the specified constructor, using the : this(...) or : base(...) syntax. This parameter accepts an IPullStrategy implementation. To create a pull strategy, use one of the factory methods of the PullStrategy class, such as UseExpression or IntroduceParameterAndPull.

The IntroduceParameterAndPull method accepts a reuseExistingParameterOfCompatibleType parameter. When set to true, if a constructor that calls the target constructor via : this(...) or : base(...) already has a parameter whose type is the same as or more specific than the type being introduced, the existing parameter is forwarded instead of adding a duplicate. If the existing parameter was previously introduced and has a less specific type, it is automatically replaced with the more specific type. This is particularly useful for dependency injection scenarios where two parameters of the same service type on a single constructor are never intentional.

Example: optional parameter

The example below demonstrates an aspect that registers the current instance in a registry of type IInstanceRegistry. The aspect appends a parameter of type IInstanceRegistry to the target constructor and invokes the IInstanceRegistry.Register(this) method.

1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5
6namespace Doc.IntroduceParameter;
7
8internal class RegisterInstanceAttribute : ConstructorAspect
9{
10    public override void BuildAspect( IAspectBuilder<IConstructor> builder )
11    {
12        builder.IntroduceParameter(
13            "instanceRegistry",
14            TypeFactory.GetNamedType( typeof(IInstanceRegistry) ).ToNullable(),
15            TypedConstant.Default( typeof(IInstanceRegistry) ),
16            pullStrategy: PullStrategy.IntroduceParameterAndPull(
17                "instanceRegistry",
18                TypeFactory.GetType( typeof(IInstanceRegistry) ),
19                TypedConstant.Default( typeof(IInstanceRegistry) ) ) );
20
21        builder.AddInitializer( StatementFactory.Parse( "instanceRegistry.Register( this );" ) );
22    }
23}
24
25public interface IInstanceRegistry
26{
27    void Register( object instance );
28}
Source Code
1namespace Doc.IntroduceParameter;
2
3internal class Foo
4{
5    [RegisterInstance]
6    public Foo() { }
7}
8



9internal class Bar : Foo { }
Transformed Code
1namespace Doc.IntroduceParameter;
2
3internal class Foo
4{
5    [RegisterInstance]
6    public Foo(IInstanceRegistry? instanceRegistry = default)
7    {
8        instanceRegistry.Register(this);
9    }
10}
11
12internal class Bar : Foo
13{
14    public Bar(IInstanceRegistry instanceRegistry = null) : base(instanceRegistry)
15    {
16    }
17}

Adding a required parameter, pulled from a forwarding constructor

Use the IntroduceParameter overload that does not accept a defaultValue argument. The mechanism has two complementary parts:

  1. The new parameter is added as required to the existing constructor, with no default value, so every call site must supply a value.

  2. A forwarding constructor preserves the pre-mutation signature. It retains the old constructor's parameter list and chains to the mutated constructor via : this(...), passing a value for the new parameter that is produced by an IPullStrategy (for example, UseExpression for an expression such as DateTime.Now, or IntroduceParameterAndPull for a dependency pulled from the DI container).

Together, these preserve both source and binary compatibility: external callers still bind to the original signature, now served by the forwarding constructor.

The forwarding constructor can also be marked with [Obsolete] by calling WithObsoleteAttribute on the overloading strategy. This signals to downstream callers that they should migrate from the original signature to the new one, while still compiling against the forwarder in the meantime (see Overloading strategy below).

Note

Generated code carries two marker attributes that make the transformation self-describing:

  • [SourceCompatibilityConstructor] (<xref:Metalama.Framework.RunTime.SourceCompatibilityConstructorAttribute>) is placed on each generated forwarding constructor, distinguishing Metalama-generated forwarders from constructors written by the user. You can check for it programmatically from a pull strategy by calling <xref:Metalama.Framework.Code.ConstructorExtensions.IsSourceCompatibilityConstructor*> on the target IConstructor.
  • [AspectGenerated] (AspectGeneratedAttribute) is placed on each introduced parameter when the target constructor is reachable from external assemblies. This lets Metalama (and other tools) reconstruct the pre-transformation identity of the constructor, which matters for cross-assembly scenarios such as re-applying an aspect to an already-transformed type.

The IPullStrategy is also consulted when user-written chained constructors (: this(...) or : base(...)) call the mutated constructor without supplying the new argument, so the pull mechanism is the single source of truth for the new parameter's value wherever it is needed.

For more advanced scenarios, implement IPullStrategy directly. Your implementation can detect whether it is being called for a forwarding constructor by using the <xref:Metalama.Framework.Code.ConstructorExtensions.IsSourceCompatibilityConstructor*> extension method.

Overloading strategy

The overloadingStrategy parameter controls whether and how forwarding constructors are generated. It accepts an IConstructorOverloadingStrategy implementation.

The ConstructorOverloadingStrategy class provides two built-in strategies:

Strategy Description
ForwardSourceConstructors Generates a forwarding constructor for every source constructor that the framework mutates. This is the default when overloadingStrategy is null.
ForwardDefaultConstructor Generates a forwarding constructor only when the mutated constructor is the parameterless constructor. This is useful for types that must remain constructible via Activator.CreateInstance<T>() or a new() generic constraint.

Both strategies return a ForwardConstructorStrategy that exposes a WithObsoleteAttribute method. Use this method to decorate the generated forwarding constructor with [Obsolete], signaling to downstream callers that they should migrate to the new constructor signature.

Example: forwarding constructor

The following example demonstrates an aspect that introduces a DateTime creationTime parameter to all constructors. The framework generates forwarding constructors that supply DateTime.Now as the default value, preserving binary compatibility.

1using System;
2using Metalama.Framework.Advising;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Code;
5using Metalama.Framework.Code.SyntaxBuilders;
6
7namespace Doc.IntroduceRequiredParameter;
8
9internal class AddTimestampAttribute : TypeAspect
10{
11    public override void BuildAspect( IAspectBuilder<INamedType> builder )
12    {
13        foreach ( var constructor in builder.Target.Constructors )
14        {
15            builder.With( constructor )
16                .IntroduceParameter(
17                    "creationTime",
18                    typeof(DateTime),
19                    pullStrategy: PullStrategy.UseExpression(
20                        ExpressionFactory.Parse( "global::System.DateTime.Now" ) ),
21                    overloadingStrategy:
22                        ConstructorOverloadingStrategy.ForwardSourceConstructors );
23        }
24    }
25}
26
Source Code
1namespace Doc.IntroduceRequiredParameter;
2


3[AddTimestamp]
4internal class Order
5{
6    public Order( int id )
7    {
8        this.Id = id;
9    }
10
11    public Order( int id, string label ) : this( id )
12    {
13        this.Label = label;
14    }
15
16    public int Id { get; }
17
18    public string? Label { get; }
19}
20
Transformed Code
1using System;
2
3namespace Doc.IntroduceRequiredParameter;
4
5[AddTimestamp]
6internal class Order
7{
8    public Order(int id, DateTime creationTime)
9    {
10        this.Id = id;
11    }
12
13    public Order(int id, string label, DateTime creationTime) : this(id, creationTime)
14    {
15        this.Label = label;
16    }
17
18    public int Id { get; }
19
20    public string? Label { get; }
21
22    public Order(int id) : this(id: id, creationTime: DateTime.Now)
23    {
24    }
25
26    public Order(int id, string label) : this(id: id, label: label, creationTime: DateTime.Now)
27    {
28    }
29}
30

Parameters on record primary constructors

Note

When IntroduceParameter targets a record's primary constructor, the introduced parameter is not materialized as part of the record's value shape by default. This means the parameter will not generate an auto-property, will not appear in Deconstruct, and will not participate in Equals, GetHashCode, or ToString. This prevents accidental pollution of a record's identity with infrastructure parameters (such as DI dependencies or contextual objects).

To materialize the parameter as part of the record's value shape, explicitly opt in by using PullStrategy.IntroduceParameterAndPull(materializeOnRecord: true).