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);
}
we need to:
- Make any existing type support this interface
- Let’s stay away from the original source code
- Maintain type safety
- 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);
}
}
### 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);
}
}
### 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;
## Why this solution works so well
- type safety: Everything is checked at compile time – no nasty runtime surprises
-
clear intention: this
AsSerializable
Method names clearly indicate what is happening -
Easy access: Do you need the original? Just use
.Value
- non-invasive: your original type remains clean and unchanged
- Maintainable:Simple code is happy code
- Expandable: Need more serialization capabilities? Add easily!
## When to use this mode
This method shines when you:
- Need to add serialization to existing types
- The original code cannot (or should not) be modified
- Want compile-time type safety
- 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
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)
);
## 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();
## 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)
);
## Main benefits of these implementations
-
separation of concerns
- Core business logic remains clear
- Cross-cutting concerns are cleanly encapsulated
- Each wrapper has a single responsibility
-
flexibility
- Can optionally be applied to any compatible type
- Easily combine multiple wrappers when needed
- Configuration can be adjusted based on the instance
-
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"
);
## 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)!