DEV Community

Cover image for The Cross-Assembly Metadata Problem in .NET (And How I Solved It)
bwi
bwi

Posted on

The Cross-Assembly Metadata Problem in .NET (And How I Solved It)

Have you ever built a fluent API where extension methods from different assemblies need to attach metadata to the same object? Or a plugin system where plugins need to add information to host objects without the host knowing about plugin types?

If you have, you've hit the Cross-Assembly Metadata Problem.

It's that moment when you realize:

  • You can't use inheritance (the object is sealed or from a different hierarchy)
  • You can't add fields (the assembly doesn't know about your metadata types)
  • Dictionary<object, object> feels like giving up on type safety
  • Reflection attributes are compile-time only
  • Passing everything through method parameters destroys your fluent API

I hit this wall building Cocoar.Configuration, and it blocked me for several days. None of the "standard" solutions would work without compromising on quality.

So I built Cocoar.Capabilities - a library that implements a pattern I call Capability Composition.

🧠 What Is Cocoar.Capabilities?

Cocoar.Capabilities lets you attach typed metadata ("capabilities") to any object at runtime, retrieve them type-safely, and compose multiple capabilities on the same object - all across assembly boundaries.

Here's what it looks like:

// Create a scope for managing capabilities lifetime
var scope = new CapabilityScope();

// Create a Composer - the fluent builder for attaching capabilities
var composer = scope.For(myObject);

// Use the composer to build up capabilities
composer
    .WithPrimary(new TypeCapability(typeof(AppSettings)))
    .Add(new LifetimeCapability(ServiceLifetime.Singleton))
    .Add(new ExposureCapability(typeof(IAppSettings)))
    .Build();

// Later, retrieve them type-safely
var composition = scope.Compositions.GetRequired(myObject);

if (composition.TryGetPrimaryAs<TypeCapability>(out var typeInfo))
{
    Type concreteType = typeInfo.SelectedType;
}

var lifetimes = composition.GetAll<LifetimeCapability>();
bool hasExposure = composition.Has<ExposureCapability>();
Enter fullscreen mode Exit fullscreen mode

The key insight: Instead of trying to modify objects or use fragile dictionaries, we create a separate capability space - a parallel dimension where metadata lives, indexed by the objects themselves. Think of it as a shadow storage system that mirrors your object graph without touching it.

How Capability Composition Works

The approach is based on three core concepts that work together to maintain this capability space:

1. Capabilities - Typed Metadata Units

A capability can be any object - typically a record or class that represents a piece of information (but it could even be a function - we'll explore that in an upcoming Part):

public record TypeCapability(Type SelectedType);
public record LifetimeCapability(ServiceLifetime Lifetime, string? Key);
public record ExposureCapability(Type ContractType);
Enter fullscreen mode Exit fullscreen mode

No base class required. No interfaces. Just data.

Primary Capabilities: Optionally, you can designate one capability as "primary" - representing the essence of what the object is. If set, only one primary capability is allowed per composition.

scope.For(builder) // creates a Composer
    .WithPrimary(new TypeCapability(typeof(AppSettings)))  // Optional primary
    .Add(new LifetimeCapability(ServiceLifetime.Singleton, null))  // Regular capabilities
    .Add(new LifetimeCapability(ServiceLifetime.Scoped, "special-key"))
    .Add(new ExposureCapability(typeof(IAppSettings)))
    .Build();
Enter fullscreen mode Exit fullscreen mode

2. Composer - The Fluent Builder

The Composer is the fluent builder that lets you attach capabilities to a subject:

var scope = new CapabilityScope();

// Get a composer for your subject
var composer = scope.For(myObject);

// Use the composer to build up capabilities
composer
    .WithPrimary(new TypeCapability(typeof(AppSettings)))
    .Add(new LifetimeCapability(ServiceLifetime.Singleton))
    .Build();  // Creates the immutable Composition
Enter fullscreen mode Exit fullscreen mode

3. CapabilityScope - The Registry Container

The CapabilityScope provides access to the registry that tracks capabilities:

var scope = new CapabilityScope();

// Create composers and build compositions
scope.For(myObject) // creates a Composer
     .Add(capability).Build();

// Retrieve the composition from the registry
var composition = scope.Compositions.Get(myObject);
Enter fullscreen mode Exit fullscreen mode

🔍 The Real Problem: Cocoar.Configuration

Let me show you the actual problem that led to this library. I was building a fluent Configure API for Cocoar.Configuration:

services.AddCocoarConfiguration(configManager, configure =>
{
    configure.ConcreteType<AppSettings>() // From Core assembly
        .AsSingleton()                    // From DI assembly
        .ExposeAs<IAppSettings>();        // From Core assembly

    configure.ExposedType<IAppSettings>() // From DI assembly
        .AsScoped("special-key");         // From DI assembly
});
Enter fullscreen mode Exit fullscreen mode

Looks simple, right? But here's what's happening architecturally:

Assembly 1: Cocoar.Configuration

  • Defines ConcreteConfigBuilder<T>
  • Knows nothing about dependency injection
  • Provides .ExposeAs<T>() method

Assembly 2: Cocoar.Configuration.DI

  • Adds extension method .AsSingleton() to builders from Assembly 1
  • Needs to attach ServiceLifetime metadata to those builders
  • Later needs to retrieve ALL metadata to register services

The Requirements:

  1. .AsSingleton() must attach DI lifetime metadata to the builder
  2. .ExposeAs<T>() must attach interface exposure metadata
  3. Both need to work on the same builder instance
  4. The builder assembly can't know about DI types (no circular dependency)
  5. Everything must be type-safe and discoverable
  6. The fluent API must be preserved

🚫 Why Standard Solutions Failed

❌ Dictionary

static Dictionary<object, object> metadata = new();

public static Builder AsSingleton(this Builder builder)
{
    metadata[builder] = ServiceLifetime.Singleton; // Lost type safety!
    // How to store multiple metadata types?
    // What about thread safety?
    // What about the primary/secondary distinction?
}
Enter fullscreen mode Exit fullscreen mode

Failed because: No type safety, can't compose multiple metadata types, external storage breaks encapsulation.

❌ Reflection Attributes

[Singleton]
[ExposeAs(typeof(IAppSettings))]
public class AppSettings { }
Enter fullscreen mode Exit fullscreen mode

Failed because: Compile-time only, can't configure the same type differently in different contexts, pollutes domain models.

❌ Method Parameters

configure.ConcreteType<AppSettings>(
    lifetime: ServiceLifetime.Singleton,
    exposedAs: typeof(IAppSettings),
    key: "my-key"
);
Enter fullscreen mode Exit fullscreen mode

Failed because: Destroys fluent API, parameter explosion, not extensible from other assemblies.

❌ Builder Internal State

public class ConcreteConfigBuilder<T>
{
    internal ServiceLifetime? Lifetime { get; set; }
    internal List<Type> ExposedAs { get; } = new();
}
Enter fullscreen mode Exit fullscreen mode

Failed because: Core assembly must know about ALL metadata types, creates circular dependencies, can't extend from other assemblies.

I spent several days exploring every alternative which comes into my mind. I have a strict rule: no hacky workarounds - quality is non-negotiable. I kept thinking, iterating through ideas...

Then the design direction finally clicked.

💡 The Solution: Capability Composition

Here's how Cocoar.Capabilities solved it:

Step 1: The Builder Uses CapabilityScope

// In Cocoar.Configuration assembly
public abstract class ConfigBuilder(CapabilityScope capabilityScope)
{
    protected CapabilityScope CapabilityScope { get; } = capabilityScope;

    public static Composer GetComposer(ConfigBuilder builder) =>
        builder.CapabilityScope.Composers.GetRequired(builder);
}

public sealed class ConcreteConfigBuilder<T>(CapabilityScope capabilityScope) 
    : ConfigBuilder(capabilityScope)
{
    internal void Initialize()
    {
        // Attach PRIMARY capability - the type being configured
        CapabilityScope.For(this)
            .WithPrimary(new ConcreteTypePrimary<ConfigBuilder>(typeof(T)));
    }

    public ConcreteConfigBuilder<T> ExposeAs<TInterface>()
    {
        // Attach SECONDARY capability - interface exposure
        GetComposer(this)
            .Add(new ExposeAsCapability<ConfigBuilder>(typeof(TInterface)));
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Extension Methods Can Attach Capabilities

// In Cocoar.Configuration.DI assembly - different project!
public static class ConcreteConfigBuilderExtensions
{
    public static ConcreteConfigBuilder<T> AsSingleton<T>(
        this ConcreteConfigBuilder<T> builder)
    {
        // Extension method from DI assembly can attach DI metadata!
        ConfigBuilder.GetComposer(builder)
            .Add(new ServiceLifetimeCapability<ConfigBuilder>(
                ServiceLifetime.Singleton, null));
        return builder;
    }

    public static ConcreteConfigBuilder<T> AsScoped<T>(
        this ConcreteConfigBuilder<T> builder, 
        string? key = null)
    {
        ConfigBuilder.GetComposer(builder)
            .Add(new ServiceLifetimeCapability<ConfigBuilder>(
                ServiceLifetime.Scoped, key));
        return builder;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Retrieve and Process All Capabilities

Now the DI assembly can harvest all the metadata from the capability space. It iterates through builders, retrieves their compositions, and uses the capabilities to register services - without the Core assembly ever knowing about DI concepts.

// Back in Cocoar.Configuration.DI - registration time
public static IServiceCollection AddCocoarConfiguration(
    this IServiceCollection services, 
    ConfigManager configManager)
{
    var scope = configManager.CapabilityScope;

    foreach (var builder in configManager.Builders)
    {
        // Get the composition (bag of all capabilities)
        if (!scope.Compositions.TryGet(builder, out var composition))
            continue;

        // Get PRIMARY capability - the concrete type
        if (!composition.TryGetPrimaryAs<ConcreteTypePrimary<ConfigBuilder>>(
            out var typeCapability))
            continue;

        var concreteType = typeCapability.SelectedType;

        // Get all SECONDARY capabilities
        var lifetimes = composition.GetAll<ServiceLifetimeCapability<ConfigBuilder>>();
        var exposures = composition.GetAll<ExposeAsCapability<ConfigBuilder>>();

        // Check for special flags
        if (composition.Has<DisableAutoRegistrationCapability<ConfigBuilder>>())
            continue; // Skip this one

        // Register with DI using all the metadata
        foreach (var lifetime in lifetimes)
        {
            services.Add(new ServiceDescriptor(
                concreteType,
                concreteType,
                lifetime.Lifetime));

            foreach (var exposure in exposures)
            {
                services.Add(new ServiceDescriptor(
                    exposure.ContractType,
                    sp => sp.GetRequiredService(concreteType),
                    lifetime.Lifetime));
            }
        }
    }

    return services;
}
Enter fullscreen mode Exit fullscreen mode

Zooming out

While this example shows Cocoar.Configuration's specific use case, the core pattern is what matters. > The capability space acts as a neutral ground where any assembly can attach metadata to any object, and any other assembly can retrieve it - all without coupling. That's the breakthrough.


🧩 What This Achieved

Cross-Assembly Extensibility: DI assembly extends Core builders without coupling

Type Safety: All capabilities are strongly typed, compile-time safe

Fluent API Preserved: Chaining works perfectly

configure.ConcreteType<AppSettings>()
    .AsSingleton()           // From DI assembly
    .ExposeAs<IAppSettings>() // From Core assembly
    .AsScoped("special");     // Multiple lifetimes!
Enter fullscreen mode Exit fullscreen mode

Composable: Multiple capabilities coexist on the same object

Discoverable: IntelliSense shows all available capabilities

No Circular Dependencies: Core knows nothing about DI, DI extends Core

The Prototype That Proved It

When I first built this embedded in Cocoar.Configuration, I knew I had something solid.

As I continued building other Cocoar libraries, I realized: I could use this approach everywhere - for plugin systems, ORMs, middleware builders, and more.

That's when I made the decision to extract it into a dedicated library. The extraction required deliberate engineering work:

  • Generalizing it to handle different subject types (primitives, enums, functions)
  • Ensuring thread safety with concurrent collections
  • Designing the key mapping system for custom equality semantics
  • Making it truly generic and reusable across different scenarios

Only after extracting it did I learn about the Role Object Pattern - I'd independently arrived at something similar, adapted for .NET's cross-assembly scenarios and extended to work with value types.

🚀 One More Quick Example

Here's how the same pattern solves enum display logic - a common pain point:

The Problem: You have error codes from a third-party library and need to attach user-friendly messages without modifying the enum.

// Can't modify the enum - it's from a third-party library
public enum ErrorCode { InvalidInput, Timeout, Unauthorized }

// Define capability records
public record DisplayCapability(string Message);
public record SeverityCapability(LogLevel Level);

// Attach display metadata via capabilities
scope.For(ErrorCode.InvalidInput)
    .Add(new DisplayCapability("Invalid input provided"))
    .Add(new SeverityCapability(LogLevel.Warning))
    .Build();

scope.For(ErrorCode.Timeout)
    .Add(new DisplayCapability("Request timed out"))
    .Add(new SeverityCapability(LogLevel.Error))
    .Build();

// Later, retrieve and display
var composition = scope.Compositions.Get(ErrorCode.InvalidInput);
var display = composition?.GetFirst<DisplayCapability>();
var severity = composition?.GetFirst<SeverityCapability>();

Console.WriteLine($"[{severity?.Level}] {display?.Message}");
// Output: [Warning] Invalid input provided
Enter fullscreen mode Exit fullscreen mode

Why it works: Capabilities let you attach metadata to types you can't modify - enums, sealed classes, third-party types - all across assembly boundaries.

(We'll explore more scenarios - plugins, multi-tenancy, ORMs, and beyond - in upcoming parts of this series.)

🧭 What's Next

This is Part 1 in a series on Capability Composition. Here's what's coming:

  • Part 1 (this): The problem and the solution ✅
  • Part 2: Core concepts deep dive - API, composition patterns, attach/retrieve
  • Part 3: Beyond objects - primitives, enums, functions, and custom key mapping
  • More parts: Advanced patterns, real-world implementations, and lessons learned

Try It Yourself

GitHub: github.com/cocoar-dev/Cocoar.Capabilities
NuGet: Cocoar.Capabilities
Real-world example: Cocoar.Configuration

Have you hit this wall? If you've ever struggled with cross-assembly metadata, plugin architectures, or fluent API extensibility, I'd love to hear about it. Drop a comment with the specific architectural problem you faced - maybe Capability Composition is the solution you need.


This library was born from a real architectural problem that had no satisfactory solution. It's not just cool tech - it's battle-tested in production, solving real problems in Cocoar.Configuration. And I think it can solve yours too.

Top comments (0)