Open sandboxFocusImprove this doc

SDK extension projects

Previous articles showed how to use Metalama.Framework.Sdk directly from your aspect projects. This approach is simple but has two drawbacks:

  • It exposes the Microsoft.CodeAnalysis package references to your project or package consumers. These are large dependencies you might want to avoid flowing to your users.
  • You can't have conditional compilation or conditional package references based on the target framework or Roslyn version.

For these situations, create extension assemblies.

Project structure

A typical SDK extension implementation requires three projects, plus an optional packaging project:

graph LR
    subgraph Metalama
        Framework[Metalama.Framework]
        Sdk[Metalama.Framework.Sdk]
    end

    Sdk --> Framework

    subgraph Your Solution
        AspectContracts[AspectContracts<br/><i>MetalamaEnabled=false</i>]
        Engine[Engine<br/><i>MetalamaEnabled=false</i>]
        Main[Main]
        Package[Package<br/><i>optional</i>]
    end

    subgraph Roslyn
        Microsoft.CodeAnalysis.*    
    end

    AspectContracts --> Framework
    Engine --> Sdk
    Main --> Framework
    Sdk --> Microsoft.CodeAnalysis.*

    Engine --> AspectContracts
    Main --> AspectContracts
    Main -.-> Engine
    Package -.-> Engine
    Package ---> AspectContracts
    Package ---> Main
Project Purpose Package Reference
AspectContracts Defines the IProjectService interface Metalama.Framework
Engine Implements the service and factory Metalama.Framework.Sdk
Main Contains aspects that use the service Metalama.Framework
Package Bundles the extension as a NuGet package (optional) Metalama.Framework

This separation ensures the following:

  • The AspectContracts project can be referenced by both the Engine and Main projects.
  • The Engine project can use SDK APIs without affecting the Main project.
  • The Main project loads the Engine as a Metalama extension.
  • The Package project bundles everything for distribution without exposing SDK dependencies.
Note

The AspectContracts and Engine projects must have <MetalamaEnabled>false</MetalamaEnabled> in their project files because they are compile-time-only projects that shouldn't be processed by Metalama.

Creating an SDK extension

Step 1. Create the AspectContracts project

Create a class library targeting netstandard2.0 with MetalamaEnabled set to false:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- netstandard2.0 ensures compatibility with all compiler hosts. -->
    <TargetFramework>netstandard2.0</TargetFramework>
    <!-- Disable Metalama processing - this assembly is used at compile time only
         and should not be transformed by Metalama. -->
    <MetalamaEnabled>false</MetalamaEnabled>
    <!-- This project is packaged via the Package project, not individually. -->
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Metalama.Framework" />
  </ItemGroup>

</Project>

Define your service interface:

using Metalama.Framework.Aspects;
using Metalama.Framework.Services;

[assembly: CompileTime]

namespace MyExtension.AspectContracts;

public interface IGreetingService : IProjectService
{
    string GetGreeting( string name );
}

Key points:

  • The interface must inherit from IProjectService.
  • Mark the entire assembly with [assembly: CompileTime] since it's used at compile time.

Step 2. Create the Engine project

Create another class library targeting netstandard2.0 with MetalamaEnabled set to false. Reference Metalama.Framework.Sdk:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- netstandard2.0 ensures compatibility with both Visual Studio (net472)
         and other compiler hosts (net8.0+). -->
    <TargetFramework>netstandard2.0</TargetFramework>
    <!-- Disable Metalama processing - this is a Metalama extension assembly,
         not a project that should be transformed by Metalama. -->
    <MetalamaEnabled>false</MetalamaEnabled>
    <!-- This project is packaged via the Package project, not individually. -->
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <!-- Reference Metalama.Framework.Sdk for IProjectServiceFactory, ExportExtensionAttribute,
         and access to Roslyn APIs (Microsoft.CodeAnalysis). -->
    <PackageReference Include="Metalama.Framework.Sdk" />
  </ItemGroup>

  <ItemGroup>
    <!-- Reference AspectContracts to implement the service interface. -->
    <ProjectReference Include="../MyExtension.AspectContracts/MyExtension.AspectContracts.csproj" />
  </ItemGroup>

</Project>

Implement the service:

using MyExtension.AspectContracts;

namespace MyExtension.Engine;

internal class GreetingService : IGreetingService
{
    public string GetGreeting( string name ) => $"Hello, {name}!";
}

Step 3. Create the service factory

In the Engine project, create a factory class that implements IProjectServiceFactory and export it using ExportExtensionAttribute:

using Metalama.Framework.Engine.Extensibility;
using Metalama.Framework.Engine.Services;
using Metalama.Framework.Services;
using System.Collections.Generic;

[assembly: ExportExtension( typeof( MyExtension.Engine.GreetingServiceFactory ), ExtensionKinds.ServiceFactory )]

namespace MyExtension.Engine;

public class GreetingServiceFactory : IProjectServiceFactory
{
    public IEnumerable<IProjectService> CreateServices( in ProjectServiceProvider serviceProvider )
    {
        return new IProjectService[] { new GreetingService() };
    }
}

Key points:

  • The [assembly: ExportExtension(...)] attribute registers the factory with Metalama.
  • Use ServiceFactory to indicate this is a service factory.
  • The factory's CreateServices method returns the service instances.

Step 4. Configure the Main project

The Main project must:

  1. Reference the AspectContracts project normally (for type visibility).
  2. Load the Engine assembly as a Metalama extension.
  3. Make the AspectContracts assembly available at compile time.
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- This sample is an executable for demonstration purposes.
         In a real library, use Library and target multiple frameworks. -->
    <OutputType>Exe</OutputType>
    <!-- Target framework for consumer projects. Choose based on your library's requirements.
         This doesn't affect compile-time extension loading. -->
    <TargetFramework>net8.0</TargetFramework>
    <!-- This project is packaged via the Package project, not individually. -->
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <!-- Reference Metalama.Framework for aspect development. -->
    <PackageReference Include="Metalama.Framework" />
  </ItemGroup>

  <ItemGroup>
    <!-- Regular project reference for compile-time type visibility.
         Aspects can use IGreetingService because this reference is available. -->
    <ProjectReference Include="../MyExtension.AspectContracts/MyExtension.AspectContracts.csproj" />

    <!-- ProjectReference establishes build order so Engine is built before this project.
         OutputItemType="None" excludes the Engine from run-time output since it contains
         SDK dependencies that shouldn't be deployed with the application. -->
    <ProjectReference Include="../MyExtension.Engine/MyExtension.Engine.csproj" OutputItemType="None" />

    <!-- Make the AspectContracts assembly available to compile-time code (aspects).
         Without this, aspects couldn't reference types from AspectContracts. -->
    <MetalamaCompileTimeAssembly
        Include="../MyExtension.AspectContracts/bin/$(Configuration)/netstandard2.0/MyExtension.AspectContracts.dll" />

    <!-- Load the Engine assembly as a Metalama extension at compile time.
         This makes GreetingServiceFactory discoverable so Metalama can instantiate
         the IGreetingService and make it available via IProject.ServiceProvider. -->
    <MetalamaExtensionAssembly
        Include="../MyExtension.Engine/bin/$(Configuration)/netstandard2.0/MyExtension.Engine.dll" />
  </ItemGroup>

</Project>

Step 5. Use the service from an aspect

In the Main project, access the service through ServiceProvider:

using Metalama.Framework.Advising;
using Metalama.Framework.Aspects;
using Metalama.Framework.Code;
using MyExtension.AspectContracts;
using System;

namespace MyExtension;

public class GreetingAspect : TypeAspect
{
    public override void BuildAspect( IAspectBuilder<INamedType> builder )
    {
        // Get the custom service from the project's service provider.
        var service = builder.Project.ServiceProvider.GetService<IGreetingService>()
            ?? throw new InvalidOperationException( "IGreetingService not found." );

        // Use the service at compile time to generate a greeting.
        var greeting = service.GetGreeting( builder.Target.Name );

        // Introduce a method that prints the greeting at run time.
        builder.IntroduceMethod( nameof( SayHello ), args: new { greeting } );
    }

    [Template]
    public static void SayHello( [CompileTime] string greeting )
    {
        Console.WriteLine( greeting );
    }
}

Conditional compilation

To target different frameworks or Roslyn versions with different assemblies, include several assemblies in the package and use the TargetFramework and TargetRoslynVersion metadata on MetalamaExtensionAssembly to specify which build of the extension to load. This approach supports conditional compilation or different package reference sets.

<ItemGroup>
    <!-- Visual Studio (runs on .NET Framework) -->
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net472/MyExtension.Engine.4.8.0.dll"
        TargetFramework="net472"
        TargetRoslynVersion="4.8.0"/>
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net472/MyExtension.Engine.4.12.0.dll"
        TargetFramework="net472"
        TargetRoslynVersion="4.12.0"/>
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net472/MyExtension.Engine.5.0.0.dll"
        TargetFramework="net472"
        TargetRoslynVersion="5.0.0"/>

    <!-- dotnet build, Rider, and other modern hosts -->
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net8.0/MyExtension.Engine.4.8.0.dll"
        TargetFramework="net8.0"
        TargetRoslynVersion="4.8.0"/>
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net8.0/MyExtension.Engine.4.12.0.dll"
        TargetFramework="net8.0"
        TargetRoslynVersion="4.12.0"/>
    <MetalamaExtensionAssembly
        Include="$(MSBuildThisFileDirectory)../metalama/net8.0/MyExtension.Engine.5.0.0.dll"
        TargetFramework="net8.0"
        TargetRoslynVersion="5.0.0"/>
</ItemGroup>

Metalama loads the assembly that matches the Roslyn version used by the compiler or IDE. If no exact match is found, the assembly won't load.

Warning

When using TargetRoslynVersion metadata, ensure your version numbers exactly match the Roslyn versions and target frameworks that the specific Metalama build you're targeting supports. Metalama currently lacks a robust, forward-compatible selection mechanism for extension assemblies, and mismatched versions will cause build failures.

For details on MetalamaExtensionAssembly and MetalamaCompileTimeAssembly, see MSBuild properties and environment variables.

Packaging as a NuGet package

When distributing your SDK extension as a NuGet package, include the extension assemblies and configure MSBuild to load them. This section describes the recommended package structure.

Package folder structure

MyExtension.nupkg
├── build/
│   └── MyExtension.props          # MSBuild props for direct package consumers
├── buildTransitive/
│   └── MyExtension.props          # MSBuild props for transitive package consumers
├── lib/
│   ├── netstandard2.0/
│   │   └── MyExtension.AspectContracts.dll  # AspectContracts assembly
│   └── net8.0/
│       └── MyExtension.dll                  # Main assembly with aspects
└── metalama/
    └── MyExtension.Engine.dll     # Extension assembly
Note

The metalama folder path is arbitrary. Metalama does not impose a convention on where extension assemblies are stored in the package - only the MetalamaExtensionAssembly item path matters.

Create the props file

Create a .props file in the build folder that defines the MetalamaCompileTimeAssembly and MetalamaExtensionAssembly items:

<Project>
    <!-- This props file is automatically imported by projects that reference the MyExtension package.
         It configures Metalama to load the extension assemblies at compile time. -->
    <ItemGroup>
        <!-- Make the AspectContracts assembly available to compile-time code (aspects).
             This allows aspects to reference types like IGreetingService. -->
        <MetalamaCompileTimeAssembly
            Include="$(MSBuildThisFileDirectory)../lib/netstandard2.0/MyExtension.AspectContracts.dll" />

        <!-- Load the Engine as a Metalama extension assembly. -->
        <MetalamaExtensionAssembly
            Include="$(MSBuildThisFileDirectory)../metalama/MyExtension.Engine.dll" />
    </ItemGroup>
</Project>

For transitive consumers, create a buildTransitive/MyExtension.props that imports the main props file:

<Project>
    <!-- This props file is imported by projects that transitively reference the MyExtension package.
         It ensures the extension is loaded even when not directly referenced. -->
    <Import Project="../build/MyExtension.props"/>
</Project>

Packaging project

Create a separate packaging project that includes the extension assemblies in the package. Use TfmSpecificPackageFile items to add assemblies to the package:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <!-- netstandard2.0 is used for the packaging project itself. This doesn't affect
             the target frameworks of the packaged assemblies. -->
        <TargetFramework>netstandard2.0</TargetFramework>
        <!-- The NuGet package ID - consumers will reference this name. -->
        <PackageId>MyExtension</PackageId>
        <!-- Don't include this project's build output in the package.
             We manually add the required assemblies via TfmSpecificPackageFile. -->
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <!-- Hook into the packaging process to add extension assemblies. -->
        <TargetsForTfmSpecificContentInPackage>
            $(TargetsForTfmSpecificContentInPackage);_AddAssembliesToOutput
        </TargetsForTfmSpecificContentInPackage>
        <!-- NU5100: Assembly not in lib folder (metalama folder is intentional). -->
        <NoWarn>$(NoWarn);NU5100</NoWarn>
    </PropertyGroup>

    <ItemGroup>
        <!-- Reference Metalama.Framework to establish the package dependency. -->
        <PackageReference Include="Metalama.Framework" />
    </ItemGroup>

    <ItemGroup>
        <!-- Include the props files in the package. -->
        <None Update="build/*" Pack="true" PackagePath=""/>
        <None Update="buildTransitive/*" Pack="true" PackagePath=""/>
    </ItemGroup>

    <!-- Add assemblies to the package. -->
    <Target Name="_AddAssembliesToOutput">
        <!-- Build dependencies first. RemoveProperties clears inherited TargetFramework. -->
        <MSBuild Projects="../MyExtension.Engine/MyExtension.Engine.csproj"
                 Properties="Configuration=$(Configuration)" RemoveProperties="TargetFramework" />
        <MSBuild Projects="../MyExtension/MyExtension.csproj"
                 Properties="Configuration=$(Configuration)" RemoveProperties="TargetFramework" />

        <ItemGroup>
            <!-- Add AspectContracts and Main to lib folder for run-time use. -->
            <TfmSpecificPackageFile
                Include="../MyExtension.AspectContracts/bin/$(Configuration)/netstandard2.0/MyExtension.AspectContracts.dll"
                PackagePath="lib/netstandard2.0" />
            <TfmSpecificPackageFile
                Include="../MyExtension/bin/$(Configuration)/net8.0/MyExtension.dll"
                PackagePath="lib/net8.0" />

            <!-- Add Engine to metalama folder for compile-time use. -->
            <TfmSpecificPackageFile
                Include="../MyExtension.Engine/bin/$(Configuration)/netstandard2.0/MyExtension.Engine.dll"
                PackagePath="metalama" />
        </ItemGroup>
    </Target>
</Project>

ExportExtensionAttribute vs. MetalamaPlugInAttribute

The MetalamaPlugInAttribute attribute registers types that Metalama discovers automatically, such as aspect weavers and metric providers. This attribute only works in projects where MetalamaEnabled=true, which means Metalama compiles the project.

In contrast, ExportExtensionAttribute works with extension assemblies that Metalama doesn't compile (MetalamaEnabled=false). Use this attribute to export extension types such as service factories from these assemblies.