Skip to main content

Command Palette

Search for a command to run...

Component-Based Capability Pattern: A Better Way to Handle Platform Differences

Updated
9 min read

The Problem We Keep Running Into

Building a system that talks to multiple platforms? You've probably written code like this:

public class Platform
{
    public string Name { get; set; }

    // Text stuff
    public bool SupportsText { get; set; }
    public int MaxTextLength { get; set; }
    public bool SupportsMarkdown { get; set; }
    public bool SupportsHtml { get; set; }

    // Voice stuff
    public bool SupportsVoice { get; set; }
    public int MaxVoiceDuration { get; set; }
    public string[] SupportedVoiceCodecs { get; set; }

    // Files
    public bool SupportsFiles { get; set; }
    public long MaxFileSize { get; set; }
    public string[] AllowedMimeTypes { get; set; }

    // Reactions
    public bool SupportsReactions { get; set; }
    public string[] AllowedReactions { get; set; }

    // ...and it keeps growing
}

This works at first. Then it becomes a nightmare. You end up with 50+ properties, half of them nullable, and you're never quite sure which combinations are valid. SupportsVoice = true but SupportedVoiceCodecs = null? That's a runtime bug waiting to happen.

The real pain hits when you're trying to figure out what a platform can actually do. You scan through dozens of boolean flags, hoping you didn't miss one. Your validation logic is scattered everywhere. Testing means setting up massive mock objects.

What If We Treated Capabilities as Components?

Here's a different approach. Instead of boolean flags with loosely-related configuration, we make each capability its own thing - a well-defined interface that either exists or doesn't.

The idea is straightforward:

  • Each capability is an interface with its own rules

  • Platforms compose themselves by adding capabilities they support

  • Your code asks "do you have X?" before trying to use it

  • Everything is type-safe and the compiler helps you

Let's build it.

Defining Capabilities

Start with interfaces for each capability:

public interface IPlatformCapability 
{
    string CapabilityName { get; }
}

public interface ITextMessageCapability : IPlatformCapability
{
    int MaxLength { get; }
    bool SupportsMarkdown { get; }
    bool SupportsHtml { get; }
}

public interface IVoiceMessageCapability : IPlatformCapability
{
    int MaxDurationSeconds { get; }
    List<string> SupportedCodecs { get; }
}

public interface IFileUploadCapability : IPlatformCapability
{
    long MaxFileSizeBytes { get; }
    List<string> AllowedMimeTypes { get; }
}

public interface IReactionCapability : IPlatformCapability
{
    List<string> AllowedReactions { get; }
    int MaxReactionsPerMessage { get; }
}

Each interface groups related properties together. If you have IVoiceMessageCapability, you know you'll get MaxDurationSeconds and SupportedCodecs - they come as a package.

Platform-Specific Implementations

Now implement these for each platform. Here's where it gets interesting - you can add platform-specific properties that go beyond the interface:

public class WhatsAppTextCapability : ITextMessageCapability
{
    public string CapabilityName => "Text";
    public int MaxLength => 4096;
    public bool SupportsMarkdown => true;
    public bool SupportsHtml => false;

    // WhatsApp-specific stuff
    public List<string> SupportedMentionTypes => new() { "@user", "@all" };
}

public class WhatsAppVoiceCapability : IVoiceMessageCapability
{
    public string CapabilityName => "Voice";
    public int MaxDurationSeconds => 900;
    public List<string> SupportedCodecs => new() { "opus", "aac" };

    // WhatsApp does auto-transcription
    public bool AutoTranscriptionAvailable => true;
}

public class TelegramTextCapability : ITextMessageCapability
{
    public string CapabilityName => "Text";
    public int MaxLength => 4096;
    public bool SupportsMarkdown => true;
    public bool SupportsHtml => true; // Telegram supports HTML!

    // Telegram-specific
    public List<string> SupportedEntityTypes => new() 
    { 
        "mention", "hashtag", "url", "bot_command" 
    };
}

public class TelegramFileCapability : IFileUploadCapability
{
    public string CapabilityName => "FileUpload";
    public long MaxFileSizeBytes => 2L * 1024 * 1024 * 1024; // 2GB
    public List<string> AllowedMimeTypes => new() { "*/*" };

    public bool SupportsFileStreaming => true;
}

public class SmsTextCapability : ITextMessageCapability
{
    public string CapabilityName => "Text";
    public int MaxLength => 160;
    public bool SupportsMarkdown => false;
    public bool SupportsHtml => false;

    public bool AutoSplitLongMessages => true;
    public int MaxConcatenatedSegments => 10;
}

Notice how SMS doesn't have voice or file capabilities - it just doesn't implement those interfaces. That's the point.

The Platform Class

This is where capabilities live:

public class Platform
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Provider { get; set; }

    private readonly Dictionary<Type, IPlatformCapability> _capabilities = new();

    public void AddCapability<T>(T capability) where T : IPlatformCapability
    {
        _capabilities[typeof(T)] = capability;
    }

    public bool HasCapability<T>() where T : IPlatformCapability
    {
        return _capabilities.ContainsKey(typeof(T));
    }

    public T GetCapability<T>() where T : IPlatformCapability
    {
        return _capabilities.TryGetValue(typeof(T), out var capability) 
            ? (T)capability 
            : default;
    }

    public TConcrete GetConcreteCapability<TConcrete>() 
        where TConcrete : class, IPlatformCapability
    {
        return _capabilities.Values.OfType<TConcrete>().FirstOrDefault();
    }
}

Pretty simple. It's just a type-safe registry of capabilities.

Setting Up Platforms

public class PlatformFactory
{
    public Platform CreateWhatsApp()
    {
        var platform = new Platform 
        { 
            Id = Guid.NewGuid(),
            Name = "WhatsApp Business",
            Provider = "whatsapp"
        };

        platform.AddCapability(new WhatsAppTextCapability());
        platform.AddCapability(new WhatsAppVoiceCapability());
        platform.AddCapability(new WhatsAppReactionCapability());
        platform.AddCapability(new WhatsAppFileCapability());

        return platform;
    }

    public Platform CreateTelegram()
    {
        var platform = new Platform 
        { 
            Id = Guid.NewGuid(),
            Name = "Telegram Bot",
            Provider = "telegram"
        };

        platform.AddCapability(new TelegramTextCapability());
        platform.AddCapability(new TelegramFileCapability());
        // No reactions - Telegram doesn't support them

        return platform;
    }

    public Platform CreateSMS()
    {
        var platform = new Platform 
        { 
            Id = Guid.NewGuid(),
            Name = "SMS Gateway",
            Provider = "sms"
        };

        platform.AddCapability(new SmsTextCapability());
        // Just text, nothing else

        return platform;
    }
}

Look how clear this is. You can see exactly what each platform supports.

Using It

Here's where it pays off:

public class MessageService
{
    public async Task<Result> SendTextMessage(Platform platform, string text)
    {
        // Check if the platform can do this
        if (!platform.HasCapability<ITextMessageCapability>())
        {
            return Result.Fail($"{platform.Name} doesn't support text messages");
        }

        // Get the capability - it's never null here
        var textCap = platform.GetCapability<ITextMessageCapability>();

        // Validate
        if (text.Length > textCap.MaxLength)
        {
            return Result.Fail($"Text too long (max {textCap.MaxLength} characters)");
        }

        // Handle markdown
        var processedText = textCap.SupportsMarkdown 
            ? ProcessMarkdown(text) 
            : StripMarkdown(text);

        var handler = GetHandler(platform.Provider);
        return await handler.SendTextAsync(processedText);
    }

    public async Task<Result> SendVoiceMessage(
        Platform platform,
        Stream audioStream,
        string codec)
    {
        if (!platform.HasCapability<IVoiceMessageCapability>())
        {
            return Result.Fail($"{platform.Name} doesn't support voice");
        }

        var voiceCap = platform.GetCapability<IVoiceMessageCapability>();

        if (!voiceCap.SupportedCodecs.Contains(codec))
        {
            return Result.Fail(
                $"Codec '{codec}' not supported. Use: {string.Join(", ", voiceCap.SupportedCodecs)}");
        }

        var handler = GetHandler(platform.Provider);
        return await handler.SendVoiceAsync(audioStream, codec);
    }

    public async Task<Result> AddReaction(
        Platform platform,
        string messageId,
        string emoji)
    {
        if (!platform.HasCapability<IReactionCapability>())
        {
            return Result.Fail($"{platform.Name} doesn't support reactions");
        }

        var reactionCap = platform.GetCapability<IReactionCapability>();

        if (!reactionCap.AllowedReactions.Contains(emoji))
        {
            return Result.Fail(
                $"Emoji '{emoji}' not allowed on {platform.Name}");
        }

        var handler = GetHandler(platform.Provider);
        return await handler.AddReactionAsync(messageId, emoji);
    }
}

The pattern is consistent: check for capability, get it, validate with it, delegate to handler.

When You Need Platform-Specific Stuff

Sometimes you need to access properties that only exist on a specific platform:

public async Task<Result> SendWithAutoTranscription(
    Platform platform,
    Stream voiceStream)
{
    // First check the interface
    if (!platform.HasCapability<IVoiceMessageCapability>())
    {
        return Result.Fail("Voice not supported");
    }

    // Get the concrete WhatsApp implementation
    var whatsappVoice = platform.GetConcreteCapability<WhatsAppVoiceCapability>();

    if (whatsappVoice == null)
    {
        return Result.Fail("Auto-transcription only available on WhatsApp");
    }

    if (!whatsappVoice.AutoTranscriptionAvailable)
    {
        return Result.Fail("Auto-transcription not available");
    }

    // Use WhatsApp-specific feature
    return await SendWithTranscription(voiceStream);
}

Comparing Approaches

Let's see how this stacks up against the traditional ways.

Feature Flags (The Common Way)

public class Platform
{
    public bool SupportsVoice { get; set; }
    public int MaxVoiceDuration { get; set; }
    public string[] SupportedVoiceCodecs { get; set; }
    // ... 50 more properties
}

// Using it
if (!platform.SupportsVoice)
    return Result.Fail("Not supported");

if (platform.MaxVoiceDuration <= 0)
    return Result.Fail("Invalid config");

if (platform.SupportedVoiceCodecs == null || platform.SupportedVoiceCodecs.Length == 0)
    return Result.Fail("No codecs configured");

Problems:

  • Properties can be inconsistent (SupportsVoice = true but SupportedVoiceCodecs = null)

  • Class gets huge as you add platforms

  • No way to know which properties go together

  • Easy to forget validation checks

  • Testing means mocking tons of properties

Strategy Pattern (The OOP Way)

public interface IMessageSender
{
    Task<Result> SendTextAsync(string text);
    Task<Result> SendVoiceAsync(Stream audio, string codec);
    Task<Result> SendFileAsync(Stream file, string mimeType);
    Task<Result> AddReactionAsync(string messageId, string emoji);
}

public class SmsSender : IMessageSender
{
    public Task<Result> SendTextAsync(string text)
    {
        // Actually implemented
    }

    // SMS doesn't support these, but we're forced to implement them
    public Task<Result> SendVoiceAsync(Stream audio, string codec)
    {
        return Task.FromResult(Result.Fail("Not supported"));
    }

    public Task<Result> AddReactionAsync(string messageId, string emoji)
    {
        return Task.FromResult(Result.Fail("Not supported"));
    }

    public Task<Result> SendFileAsync(Stream file, string mimeType)
    {
        return Task.FromResult(Result.Fail("Not supported"));
    }
}

Problems:

  • Interface forces you to implement methods you don't support

  • No way to check capabilities before calling

  • Validation logic duplicated in every implementation

  • Testing requires mocking entire interface

Capability Pattern (Our Way)

// Platform only has capabilities it supports
var sms = new Platform();
sms.AddCapability(new SmsTextCapability());

// Check before using
if (!sms.HasCapability<IVoiceMessageCapability>())
{
    // We know voice isn't supported - don't even try
}

// If it has the capability, we know it's fully configured
var textCap = sms.GetCapability<ITextMessageCapability>();
// textCap is never null here, and all its properties are valid

Benefits:

  • Impossible to have inconsistent state

  • Clear what each platform supports

  • Validation is centralized

  • Easy to test (mock just the capability you need)

  • New platforms don't change existing code

Real-World Benefits

I've been using this pattern in production for my CRM that integrates with messaging platforms. Here's what I've seen:

Adding a new platform used to take 2-3 days. Now it takes 2-3 hours. I just implement the capabilities it supports and I'm done.

Bug rate dropped significantly. The compiler catches most mistakes. If I try to use a capability that doesn't exist, I get a compilation error, not a runtime crash.

Code is self-documenting. Want to know what WhatsApp supports? Look at which capabilities it has. Want to know the limitations? Look at the capability implementation.

Testing is easier. I mock just the capability I'm testing, not the entire platform.

Onboarding would be faster. When I eventually bring on other developers, they'll understand the system quickly because capabilities are explicit and well-defined.

When To Use This

This pattern makes sense when:

  • You integrate with 5+ platforms with different features

  • Capabilities change frequently (platforms add features)

  • You need type-safe feature detection

  • You want to avoid giant classes with dozens of boolean flags

  • Testing is important to you

It might be overkill if:

  • You only have 2-3 platforms that rarely change

  • All platforms support mostly the same things

  • You're building an MVP and need to ship fast

Implementation Tips

Start small. Don't try to model every capability upfront. Start with 3-4 core capabilities and expand as needed.

Group related properties. If properties always go together, they belong in the same capability interface.

Make capabilities immutable. Use { get; init; } or readonly properties. Capabilities shouldn't change after creation.

Serialize carefully. If you're using Orleans or need persistence, you'll need a serialization strategy. JSON with type discriminators works well.

Document platform-specific properties. When you add properties beyond the interface, comment why they exist.

Code That Inspired This

This pattern draws from several places:

ASP.NET Core's Feature Collections use a similar approach for HTTP features:

var feature = httpContext.Features.Get<IHttpConnectionFeature>();

ECS (Entity Component System) in game engines uses composable components, though without the systems loop.

Browser feature detection checks for capabilities at runtime:

if ('geolocation' in navigator) {
    // Use geolocation
}

I took these ideas and applied them to platform integration with type-safe interfaces.

Wrapping Up

The Component-Based Capability Pattern isn't revolutionary - it's a synthesis of existing ideas applied to a specific problem. But it works really well for handling platform differences.

Instead of maintaining a growing list of boolean flags, you compose platforms from well-defined capabilities. Your code checks for capabilities before using them. The compiler helps you catch mistakes. Testing is easier. New platforms are straightforward to add.

If you're building something that integrates with multiple platforms, give this pattern a shot. Start with a couple of capabilities and see how it feels. You might find it's exactly what you needed.


I'm using this pattern in production in Kasumi, my CRM built with ASP.NET Core and Microsoft Orleans.