Extending Types Without Modification: The Wrapper Pattern Journey
December 21, 2024

Extending Types Without Modification: The Wrapper Pattern Journey


core issues

Have you ever found yourself thinking “I wish this class implemented ISerialized” or “It would be nice if this type had a built-in cache”? We all have them. But what if I told you there was a way for any class to implement any interface without touching its source code?

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

  • Third-party libraries that you cannot modify
  • Change legacy code that is too risky
  • Framework categories that require extended functionality##Challenges we face

Here’s what we’re dealing with:

We have a simple serialization interface:

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

Exit full screen mode

we need to:

  1. Make any existing type support this interface
  2. Let’s stay away from the original source code
  3. Maintain type safety
  4. Keep it simple for developers

## Our first attempt: dynamic proxy method

Like many developers, we first found a seemingly 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 full screen mode

Exit full screen mode

### Why it didn’t work

While this approach looks promising, we run into a fundamental obstacle to universal constraints:

  • Even though we can generate supported types ISerialize at runtime
  • The original T-shape is not satisfactory ISerialize compile time constraints
  • This means we can’t use it where we need it ISerialize constraint

## Better solution: wrapper pattern

After some brainstorming and coffee, we came up with a 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 full screen mode

Exit full screen mode

### How to use it

The beauty of this solution is its simplicity. Here’s how you 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 full screen mode

Exit full screen mode

## Why this solution works so well

  1. type safety: Everything is checked at compile time – no nasty runtime surprises
  2. clear intention: this AsSerializable Method names clearly indicate what is happening
  3. Easy access: Do you need the original? Just use .Value
  4. non-invasive: your original type remains clean and unchanged
  5. Maintainable:Simple code is happy code
  6. Expandable: Need more serialization capabilities? Add easily!

## When to use this mode

This method shines when you:

  1. Need to add serialization to existing types
  2. The original code cannot (or should not) be modified
  3. Want compile-time type safety
  4. Requires 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 full screen mode

Exit full screen mode


Extended example: adding functionality through wrappers

Let’s explore three real-life scenarios where this wrapper pattern elegantly solves common challenges.

## Example 1: Add retry function

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

  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 full screen mode

Exit full screen mode

## Example 2: Add verification function

### Imagine
You have a data object that needs to be validated in a specific context, but you don’t want to pollute the original class 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 full screen mode

Exit full screen mode

## Example 3: Add cache behavior

### Imagine
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 full screen mode

Exit full screen mode

## Main benefits of these implementations

  1. separation of concerns

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

    • Can optionally be applied to any compatible type
    • Easily combine multiple wrappers when needed
    • Configuration can be adjusted based on the instance
  3. Maintainability

    • Changes to retry/validation/caching logic are centralized
    • Simplify testing with clear boundaries
    • Implementation details hidden behind clean interface

## Combining wrappers

One of the powerful features 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 full screen mode

Exit full screen mode

## Final Thoughts

Sometimes, the best solution is not the most technically complex one. By choosing the wrapper pattern over dynamic proxies, we end up with code that is easier to understand, maintain, and use. This is a good reminder: in software development, simpler is often better.

Remember: the goal is not just to solve the problem, but to solve it in a way that makes life easier for developers after us (including our future selves)!

2024-12-21 11:08:20

Leave a Reply

Your email address will not be published. Required fields are marked *