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);
}
And we needed to:
- Make any existing type support this interface
- Keep our hands off the original source code
- Maintain type safety
- 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);
}
}
### 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);
}
}
### 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;
## Why This Solution Works So Well
- Type Safety: Everything is checked at compile-time - no nasty runtime surprises
-
Clear Intent: The
AsSerializable
method name clearly shows what's happening -
Easy Access: Need the original object? Just use
.Value
- Non-Invasive: Your original types stay clean and unchanged
- Maintainable: Simple code is happy code
- Extensible: Need more serialization features? Easy to add!
## When to Use This Pattern
This approach shines when you:
- Need to add serialization to existing types
- Can't (or shouldn't) modify the original code
- Want compile-time type safety
- 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
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)
);
## 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();
## 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)
);
## Key Benefits of These Implementations
-
Separation of Concerns
- Core business logic remains clean
- Cross-cutting concerns are cleanly encapsulated
- Each wrapper has a single responsibility
-
Flexibility
- Can be applied selectively to any compatible type
- Easy to combine multiple wrappers when needed
- Configuration can be adjusted per-instance
-
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"
);
## 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)