DEV Community

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

Posted on

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!

Top comments (0)