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.CodeAnalysispackage 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:
- Reference the AspectContracts project normally (for type visibility).
- Load the Engine assembly as a Metalama extension.
- 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.