In the previous step, we compared the results of the new calculator with the results of the legacy one. In this step, we switch the calls to the new calculator one module at a time. Each module goes through two stages:
Comparing: the calls go to the legacy calculator, and some of them are compared with the new calculator, as in the previous step.Migrated: the calls go to the new calculator.
The modules that are in no stage still call the legacy calculator directly.
Adapting the calls to the new calculator
The calls in the source code are written for the legacy calculator: their receiver is a LegacyTaxCalculator object. The interceptor of the migrated calls receives this object and ignores it:
1public static class NewTaxCalculatorAdapter
2{
3 public static decimal ComputeTax( LegacyTaxCalculator calculator, decimal amount, string region )
4 => NewTaxCalculator.ComputeTax( amount, region );
5}
6
The TaxComparison class is the same as in the previous step.
Selecting the modules with a tagged query
The project fabric declares the stage of each module:
8// The migration stage of each module. The calls in the modules that are not listed
9// still go to the legacy calculator.
10private static readonly Dictionary<string, MigrationStage> _modules = new()
11{
12 ["Billing"] = MigrationStage.Migrated,
13 ["Shipping"] = MigrationStage.Comparing
14};The fabric then selects the namespace of each module, tags it with its stage, and registers the interception:
18public override void AmendProject( IProjectAmender amender )
19 => amender
20 .SelectMany(
21 compilation => _modules.Keys
22 .Select( name => compilation.GlobalNamespace.GetDescendant( name ) )
23 .OfType<INamespace>() )
24 .WithTag( ns => _modules[ns.FullName] )
25 .InterceptMethods(
26 calls => calls
27 .Type( typeof(LegacyTaxCalculator) )
28 .Methods( nameof(LegacyTaxCalculator.ComputeTax) )
29 .ForEachSite( site => site.RedirectToExistingMethod( GetInterceptor( site.ScopeTag ) ) ) );- The
SelectManyquery finds the namespace of each module with the GetDescendant method. - The WithTag method attaches the stage of the module to its namespace.
- The
InterceptMethodsmethod, called on the tagged query, makes each namespace a scope. Only the calls written in these namespaces are intercepted. - The delegate given to ForEachSite runs for each call site. It reads the stage of the module through the ScopeTag property, and redirects the call to the matching interceptor.
To move a module to the next stage, you change one line of the fabric. The callers do not change. If the new calculator causes a problem in production, you move the module back to the previous stage.
At build time, Metalama redirects the calls in the Billing module to the new calculator:
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
1namespace Billing;
2
3public class InvoiceService
4{
5 private readonly LegacyTaxCalculator _taxCalculator = new();
6
7 public decimal ComputeTotal( decimal amount, string region )
8 => amount + NewTaxCalculatorAdapter.ComputeTax( this._taxCalculator,amount, region );
9
10 public decimal ComputeRefund( decimal amount, string region )
11 => -(amount + NewTaxCalculatorAdapter.ComputeTax( this._taxCalculator,amount, region ));
12}
13
The calls in the Shipping module go to the comparison:
1namespace Shipping;
2
3public class ShippingService
4{
5 private readonly LegacyTaxCalculator _taxCalculator = new();
6
7 public decimal ComputeShippingCost( decimal weight, string region )
8 {
9 var cost = 5 + (weight * 2);
10
11 return cost + this._taxCalculator.ComputeTax( cost, region );
12 }
13}
14
1namespace Shipping;
2
3public class ShippingService
4{
5 private readonly LegacyTaxCalculator _taxCalculator = new();
6
7 public decimal ComputeShippingCost( decimal weight, string region )
8 {
9 var cost = 5 + (weight * 2);
10
11 return cost + TaxComparison.ComputeTax( this._taxCalculator,cost, region ,callerMember: "ComputeShippingCost", callerLine: 11);
12 }
13}
14
The calls in the Reporting module do not change:
1namespace Reporting;
2
3public class TaxReport
4{
5 private readonly LegacyTaxCalculator _taxCalculator = new();
6
7 public decimal ComputeAnnualTax( IEnumerable<decimal> monthlyRevenues, string region )
8 => this._taxCalculator.ComputeTax( monthlyRevenues.Sum(), region );
9
10 public decimal ComputeQuarterlyTax( decimal revenue, string region )
11 => this._taxCalculator.ComputeTax( revenue, region );
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: 108.10
Difference at ComputeShippingCost, line 11 for 10.00 in CH: legacy 1.00, new 0.81.
Shipping cost in CH: 11.00
Annual tax in EU: 900.00
The invoice total in the CH region now uses the tax rate of the new calculator. The shipping cost still uses the legacy calculator, and the difference is logged.
Finishing the migration
The interceptors are a temporary abstraction. They make the migration safe, but they hide the real behavior of the code: a reader of InvoiceService sees a call to the legacy calculator, while the program calls the new one. Do not keep them after the migration.
When all the modules are in the Migrated stage, and the new calculator has run in production for long enough, finish the migration:
- Rewrite the call sites in one reviewed change. Replace each call to
LegacyTaxCalculator.ComputeTaxwith a call toNewTaxCalculator.ComputeTax, and remove the fields that store aLegacyTaxCalculator. The behavior of the program does not change, because the interceptors already redirected these calls. The reviewers only have to check that the change is mechanical. - Remove the interceptors: delete the fabric, the
NewTaxCalculatorAdapterclass and theTaxComparisonclass. - Delete the
LegacyTaxCalculatorclass. If a call to it remains, the build fails, so no call site can be forgotten.