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>();
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);
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();
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
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);
🔍 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
});
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:
-
.AsSingleton()
must attach DI lifetime metadata to the builder -
.ExposeAs<T>()
must attach interface exposure metadata - Both need to work on the same builder instance
- The builder assembly can't know about DI types (no circular dependency)
- Everything must be type-safe and discoverable
- 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?
}
Failed because: No type safety, can't compose multiple metadata types, external storage breaks encapsulation.
❌ Reflection Attributes
[Singleton]
[ExposeAs(typeof(IAppSettings))]
public class AppSettings { }
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"
);
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();
}
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;
}
}
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;
}
}
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;
}
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!
✅ 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
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)