DEV Community

Cover image for Building with Capabilities: The Core API
bwi
bwi

Posted on

Building with Capabilities: The Core API

In Part 1, we explored the Cross-Assembly Metadata Problem and saw how Capability Composition solves it by creating a separate "capability space" where metadata lives independently from objects.

When I first show developers the solution, I often hear: "This looks clean, but how does it actually work? What am I really doing when I call these methods?"

That's what this article is about. We're going to understand the three core concepts that make everything work: CapabilityScope, Composer, and Composition.

Think of them like this:

  • CapabilityScope is the world where capabilities exist
  • Composer is the tool you use to attach capabilities to something
  • Composition is the final result - an immutable bag of capabilities you can query

By the end, you'll have a solid mental model of how it all fits together.


The Complete Picture (in 30 seconds)

Before diving into each piece, let's see them all working together:

// 1. Create a scope - the "world" for capabilities
var scope = new CapabilityScope();

var document = new Document("README.md");

// 2. Use a Composer to attach capabilities
var composition = scope.For(document)              // Get a Composer
    .Add(new EditCapability())                     // Attach capabilities
    .Add(new PrintCapability())
    .Add(new ShareCapability())
    .Build();                                       // Creates immutable Composition

// 3. Query the Composition later
if (composition.Has<PrintCapability>())
{
    var printCap = composition.GetFirstOrDefault<PrintCapability>();
    printCap?.Print(document);
}
Enter fullscreen mode Exit fullscreen mode

That's it. Now let's understand what's happening under the hood.


1. Composer: The Fluent Builder

Let's start with the most hands-on part - the thing you interact with most: Composer.

A Composer is a fluent builder that lets you attach capabilities to a subject. You get one by calling scope.For(subject):

var composer = scope.For(myDocument);
Enter fullscreen mode Exit fullscreen mode

Now you have a tool for building up what this document "knows" or "can do".

Adding Capabilities

The simplest operation - just attach a capability:

scope.For(document)
    .Add(new EditCapability())
    .Add(new PrintCapability())
    .Build();
Enter fullscreen mode Exit fullscreen mode

Capabilities can be any object - records, classes, interfaces, even functions (we'll explore that in Part 3). No base class required, no magic attributes.

Here's the key insight: This means you can add any data or functionality to an object without modifying its structure.

Need to track audit information? Add an AuditCapability. New feature requires tenant context? Add a TenantCapability. Third-party API adds a new status? Add a StatusMetadataCapability.

You don't have to add a new property to the class just because a feature needs it. Instead, you attach a capability. The object stays clean, focused on its core responsibility, while capabilities carry the cross-cutting concerns, metadata, and feature-specific data.

This is especially powerful when:

  • You can't modify the class (sealed, third-party, generated code)
  • You don't want to pollute domain models with infrastructure concerns
  • Different features need different data about the same objects
  • You want to add functionality across assembly boundaries

Primary Capabilities

Sometimes you want to say "this is THE main capability - the identity of this subject". That's what primary capabilities are for:

public record UserIdentityCapability(string UserId, string Name) : IPrimaryCapability;

scope.For(user)
    .WithPrimary(new UserIdentityCapability("user123", "Alice"))
    .Add(new RoleCapability("Admin"))
    .Add(new EmailCapability("alice@example.com"))
    .Build();
Enter fullscreen mode Exit fullscreen mode

Rules:

  • Optional - you don't need a primary capability
  • Only ONE primary per composition
  • Must implement the marker interface IPrimaryCapability
  • Trying to add a second primary throws an exception

Why use primary capabilities?

Think of it like this: if you had to describe what this composition is in one sentence, that's your primary capability. Everything else is secondary information.

// Later, query the primary
var composition = scope.Compositions.Get(user);
if (composition.TryGetPrimaryAs<UserIdentityCapability>(out var identity))
{
    Console.WriteLine($"User: {identity.Name}");
}
Enter fullscreen mode Exit fullscreen mode

Multiple Contracts with AddAs<>

Here's something powerful: register capabilities under contract types (interfaces) to group and discover them later.

// Define a common interface for grouping
public interface INotificationProvider
{
    Task SendNotification(string message);
}

// Different notification implementations
public class EmailNotifier : INotificationProvider { /* ... */ }
public class SmsNotifier : INotificationProvider { /* ... */ }
public class PushNotifier : INotificationProvider { /* ... */ }

// Register each under the same interface
scope.For("notification-system")
    .AddAs<INotificationProvider>(new EmailNotifier())
    .AddAs<INotificationProvider>(new SmsNotifier())
    .AddAs<INotificationProvider>(new PushNotifier())
    .Build();

// Later, get ALL notification providers and invoke them!
var comp = scope.Compositions.Get("notification-system");
var notifiers = comp.GetAll<INotificationProvider>();

foreach (var notifier in notifiers)
{
    await notifier.SendNotification("Hello!");
}
Enter fullscreen mode Exit fullscreen mode

This is one of the key features: You can add as many capabilities as you want under the same contract type, then retrieve them all as a group. It's perfect for plugin systems, notification systems, validation pipelines, event handlers, and more.

Multiple contracts for one instance:

You can also register a single instance under multiple contracts:

public class EmailService : IEmailSender, INotificationProvider, IAuditable
{
    // Implementation
}

var emailService = new EmailService();

scope.For("email-service")
    .AddAs<(IEmailSender, INotificationProvider, IAuditable)>(emailService)
    .Build();

// Query by any interface - all return the SAME instance
var comp = scope.Compositions.Get("email-service");
var sender = comp.GetFirstOrDefault<IEmailSender>();
var notifier = comp.GetFirstOrDefault<INotificationProvider>();
var auditable = comp.GetFirstOrDefault<IAuditable>();

// sender, notifier, and auditable are the SAME object
Enter fullscreen mode Exit fullscreen mode

When to use this:

  • Grouping: Add multiple capabilities under a shared interface for batch processing
  • Multi-contract: Register one instance under multiple interfaces it implements
  • Discovery: Find all capabilities by contract type
  • No boilerplate registration code

Try-Add Pattern

Sometimes you want to add a capability only if it doesn't already exist:

scope.For(document)
    .Add(new ValidationCapability())           // Always added
    .TryAdd(new DefaultSettingsCapability())   // Only if not already present
    .Build();
Enter fullscreen mode Exit fullscreen mode

This is useful for default values that should be overridable.

Checking Before Building

You can query the composer before building:

var composer = scope.For(document)
    .Add(new EditCapability());

if (!composer.Has<PrintCapability>())
{
    composer.Add(new PrintCapability());
}

var composition = composer.Build();
Enter fullscreen mode Exit fullscreen mode

Building the Composition

When you call .Build(), you get an immutable composition:

var composition = composer.Build();
Enter fullscreen mode Exit fullscreen mode

Important: Once built, the composer is finished. You can't add more capabilities to it. If you need to modify, you use recomposition (covered in Part 3).


2. Composition: The Immutable Result

A Composition is an immutable, thread-safe collection of all capabilities attached to a subject. Think of it as a sealed envelope - once created, it can't change.

Querying Capabilities

The most common operations:

// Check if a capability exists
if (composition.Has<EditCapability>())
{
    Console.WriteLine("Can edit!");
}

// Get the first capability of a type (most common when you expect only one)
var editor = composition.GetFirstOrDefault<EditCapability>();
if (editor != null)
{
    editor.Edit(document);
}

// Get all capabilities of a type
var validators = composition.GetAll<ValidationCapability>();
foreach (var validator in validators)
{
    validator.Validate(document);
}

// Count how many
int validatorCount = composition.Count<ValidationCapability>();
Enter fullscreen mode Exit fullscreen mode

Try Pattern

If you prefer the TryGet pattern:

if (composition.TryGetFirst<EditCapability>(out var editor))
{
    editor.Edit(document);
}
Enter fullscreen mode Exit fullscreen mode

Required Pattern

When a capability MUST exist:

// Throws InvalidOperationException if not found
var editor = composition.GetRequiredFirst<EditCapability>();
editor.Edit(document);
Enter fullscreen mode Exit fullscreen mode

Working with Primary Capabilities

// Check if primary exists
if (composition.HasPrimary())
{
    var primary = composition.GetPrimary();
    Console.WriteLine($"Primary: {primary}");
}

// Type-safe access
if (composition.TryGetPrimaryAs<UserIdentityCapability>(out var user))
{
    Console.WriteLine($"User: {user.Name}");
}

// Required access (throws if not found or wrong type)
var identity = composition.GetRequiredPrimaryAs<UserIdentityCapability>();
Enter fullscreen mode Exit fullscreen mode

Immutability & Thread Safety

This is crucial to understand:

// Composition is immutable - this is safe
var composition = scope.For(document)
    .Add(new EditCapability())
    .Build();

// Multiple threads can query simultaneously - no locks needed
Task.Run(() => 
{
    var editor = composition.GetFirstOrDefault<EditCapability>();
});

Task.Run(() => 
{
    var hasEdit = composition.Has<EditCapability>();
});
Enter fullscreen mode Exit fullscreen mode

Key insight: Because compositions are immutable, you can safely share them across threads without synchronization. This is by design.


3. CapabilityScope: The Foundation

Now that you understand Composer (the builder) and Composition (the result), let's talk about CapabilityScope - the foundation that ties everything together.

Why Do You Need a Scope?

You might be thinking: "Why can't I just create Composers directly? Why the extra layer?"

Answer: The scope provides two crucial things:

  1. Context Isolation - Different scopes are different "worlds"
  2. Optional Registries - Lookup capabilities by subject later

Let's explore both.

Context Isolation

Each scope is its own isolated world:

var scope1 = new CapabilityScope();
var scope2 = new CapabilityScope();

// Same subject, different scopes = completely separate capabilities
scope1.For("config")
    .Add(new LanguageCapability("English"))
    .Build();

scope2.For("config")
    .Add(new LanguageCapability("German"))
    .Build();

// Each scope has its own version
var comp1 = scope1.Compositions.Get("config"); // English
var comp2 = scope2.Compositions.Get("config"); // German
Enter fullscreen mode Exit fullscreen mode

When is this useful?

  • Multi-tenant applications - Each tenant gets its own scope
  • Test isolation - Each test gets its own scope
  • Request contexts - Each request gets its own capability space

Optional Registries

Here's where it gets interesting. By default, when you build a composition, it's automatically registered in the scope so you can look it up later:

var scope = new CapabilityScope();

// Build and it's automatically registered
scope.For(document)
    .Add(new EditCapability())
    .Build();

// Later, anywhere in your code
var composition = scope.Compositions.Get(document);
if (composition != null)
{
    var editor = composition.GetFirstOrDefault<EditCapability>();
}
Enter fullscreen mode Exit fullscreen mode

This is incredibly convenient - build capabilities in one place, use them in another. No need to pass compositions through method parameters.

But what if you don't need registries?

You can disable them:

var scope = new CapabilityScope(new CapabilityScopeOptions
{
    UseCompositionRegistry = false
});

// Now compositions are NOT registered
var composition = scope.For(document)
    .Add(new EditCapability())
    .Build();

// You must pass the composition directly
ProcessDocument(document, composition);
Enter fullscreen mode Exit fullscreen mode

When to disable registries:

  • You build and use capabilities in the same method
  • You want absolute minimal overhead
  • You prefer explicit data flow

When to keep registries enabled (default):

  • You build in one place, use in another
  • You want lookup-by-subject convenience
  • You're okay with the tiny overhead (it's negligible)

The Two Registries

Actually, there are TWO independent registries, and both are enabled by default:

  1. Composition Registry - Stores finished compositions (enabled by default)
  2. Composer Registry - Stores composers so you can continue building (enabled by default)
// Default: Both registries enabled
var scope = new CapabilityScope();

// Explicitly configure if needed
var customScope = new CapabilityScope(new CapabilityScopeOptions
{
    UseComposerRegistry = true,      // Default: enabled
    UseCompositionRegistry = true    // Default: enabled
});

// With Composer Registry enabled (default), you can continue building:
scope.For(document)
    .Add(new EditCapability())
    // Don't call Build() yet

// Later, continue building the same composer
var composer = scope.Composers.Get(document);
if (composer != null)
{
    composer.Add(new PrintCapability()).Build();
}
Enter fullscreen mode Exit fullscreen mode

Scope Lifetime

Here's an important pattern: scopes are typically long-lived.

public class Application
{
    // Scope lives for the entire application lifetime
    private readonly CapabilityScope _scope = new();

    public void Initialize()
    {
        // Build capabilities during startup
        _scope.For(ErrorCode.Timeout)
            .Add(new MessageCapability("Request timed out"))
            .Build();
    }

    public void ProcessError(ErrorCode code)
    {
        // Use capabilities anywhere in the application
        var comp = _scope.Compositions.Get(code);
        var message = comp?.GetFirst<MessageCapability>();
        Console.WriteLine(message?.Text);
    }
}
Enter fullscreen mode Exit fullscreen mode

You typically only use using var scope = new CapabilityScope() for short-lived, isolated contexts like per-test or per-request scopes.

Reference Types vs Value Types

One more thing that's crucial to understand: the registry behaves differently for reference types and value types.

Reference Types (classes, objects):

var document = new Document("README.md"); // Reference type

scope.For(document)
    .Add(new EditCapability())
    .Build();

// Later, if document is garbage collected...
document = null;
GC.Collect();

// The registry entry is AUTOMATICALLY removed!
// No memory leak, no manual cleanup needed
Enter fullscreen mode Exit fullscreen mode

This uses ConditionalWeakTable internally, which holds weak references.

Value Types (enums, primitives, strings):

scope.For(ErrorCode.Timeout)  // Enum
    .Add(new MessageCapability("Timed out"))
    .Build();

scope.For("config-key")  // String
    .Add(new ValidationCapability())
    .Build();

// These stay in the registry PERMANENTLY (until scope is disposed)
Enter fullscreen mode Exit fullscreen mode

This uses ConcurrentDictionary internally.

Why is this important?

Because value types are stable identifiers - perfect for application-wide metadata like error messages, display names, and configuration. You WANT them to persist for the lifetime of the application.

// This is a feature, not a bug!
_scope.For(ErrorCode.InvalidInput)
    .Add(new DisplayCapability("Invalid input provided"))
    .Add(new SeverityCapability(LogLevel.Warning))
    .Build();

// Available everywhere, always - exactly what you want
Enter fullscreen mode Exit fullscreen mode

4. Advanced Features

Now that you understand the core concepts, let's look at two powerful features that extend what you can do with capabilities.

Recomposition: Modifying Existing Compositions

The Scenario: You build a composition early in a workflow, then need to add more capabilities as you learn more about the context.

Remember: Compositions are immutable. You can't modify them directly. Instead, you create a new composition based on the old one. When you call .Build(), the new composition replaces the old one in the registry (for the same subject).

// Initial composition during authentication
var initialComposition = scope.For(request)
    .WithPrimary(new RequestCapability(requestId))
    .Add(new AuthenticationCapability(userId))
    .Build();  // Stored in registry for 'request'

// Later, after authorization check - recompose from the existing one
var authorizedComposition = scope.Recompose(initialComposition)
    .Add(new RoleCapability("Admin"))
    .Add(new PermissionCapability("CanDelete"))
    .Build();  // REPLACES the previous composition in the registry

// Even later, after tenant identification
var finalComposition = scope.Recompose(authorizedComposition)
    .Add(new TenantCapability(tenantId))
    .Build();  // REPLACES again - finalComposition is now in the registry

// Now when you look up by subject:
var current = scope.Compositions.Get(request);  // Returns finalComposition
// The earlier compositions (initialComposition, authorizedComposition) 
// still exist as variables, but they're no longer in the registry
Enter fullscreen mode Exit fullscreen mode

Key insight: The old composition objects still exist (immutability), but the registry now points to the new one. This means:

  • ✅ Other code can keep using old composition references safely
  • ✅ The registry always has the latest version for lookups
  • ✅ You get both immutability AND the ability to "update" what's registered

Common Patterns:

// Conditional enrichment
var composition = scope.For(user).Add(new BasicUserCapability()).Build();

if (userIsAdmin)
{
    composition = scope.Recompose(composition)
        .Add(new AdminCapability())
        .Build();
}

// Replacing primary capability
var guestComposition = scope.For(session)
    .WithPrimary(new GuestCapability(sessionId))
    .Build();

// Later, upgrade to registered user
var userComposition = scope.Recompose(guestComposition)
    .WithPrimary(new RegisteredUserCapability(userId, username))
    .Build();
Enter fullscreen mode Exit fullscreen mode

Note: When you use WithPrimary() during recomposition, it replaces the existing primary capability.

Custom Key Mapping

For advanced scenarios, you can customize how subjects are used as registry keys.

Why you'd need this:

  • Normalize keys (case-insensitive strings, trimmed whitespace)
  • Make reference types persist in the registry like value types
  • Define custom equality for complex objects

Example: Case-Insensitive Email Lookup

public class EmailSubjectKeyMapper : ISubjectKeyMapper
{
    public bool CanHandle(Type subjectType) 
        => subjectType == typeof(string);

    public object Map(object subject)
    {
        var email = (string)subject;

        if (email.Contains('@'))
        {
            // Normalize emails to lowercase
            return new EmailSubjectKey(email.ToLowerInvariant());
        }

        // Not an email - use default string handling
        return new StringSubjectKey(email);
    }
}

public readonly record struct EmailSubjectKey(string Email);

// Register the mapper when creating the scope
var scope = new CapabilityScope(new CapabilityScopeOptions
{
    SubjectKeyMappers = new[] { new EmailSubjectKeyMapper() }
});

// Now lookups are case-insensitive!
scope.For("USER@Example.COM")
    .Add(new EmailCapability())
    .Build();

var comp = scope.Compositions.Get("user@example.com"); // ✓ Found!
Enter fullscreen mode Exit fullscreen mode

When to use custom mappers:

  • Normalize string keys (case, whitespace, encoding)
  • Entity ID-based lookups (different object instances, same ID)
  • Make URIs, file paths, or other reference types act like value types

Important: Custom mappers are an advanced feature. Most applications won't need them. Start with the defaults and only add mappers when you have a specific normalization or equality need.


Putting It All Together

Let's see a complete example that uses everything we've learned:

public record DocumentTypeCapability(string FileType) : IPrimaryCapability;
public record PermissionCapability(string Action, string Role);
public record ValidationCapability(Func<Document, bool> Validator);

public class DocumentService
{
    private readonly CapabilityScope _scope = new();

    // Register a document with capabilities
    public void RegisterDocument(Document document)
    {
        _scope.For(document)
            .WithPrimary(new DocumentTypeCapability(document.Extension))
            .Add(new PermissionCapability("Read", "User"))
            .Add(new PermissionCapability("Write", "Admin"))
            .Add(new ValidationCapability(doc => doc.Size < 10_000_000))
            .Build();
    }

    // Check permissions in one method
    public bool CanUserPerform(Document document, string action, string role)
    {
        var composition = _scope.Compositions.Get(document);
        if (composition == null) return false;

        var permissions = composition.GetAll<PermissionCapability>();
        return permissions.Any(p => p.Action == action && p.Role == role);
    }

    // Validate in another method
    public bool ValidateDocument(Document document)
    {
        var composition = _scope.Compositions.Get(document);
        if (composition == null) return false;

        var validators = composition.GetAll<ValidationCapability>();
        return validators.All(v => v.Validator(document));
    }

    // Get document info in yet another method
    public string GetDocumentType(Document document)
    {
        var composition = _scope.Compositions.Get(document);

        if (composition?.TryGetPrimaryAs<DocumentTypeCapability>(out var docType) == true)
        {
            return docType.FileType;
        }

        return "Unknown";
    }
}
Enter fullscreen mode Exit fullscreen mode

What makes this clean:

✅ Capabilities built once in RegisterDocument
✅ Retrieved independently in different methods
✅ No need to pass compositions around
✅ Each method focuses on one concern
✅ Type-safe queries throughout


Quick Reference

CapabilityScope

var scope = new CapabilityScope();                    // Create (registries enabled)
var composer = scope.For(subject);                    // Get composer
var composition = scope.Compositions.Get(subject);    // Retrieve composition
scope.Dispose();                                      // Cleanup (rarely needed)

// Advanced: Custom key mapping
var scope = new CapabilityScope(new CapabilityScopeOptions
{
    SubjectKeyMappers = new[] { new MyMapper() }      // Custom key normalization
});
Enter fullscreen mode Exit fullscreen mode

Composer

scope.For(subject)                                    // Get composer
    .WithPrimary(primaryCapability)                   // Set primary (optional)
    .Add(capability)                                  // Add capability
    .AddAs<(I1, I2)>(instance)                        // Add with multiple contracts
    .TryAdd(capability)                               // Add if not exists
    .Build();                                         // Create composition

scope.Recompose(existingComposition)                  // Recompose existing
    .Add(newCapability)                               // Add more capabilities
    .Build();                                         // Replace in registry
Enter fullscreen mode Exit fullscreen mode

Composition

composition.Has<TCapability>()                        // Check existence
composition.GetFirstOrDefault<T>()                    // Get first or null
composition.GetAll<T>()                               // Get all
composition.Count<T>()                                // Count
composition.TryGetFirst<T>(out var cap)               // Try pattern
composition.GetRequiredFirst<T>()                     // Get or throw

composition.HasPrimary()                              // Check primary
composition.TryGetPrimaryAs<T>(out var primary)       // Try get primary
composition.GetRequiredPrimaryAs<T>()                 // Get primary or throw
Enter fullscreen mode Exit fullscreen mode

What's Next

You now understand the core API - CapabilityScope, Composer, and Composition - including advanced features like recomposition and custom key mapping. You can build, extend, and query capabilities effectively.

In Part 3, we'll explore the power patterns that make this library shine in real-world scenarios:

  • Framework-free pipelines with ordered execution
  • Enum and primitive enrichment with metadata
  • Named constants with behavior for feature flags
  • Multi-tenant context management
  • Plugin systems with dynamic capability discovery

These patterns show where Capability Composition really differentiates itself from traditional approaches.


Try It Yourself

GitHub: github.com/cocoar-dev/Cocoar.Capabilities
NuGet: Cocoar.Capabilities
Example: Cocoar.Configuration (real-world usage)

Questions? Drop a comment - I'd love to hear how you're thinking about using capabilities in your projects!


This is Part 2 in the Capability Composition series.

← Part 1: The Cross-Assembly Metadata Problem

Top comments (0)