Adapter Generator
Overview
The Adapter Generator creates object adapters that implement a target contract (interface or abstract class) by delegating to an adaptee through explicit mapping methods. This pattern allows incompatible interfaces to work together without modifying either the target or adaptee.
When to Use
Use the Adapter generator when you need to:
- Integrate legacy code: Wrap older implementations to work with modern interfaces
- Abstract third-party libraries: Create a clean boundary around external dependencies
- Support multiple implementations: Adapt different backends (payment gateways, loggers, etc.) to a unified interface
- Compile-time safety: Ensure all contract members are properly mapped
Installation
The generator is included in the PatternKit.Generators package:
dotnet add package PatternKit.Generators
Quick Start
using PatternKit.Generators.Adapter;
// Target interface your app uses
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
// Legacy class with different API
public class LegacyClock
{
public DateTime GetCurrentTimeUtc() => DateTime.UtcNow;
}
// Define mappings in a static partial class
[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))]
public static partial class ClockAdapters
{
[AdapterMap(TargetMember = nameof(IClock.UtcNow))]
public static DateTimeOffset MapUtcNow(LegacyClock adaptee)
=> new(adaptee.GetCurrentTimeUtc(), TimeSpan.Zero);
}
Generated:
public sealed partial class LegacyClockToIClockAdapter : IClock
{
private readonly LegacyClock _adaptee;
public LegacyClockToIClockAdapter(LegacyClock adaptee)
{
_adaptee = adaptee ?? throw new ArgumentNullException(nameof(adaptee));
}
public DateTimeOffset UtcNow
{
get => ClockAdapters.MapUtcNow(_adaptee);
}
}
Usage:
// Create the adapter
IClock clock = new LegacyClockToIClockAdapter(new LegacyClock());
// Use through the clean interface
var now = clock.UtcNow;
Mapping Methods
Each target contract member needs a mapping method marked with [AdapterMap].
Property Mappings
For properties, the mapping method takes only the adaptee and returns the property type:
public interface IService
{
string Name { get; }
}
[AdapterMap(TargetMember = nameof(IService.Name))]
public static string MapName(LegacyService adaptee) => adaptee.ServiceName;
Method Mappings
For methods, the mapping method takes the adaptee as the first parameter, followed by all method parameters:
public interface ICalculator
{
int Add(int a, int b);
}
[AdapterMap(TargetMember = nameof(ICalculator.Add))]
public static int MapAdd(OldCalculator adaptee, int a, int b)
=> adaptee.Sum(a, b);
Async Method Mappings
Async methods work the same way - just match the return type:
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(string token, decimal amount, CancellationToken ct);
}
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
public static async Task<PaymentResult> MapChargeAsync(
LegacyPaymentClient adaptee,
string token,
decimal amount,
CancellationToken ct)
{
var response = await adaptee.ProcessPaymentAsync(token, (int)(amount * 100), ct);
return new PaymentResult(response.Success, response.Id);
}
Attributes
[GenerateAdapter]
Marks a static partial class as an adapter mapping host.
| Property | Type | Default | Description |
|---|---|---|---|
Target |
Type |
Required | The interface or abstract class to implement |
Adaptee |
Type |
Required | The class to adapt |
AdapterTypeName |
string |
{Adaptee}To{Target}Adapter |
Custom name for the generated adapter class |
MissingMap |
AdapterMissingMapPolicy |
Error |
How to handle unmapped members |
Sealed |
bool |
true |
Whether the adapter class is sealed |
Namespace |
string |
Host namespace | Custom namespace for the adapter |
[AdapterMap]
Marks a method as a mapping for a target member.
| Property | Type | Default | Description |
|---|---|---|---|
TargetMember |
string |
Required | Name of the target member (use nameof()) |
Missing Map Policies
Control what happens when a target member has no [AdapterMap]:
Error (Default)
Emits a compiler error. Recommended for production code:
[GenerateAdapter(Target = typeof(IClock), Adaptee = typeof(LegacyClock))]
// MissingMap = AdapterMissingMapPolicy.Error is the default
ThrowingStub
Generates a stub that throws NotImplementedException. Useful during incremental development:
[GenerateAdapter(
Target = typeof(IClock),
Adaptee = typeof(LegacyClock),
MissingMap = AdapterMissingMapPolicy.ThrowingStub)]
Ignore
Silently ignores unmapped members. May cause compilation errors if the target is an interface (missing implementations):
[GenerateAdapter(
Target = typeof(IPartialService),
Adaptee = typeof(Legacy),
MissingMap = AdapterMissingMapPolicy.Ignore)]
Multiple Adapters
You can define multiple adapters in the same host class:
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient))]
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(PayPalClient))]
public static partial class PaymentAdapters
{
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
public static Task<PaymentResult> MapStripeChargeAsync(StripeClient adaptee, ...) { ... }
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
public static Task<PaymentResult> MapPayPalChargeAsync(PayPalClient adaptee, ...) { ... }
}
The generator matches mapping methods to adapters by the first parameter type (adaptee).
Abstract Class Targets
The generator supports abstract classes as targets:
public abstract class ClockBase
{
public abstract DateTimeOffset Now { get; }
public virtual string TimeZone => "UTC"; // Inherited, not in contract
}
[GenerateAdapter(Target = typeof(ClockBase), Adaptee = typeof(LegacyClock))]
public static partial class Adapters
{
[AdapterMap(TargetMember = nameof(ClockBase.Now))]
public static DateTimeOffset MapNow(LegacyClock adaptee) => ...;
// Only abstract members need mapping
}
Diagnostics
| ID | Severity | Description |
|---|---|---|
| PKADP001 | Error | Adapter host must be static partial |
| PKADP002 | Error | Target must be interface or abstract class |
| PKADP003 | Error | Missing [AdapterMap] for target member |
| PKADP004 | Error | Multiple [AdapterMap] methods for same target member |
| PKADP005 | Error | Mapping method signature doesn't match target member |
| PKADP006 | Error | Adapter type name conflicts with existing type |
| PKADP007 | Error | Adaptee must be a concrete class or struct |
| PKADP008 | Error | Mapping method must be static |
| PKADP009 | Error | Events are not supported |
| PKADP010 | Error | Generic methods are not supported |
| PKADP011 | Error | Overloaded methods are not supported |
| PKADP012 | Error | Abstract class target requires accessible parameterless constructor |
| PKADP013 | Error | Settable properties are not supported |
| PKADP014 | Error | Nested or generic host not supported |
| PKADP015 | Error | Mapping method must be accessible (public or internal) |
| PKADP016 | Error | Static members are not supported |
| PKADP017 | Error | Ref-return members are not supported |
| PKADP018 | Error | Indexers are not supported |
Limitations
Multiple Adapters with Shared Adaptee
When defining multiple [GenerateAdapter] attributes within the same host class that share the same adaptee type, mapping ambiguity can occur. The generator matches [AdapterMap] methods to adapters solely by adaptee type and then by TargetMember name. If two target types have overlapping member names (both use nameof(...) resulting in the same string), mappings become inherently ambiguous, and the generator cannot reliably determine which adapter a mapping belongs to. In this case, PKADP004 duplicate mapping diagnostics are expected given the current API design, rather than being false positives, unless mappings are split into separate hosts or the API is extended to provide additional disambiguation.
Workaround: Define separate host classes for each adapter when they share the same adaptee type:
// ✅ Good: Separate hosts avoid ambiguity
[GenerateAdapter(Target = typeof(IServiceA), Adaptee = typeof(LegacyService))]
public static partial class ServiceAAdapters
{
[AdapterMap(TargetMember = nameof(IServiceA.DoWork))]
public static void MapDoWork(LegacyService adaptee) => adaptee.Execute();
}
[GenerateAdapter(Target = typeof(IServiceB), Adaptee = typeof(LegacyService))]
public static partial class ServiceBAdapters
{
[AdapterMap(TargetMember = nameof(IServiceB.DoWork))]
public static void MapDoWork(LegacyService adaptee) => adaptee.Run();
}
// ⚠️ Problematic: Multiple adapters with same adaptee in one host
public static partial class AllAdapters
{
// Both IServiceA and IServiceB have DoWork() members
// The generator cannot distinguish which mapping is for which target
}
Best Practices
1. Use nameof() for Type Safety
// ✅ Good: Compile-time checked
[AdapterMap(TargetMember = nameof(IClock.Now))]
// ❌ Bad: String literals can drift
[AdapterMap(TargetMember = "Now")]
2. Keep Mapping Methods Simple
Mapping methods should be thin wrappers, not business logic:
// ✅ Good: Simple delegation with conversion
[AdapterMap(TargetMember = nameof(IService.DoWork))]
public static void MapDoWork(Legacy adaptee, string input)
=> adaptee.PerformTask(input);
// ❌ Bad: Business logic in mapping
[AdapterMap(TargetMember = nameof(IService.DoWork))]
public static void MapDoWork(Legacy adaptee, string input)
{
if (string.IsNullOrEmpty(input)) throw new ArgumentException();
var processed = input.ToUpper().Trim();
adaptee.PerformTask(processed);
// This logic should be elsewhere
}
3. Separate Mapping Hosts by Domain
// ✅ Good: Organized by domain
public static partial class PaymentAdapters { ... }
public static partial class LoggingAdapters { ... }
// ❌ Bad: Everything in one place
public static partial class AllAdapters { ... }
4. Document Complex Mappings
/// <summary>
/// Maps the legacy millisecond-based delay to TimeSpan.
/// Note: Precision is limited to milliseconds.
/// </summary>
[AdapterMap(TargetMember = nameof(IClock.DelayAsync))]
public static ValueTask MapDelayAsync(LegacyClock adaptee, TimeSpan duration, CancellationToken ct)
=> new(adaptee.Sleep((int)duration.TotalMilliseconds, ct));
Real-World Example: Payment Gateway Abstraction
// Unified interface for your application
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(string token, decimal amount, string currency, CancellationToken ct);
Task<RefundResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct);
string GatewayName { get; }
}
// Stripe adapter
[GenerateAdapter(Target = typeof(IPaymentGateway), Adaptee = typeof(StripeClient), AdapterTypeName = "StripePaymentAdapter")]
public static partial class StripeAdapters
{
[AdapterMap(TargetMember = nameof(IPaymentGateway.GatewayName))]
public static string MapGatewayName(StripeClient adaptee) => "Stripe";
[AdapterMap(TargetMember = nameof(IPaymentGateway.ChargeAsync))]
public static async Task<PaymentResult> MapChargeAsync(
StripeClient adaptee, string token, decimal amount, string currency, CancellationToken ct)
{
var request = new StripeChargeRequest { Source = token, Amount = (long)(amount * 100), Currency = currency };
var response = await adaptee.CreateChargeAsync(request, ct);
return new PaymentResult(response.Succeeded, response.ChargeId, response.Error);
}
[AdapterMap(TargetMember = nameof(IPaymentGateway.RefundAsync))]
public static async Task<RefundResult> MapRefundAsync(
StripeClient adaptee, string transactionId, decimal amount, CancellationToken ct)
{
var response = await adaptee.CreateRefundAsync(transactionId, (long)(amount * 100), ct);
return new RefundResult(response.Succeeded, response.RefundId, response.Error);
}
}
// Usage with DI
services.AddSingleton<StripeClient>();
services.AddSingleton<IPaymentGateway>(sp => new StripePaymentAdapter(sp.GetRequiredService<StripeClient>()));
See Also
- Facade Generator - For simplifying complex subsystems
- Decorator Generator - For adding behavior to objects
- Proxy Generator - For controlling access to objects