Open sandboxFocusImprove this doc

Replacing an implementation, step 1: Finding which calls are actually used

Before you replace a component, you need to know how it is used. A text search finds the call sites in the source code, but it does not tell you which of them actually run. In this step, we count the calls to the legacy calculator for each source location, and print the counts when the program exits.

The legacy calculator and its callers

The legacy calculator has one method:

1public class LegacyTaxCalculator
2{
3    public decimal ComputeTax( decimal amount, string region )
4        => region switch
5        {
6            "EU" => amount * 0.2m,
7            _ => amount * 0.1m
8        };
9}
10

Three modules call it. For instance, the Billing module:

1namespace Billing;
2
3public class InvoiceService
4{
5    private readonly LegacyTaxCalculator _taxCalculator = new();
6
7    public decimal ComputeTotal( decimal amount, string region )
8        => amount + this._taxCalculator.ComputeTax( amount, region );
9
10    public decimal ComputeRefund( decimal amount, string region )
11        => -(amount + this._taxCalculator.ComputeTax( amount, region ));
12}
13

The Shipping and Reporting modules are similar. The program calls some of their methods:

1using Billing;
2using Reporting;
3using Shipping;
4
5var invoiceService = new InvoiceService();
6var shippingService = new ShippingService();
7var taxReport = new TaxReport();
8
9foreach ( var region in new[] { "EU", "US", "CH" } )
10{
11    Console.WriteLine( $"Invoice total in {region}: {invoiceService.ComputeTotal( 100, region ):F2}" );
12    Console.WriteLine( $"Shipping cost in {region}: {shippingService.ComputeShippingCost( 2.5m, region ):F2}" );
13}
14
15Console.WriteLine( $"Annual tax in EU: {taxReport.ComputeAnnualTax( [1000, 2000, 1500], "EU" ):F2}" );
16

Counting the calls

The CallCounter class has a static method with the same parameters as LegacyTaxCalculator.ComputeTax, and a first parameter that receives the calculator on which the method is called. Its last two parameters have caller information attributes. When Metalama redirects a call to this method, these parameters receive the calling member and the line of the original call site, not of the call inside the interceptor.

1using Metalama.Extensions.Interceptors;
2using System.Collections.Concurrent;
3using System.Runtime.CompilerServices;
4
5// The calls in this class are not intercepted. Without this attribute, the call to the legacy calculator
6// below would be redirected to this method, which would call itself.
7[ExcludeInterceptors]
8public static class CallCounter
9{
10    private static readonly ConcurrentDictionary<string, int> _counts = new();
11
12    static CallCounter()
13    {
14        AppDomain.CurrentDomain.ProcessExit += ( _, _ ) => PrintCounts();
15    }
16
17    public static decimal ComputeTax(
18        LegacyTaxCalculator calculator,
19        decimal amount,
20        string region,
21        [CallerMemberName] string callerMember = "",
22        [CallerLineNumber] int callerLine = 0 )
23    {
24        var location = $"{callerMember}, line {callerLine}";
25        _counts.AddOrUpdate( location, 1, ( _, count ) => count + 1 );
26
27        return calculator.ComputeTax( amount, region );
28    }
29
30    private static void PrintCounts()
31    {
32        Console.WriteLine();
33        Console.WriteLine( "Calls to LegacyTaxCalculator.ComputeTax:" );
34
35        foreach ( var (location, count) in _counts.OrderBy( p => p.Key ) )
36        {
37            Console.WriteLine( $"  {location}: {count} call(s)" );
38        }
39    }
40}
41

You can also add a parameter with the [CallerFilePath] attribute. Note that it receives the absolute path of the source file on the build machine, and that this path is stored in the binary.

The method counts the call, then calls the legacy calculator, so the behavior of the program does not change. The static constructor registers a handler of the AppDomain.ProcessExit event, which prints the counts when the program exits.

The class has the ExcludeInterceptorsAttribute attribute, so the calls in this class are not intercepted. Without this attribute, the call to calculator.ComputeTax in the interceptor would be redirected to the interceptor itself.

Redirecting the calls

The following project fabric redirects all the calls to LegacyTaxCalculator.ComputeTax in the project to CallCounter.ComputeTax:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Code;
3using Metalama.Framework.Fabrics;
4
5internal class Fabric : ProjectFabric
6{
7    public override void AmendProject( IProjectAmender amender )
8    {
9        var interceptor = typeof(CallCounter).AsINamedType()
10            .Methods.OfName( nameof(CallCounter.ComputeTax) )
11            .Single();
12
13        amender.InterceptMethods(
14            calls => calls
15                .Type( typeof(LegacyTaxCalculator) )
16                .Methods( nameof(LegacyTaxCalculator.ComputeTax) )
17                .RedirectToExistingMethod( interceptor ) );
18    }
19}
20

The InterceptMethods method selects the intercepted method by its type and its name. The RedirectToExistingMethod method gives the interceptor.

The source code of the callers does not change. At build time, Metalama rewrites the call sites:

Source Code
1namespace Billing;
2
3public class InvoiceService
4{
5    private readonly LegacyTaxCalculator _taxCalculator = new();
6
7    public decimal ComputeTotal( decimal amount, string region )
8        => amount + this._taxCalculator.ComputeTax( amount, region );
9
10    public decimal ComputeRefund( decimal amount, string region )
11        => -(amount + this._taxCalculator.ComputeTax( amount, region ));
12}
13
Transformed Code
1namespace Billing;
2
3public class InvoiceService
4{
5    private readonly LegacyTaxCalculator _taxCalculator = new();
6
7    public decimal ComputeTotal( decimal amount, string region )
8        => amount + CallCounter.ComputeTax( this._taxCalculator,amount, region ,callerMember: "ComputeTotal", callerLine: 8);
9
10    public decimal ComputeRefund( decimal amount, string region )
11        => -(amount + CallCounter.ComputeTax( this._taxCalculator,amount, region ,callerMember: "ComputeRefund", callerLine: 11));
12}
13

Result

The program produces the following output:

Invoice total in EU: 120.00
Shipping cost in EU: 12.00
Invoice total in US: 110.00
Shipping cost in US: 11.00
Invoice total in CH: 110.00
Shipping cost in CH: 11.00
Annual tax in EU: 900.00

Calls to LegacyTaxCalculator.ComputeTax:
  ComputeAnnualTax, line 8: 1 call(s)
  ComputeShippingCost, line 11: 3 call(s)
  ComputeTotal, line 8: 3 call(s)

The call sites in InvoiceService.ComputeRefund and TaxReport.ComputeQuarterlyTax are not in the list, because they did not run. In a real application, you would run this build in a test or staging environment for some time, and write the counts to a log instead of the console.

To remove the counting, delete the fabric and the CallCounter class. The callers do not need any change.