DEV Community

Cover image for Extending Types Without Modification: The Wrapper Pattern Journey
林鉦傑
林鉦傑

Posted on

1

Extending Types Without Modification: The Wrapper Pattern Journey

The Core Problem

Have you ever found yourself thinking "I wish this class implemented ISerializable" or "If only this type had caching built-in"? We all have. But what if I told you there's a way to make any class implement any interface without touching its source code?

This pattern is particularly powerful when you're dealing with:

  • Third-party libraries you can't modify
  • Legacy code that's too risky to change
  • Framework classes that need extended functionality ## The Challenge We Faced

Here's what we were dealing with:

We had a simple serialization interface:

  public interface ISerialize<T>
  {
      string Serialize();
      T DeSerialize(string seralizeString);
  }
Enter fullscreen mode Exit fullscreen mode

And we needed to:

  1. Make any existing type support this interface
  2. Keep our hands off the original source code
  3. Maintain type safety
  4. Keep it simple for developers to use

## Our First Attempt: The Dynamic Proxy Approach

Like many developers, we first reached for what seemed like a clever solution - dynamic proxies:

  public static class SerializeExtensions
  {
      private static readonly Dictionary<Type, Type> _proxyTypes = new Dictionary<Type, Type>();

      public static T AsSerializable<T>(this T obj) where T : class
      {
          if (obj is ISerialize<T>)
              return obj;

          var proxyType = GetProxyType<T>();
          return (T)Activator.CreateInstance(proxyType, obj);
      }
  }
Enter fullscreen mode Exit fullscreen mode

### Why It Didn't Work Out

While this approach seemed promising, we hit a fundamental roadblock with generic constraints:

  • Even though we could generate types supporting ISerialize<T> at runtime
  • The original type T didn't satisfy the ISerialize<T> constraint at compile time
  • This meant we couldn't use it where we needed the ISerialize<T> constraint

## The Better Solution: Wrapper Pattern

After some head-scratching and coffee, we came up with a much cleaner solution using the wrapper pattern:

  public class SerializeWrapper<T> : ISerialize<SerializeWrapper<T>> where T : class
  {
      public T Value { get; private set; }

      public SerializeWrapper(T value)
      {
          Value = value;
      }

      public string Serialize()
      {
          return JsonSerializer.Serialize(Value);
      }

      public SerializeWrapper<T> DeSerialize(string serializeString)
      {
          return new SerializeWrapper<T>(JsonSerializer.Deserialize<T>(serializeString));
      }
  }

  public static class SerializeExtensions
  {
      public static SerializeWrapper<T> AsSerializable<T>(this T obj) where T : class
      {
          return new SerializeWrapper<T>(obj);
      }
  }
Enter fullscreen mode Exit fullscreen mode

### How to Use It

The beauty of this solution is its simplicity. Here's how you'd use it in your code:

  // Start with any class
  var myObject = new SomeClass();

  // Need serialization? Just wrap it
  var serializable = myObject.AsSerializable();

  // Serialize it
  string serialized = serializable.Serialize();

  // Deserialize when needed
  var deserialized = serializable.DeSerialize(serialized);

  // Get back your original object
  var original = deserialized.Value;
Enter fullscreen mode Exit fullscreen mode

## Why This Solution Works So Well

  1. Type Safety: Everything is checked at compile-time - no nasty runtime surprises
  2. Clear Intent: The AsSerializable method name clearly shows what's happening
  3. Easy Access: Need the original object? Just use .Value
  4. Non-Invasive: Your original types stay clean and unchanged
  5. Maintainable: Simple code is happy code
  6. Extensible: Need more serialization features? Easy to add!

## When to Use This Pattern

This approach shines when you:

  1. Need to add serialization to existing types
  2. Can't (or shouldn't) modify the original code
  3. Want compile-time type safety
  4. Need consistent serialization behavior
## Design Considerations When Adding Interfaces

1. **Type Safety**
   - Wrapper should implement interface with itself as type parameter
   - Use generic constraints appropriately
   - Consider compile-time type checking

2. **Value Access**
   - Always provide clean access to wrapped value
   - Consider implicit/explicit operators if appropriate
   - Keep Value property read-only when possible

3. **Interface Design**
   - Keep interfaces focused and cohesive
   - Consider fluent interface patterns
   - Plan for composition of multiple wrappers

4. **Performance Impact**
   - Minimize object allocation
   - Consider caching wrapper instances
   - Profile wrapper overhead in critical paths
Enter fullscreen mode Exit fullscreen mode

Extended Examples: Adding Capabilities Through Wrappers

Let's explore three real-world scenarios where this wrapper pattern can elegantly solve common challenges.

## Example 1: Adding Retry Capability

### Scenario
Imagine you have various API client classes from different third-party packages, and you want to add retry logic without modifying the original implementations.

  public interface IRetryable<T>
  {
      Task<TResult> ExecuteWithRetryAsync<TResult>(
          Func<T, Task<TResult>> operation,
          int maxRetries = 3,
          TimeSpan? delay = null);
  }

  public class RetryWrapper<T> : IRetryable<RetryWrapper<T>> where T : class
  {
      public T Value { get; private set; }

      public RetryWrapper(T value)
      {
          Value = value;
      }

      public async Task<TResult> ExecuteWithRetryAsync<TResult>(
          Func<T, Task<TResult>> operation,
          int maxRetries = 3,
          TimeSpan? delay = null)
      {
          var retryDelay = delay ?? TimeSpan.FromSeconds(1);
          var attempts = 0;

          while (true)
          {
              try
              {
                  attempts++;
                  return await operation(Value);
              }
              catch (Exception ex) when (attempts < maxRetries)
              {
                  await Task.Delay(retryDelay);
              }
          }
      }
  }

  // Usage Example
  var apiClient = new ThirdPartyApiClient();
  var retryableClient = apiClient.AsRetryable();

  var result = await retryableClient.ExecuteWithRetryAsync(
      client => client.FetchDataAsync(),
      maxRetries: 3,
      delay: TimeSpan.FromSeconds(2)
  );
Enter fullscreen mode Exit fullscreen mode

## Example 2: Adding Validation Capabilities

### Scenario
You have data objects that need validation in specific contexts, but you don't want to pollute the original classes with validation logic.

  public interface IValidatable<T>
  {
      ValidationResult Validate();
      bool IsValid { get; }
      IEnumerable<string> Errors { get; }
  }

  public class ValidationWrapper<T> : IValidatable<ValidationWrapper<T>> where T : class
  {
      private readonly List<Func<T, ValidationError>> _validationRules = new();
      private List<string> _errors = new();

      public T Value { get; private set; }
      public bool IsValid => !Errors.Any();
      public IEnumerable<string> Errors => _errors;

      public ValidationWrapper(T value)
      {
          Value = value;
      }

      public ValidationWrapper<T> AddRule(Func<T, ValidationError> rule)
      {
          _validationRules.Add(rule);
          return this;
      }

      public ValidationResult Validate()
      {
          _errors.Clear();
          foreach (var rule in _validationRules)
          {
              var error = rule(Value);
              if (error != null)
                  _errors.Add(error.Message);
          }
          return new ValidationResult(IsValid, _errors);
      }
  }

  // Usage Example
  var user = new User { Name = "John", Email = "invalid-email" };
  var validatableUser = user.AsValidatable()
      .AddRule(u => string.IsNullOrEmpty(u.Name) 
          ? new ValidationError("Name is required") 
          : null)
      .AddRule(u => !u.Email.Contains("@") 
          ? new ValidationError("Invalid email format") 
          : null);

  var result = validatableUser.Validate();
Enter fullscreen mode Exit fullscreen mode

## Example 3: Adding Caching Behavior

### Scenario
You have various service classes that could benefit from caching, but implementing caching in each service would be repetitive and intrusive.

  public interface ICacheable<T>
  {
      Task<TResult> WithCachingAsync<TResult>(
          Func<T, Task<TResult>> operation,
          string cacheKey,
          TimeSpan? duration = null);
  }

  public class CacheWrapper<T> : ICacheable<CacheWrapper<T>> where T : class
  {
      private readonly IMemoryCache _cache;
      public T Value { get; private set; }

      public CacheWrapper(T value, IMemoryCache cache)
      {
          Value = value;
          _cache = cache;
      }

      public async Task<TResult> WithCachingAsync<TResult>(
          Func<T, Task<TResult>> operation,
          string cacheKey,
          TimeSpan? duration = null)
      {
          if (_cache.TryGetValue(cacheKey, out TResult cachedResult))
              return cachedResult;

          var result = await operation(Value);

          var cacheOptions = new MemoryCacheEntryOptions()
              .SetAbsoluteExpiration(duration ?? TimeSpan.FromMinutes(5));

          _cache.Set(cacheKey, result, cacheOptions);

          return result;
      }
  }

  // Usage Example
  var dataService = new DataService();
  var cacheableService = dataService.AsCacheable(memoryCache);

  var data = await cacheableService.WithCachingAsync(
      service => service.FetchExpensiveDataAsync(),
      cacheKey: "expensive-data",
      duration: TimeSpan.FromHours(1)
  );
Enter fullscreen mode Exit fullscreen mode

## Key Benefits of These Implementations

  1. Separation of Concerns

    • Core business logic remains clean
    • Cross-cutting concerns are cleanly encapsulated
    • Each wrapper has a single responsibility
  2. Flexibility

    • Can be applied selectively to any compatible type
    • Easy to combine multiple wrappers when needed
    • Configuration can be adjusted per-instance
  3. Maintainability

    • Changes to retry/validation/caching logic are centralized
    • Testing is simplified through clear boundaries
    • Implementation details are hidden behind clean interfaces

## Combining Wrappers

One of the powerful aspects of this pattern is that you can chain multiple wrappers together:

  var service = new DataService()
      .AsRetryable()
      .AsCacheable(cache)
      .AsValidatable()
      .AddRule(s => /* validation rule */);

  var result = await service
      .WithCachingAsync(async s => 
          await s.ExecuteWithRetryAsync(async s2 => 
              {
                  if (!s2.Validate().IsValid)
                      throw new ValidationException();
                  return await s2.Value.FetchDataAsync();
              }
          ),
          "cache-key"
      );
Enter fullscreen mode Exit fullscreen mode

## Final Thoughts

Sometimes the best solutions aren't the most technically sophisticated ones. By choosing the wrapper pattern over dynamic proxies, we ended up with code that's easier to understand, maintain, and use. It's a great reminder that in software development, simpler is often better.

Remember: The goal isn't just to solve the problem, but to solve it in a way that makes life easier for the developers who come after us - including our future selves!

Billboard image

Use Playwright to test. Use Playwright to monitor.

Join Vercel, CrowdStrike, and thousands of other teams that run end-to-end monitors on Checkly's programmable monitoring platform.

Get started now!

Top comments (5)

Collapse
 
andreea_m profile image
Andreea M. • Edited

Is there a reason why you have
SerializeWrapper<T> : ISerialize<SerializeWrapper<T>> where T : class
instead of
SerializeWrapper<T> : ISerialize<T> where T : class ?

As it is it gets a bit confusing, since the serialize method returns the serialized original value, but the deserialize method, instead of the original value, returns that wrapper object. You could have the deserialization method return the object too, and then you wouldn't need to use the extra line

var original = deserialized.Value;
Enter fullscreen mode Exit fullscreen mode

because deserialized then would already hold your object.

Collapse
 
jaylin profile image
林鉦傑 • Edited

Ah yes, let me help clarify this important point about interface constraints and capability requirements:

When we have a method signature like:

T Get<T>(string key) where T : ISerialize<T>;
Enter fullscreen mode Exit fullscreen mode

This constraint where T : ISerialize requires that type T must implement ISerialize. We can see this clearly in these two examples:


// This compiles successfully because we wrap T with SerializeWrapper
public T Query(string id)
{
    var entityKey = GetEntityKey(id);
    return _redis.Get<SerializeWrapper<T>>(entityKey).Value;
}

Enter fullscreen mode Exit fullscreen mode
// This fails to compile because T doesn't implement required serialization interface
public T Query(string id)
{
    var entityKey = GetEntityKey(id);
    return _redis.Get<T>(entityKey); // Compiler error: T doesn't satisfy interface constraints
}
Enter fullscreen mode Exit fullscreen mode

This is precisely why we need the wrapper pattern - to add capabilities to types that don't inherently have them. The SerializeWrapper acts as an adapter that provides these required capabilities while preserving the original object's functionality through the .Value property.

This is a crucial distinction I missed earlier - thank you for the correction. It helps explain why the wrapper pattern is necessary in this context and how it solves the interface constraint requirement while maintaining access to the original object.

Collapse
 
andreea_m profile image
Andreea M.

Ah, I was referring to having something like this dotnetfiddle.net/Y8MXeU
(this is just the serialization example, I haven't tried updating the others).

You can see I have the serialization wrapper, with serialize/deserialize, but I didn't need to inherit from ISerializable<SerializerWrapper<T>>, a plain old ISerializable of T was enough.

Thread Thread
 
jaylin profile image
林鉦傑 • Edited

From this perspective, I was excited because now we have this improved WRAPPER - it's like putting a new outfit on our original classes! It's a foundational template that we can inherit from anytime. This is truly code therapy, thank you so much!

AnyWrapper Base Implementation:

public class AnyWrapper<T> where T : class
{
    protected readonly T _wrapped;  // Access to wrapped object

    protected AnyWrapper(T value) => _wrapped = value;

    // Get bi-directional implicit conversion
    public static implicit operator T(AnyWrapper<T> wrapper) => wrapper._wrapped;
    public static implicit operator AnyWrapper<T>(T value) => new AnyWrapper<T>(value);
}
Enter fullscreen mode Exit fullscreen mode

Practical Application Example:

// Monitor wrapper
public class MonitorWrapper<T> : AnyWrapper<T> where T : class
{
    public MonitorWrapper(T value) : base(value) { }

    public void DoSomething()
    {
        // Direct access to original object using _wrapped
        var prop = _wrapped.GetType().GetProperty("Name");
    }
}

// Usage example
var original = new Employee { Name = "John" };
var wrapper = new MonitorWrapper<Employee>(original);

// Can be converted back to Employee directly
Employee emp = wrapper;  // Implicit conversion works


//Another implement
public class SaveableWrapper<T> : AnyWrapper<T>, ISaveable<SaveableWrapper<T>> where T : class
{
    public SaveableWrapper(T value) : base(value) { }


    public string ToSaveString()
    {
        return DataConvert.Serialize(_wrapped);
    }

    public SaveableWrapper<T> BySaveString(string saveString)
    {
        return new SaveableWrapper<T>(DataConvert.Deserialize<T>(saveString));
    }

    public T Clone()
    {
        return new SaveableWrapper<T>(DataConvert.Deserialize<T>(DataConvert.Serialize(_wrapped)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefit:

Implicit Conversion Capability

  • Automatic conversion between wrapper and original types
  • T original = wrapper; automatically converts back to original type
  • Wrapper<T> wrapper = original; automatically converts to wrapper class
Collapse
 
jaylin profile image
林鉦傑 • Edited

Here's a breakthrough idea to solve this problem. Key points:

  • No modification to original classes
  • No changes to target interfaces
  • Only one as conversion needed to get interface support

Core Implementation Principle

public class AnyWrapper<T> where T : class
{
    private readonly T _wrapped;

    // Core magic: Bi-directional implicit conversion
    public static implicit operator T(AnyWrapper<T> wrapper) => wrapper._wrapped;
    public static implicit operator AnyWrapper<T>(T value) => new(value);
}
Enter fullscreen mode Exit fullscreen mode

Implementation Examples

1. Serialization Wrapper

public class SerializeWrapper<T> : ISerialize<SerializeWrapper<T>> where T : class
{
    private readonly T _wrapped;

    public SerializeWrapper(T value) => _wrapped = value;

    public static implicit operator T(SerializeWrapper<T> wrapper) => wrapper._wrapped;
    public static implicit operator SerializeWrapper<T>(T value) => new(value);

    public string Serialize() => JsonSerializer.Serialize(_wrapped);
    public SerializeWrapper<T> DeSerialize(string serializeString) 
        => new(JsonSerializer.Deserialize<T>(serializeString));
}
Enter fullscreen mode Exit fullscreen mode

2. ID Management Wrapper

public interface IEntity
{
    string Id { get; }
}

public class EntityWrapper<T> : IEntity where T : class
{
    private readonly T _wrapped;
    public string Id { get; }

    public static implicit operator T(EntityWrapper<T> wrapper) => wrapper._wrapped;
    public static implicit operator EntityWrapper<T>(T value) 
        => new(value, Guid.NewGuid().ToString());

    public EntityWrapper(T wrapped, string id)
    {
        _wrapped = wrapped;
        Id = id;
    }

    public EntityWrapper(T wrapped, Func<string> idGenerator)
    {
        _wrapped = wrapped;
        Id = idGenerator();
    }

    public EntityWrapper(T wrapped, Func<T, string> idGenerator)
    {
        _wrapped = wrapped;
        Id = idGenerator(wrapped);
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage Comparison

Old Way (Requires .Value)
New Way (Completely Seamless)
Enter fullscreen mode Exit fullscreen mode

Combined Application Scenarios

Original Classes and Interfaces

public class Person
{
    // Original properties...
}

public interface IEntity
{
    string Id { get; }
}

public interface ISerialize<T>
{
    string Serialize();
    T DeSerialize(string serializeString);
}
Enter fullscreen mode Exit fullscreen mode

Seamless Combined Usage

// Create a person object
var person = new Person 
{ 
    Name = "John Doe", 
    Age = 30 
};

// Apply both wrappers in a fluent way
var userWrapper = person
    .AsEntity(p => $"user_{p.Name}_{DateTime.Now:yyyyMMdd}")  // Adds ID capability
    .AsSerializable();                                        // Adds serialization capability

// Store in NoSQL database
// - userWrapper.Id provides the key
// - userWrapper auto-serializes when stored
db.Set(userWrapper.Id, userWrapper);

// Retrieve from database
var storedUser = db.Get<SerializeWrapper<Person>>("user_JohnDoe_20241222");
Console.WriteLine(storedUser.Name);  // Directly access as Person
Enter fullscreen mode Exit fullscreen mode

Now

  1. Achieves true seamless conversion
  2. Maintains purity of original classes
  3. Non-invasive design
  4. Minimizes usage complexity
  5. Supports multiple wrapper combinations

Benefits

  • Eliminates boilerplate code
  • Reduces cognitive load
  • Improves code readability
  • Maintains type safety
  • Flexible and extensible design

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Explore a sea of insights with this enlightening post, highly esteemed within the nurturing DEV Community. Coders of all stripes are invited to participate and contribute to our shared knowledge.

Expressing gratitude with a simple "thank you" can make a big impact. Leave your thanks in the comments!

On DEV, exchanging ideas smooths our way and strengthens our community bonds. Found this useful? A quick note of thanks to the author can mean a lot.

Okay