I'm developing an ASP.NET Core application using .NET 8, following the Onion Architecture. In our domain model, we use Enumeration
classes instead of traditional enums, inspired by this Microsoft article. The purpose of this approach is to encapsulate domain-specific logic within these classes, providing a richer, more flexible alternative to enums.
However, we’ve encountered a problem: methods like FromName
and FromId
need to be implemented in each specific Enumeration class, leading to code duplication. To solve this, I’ve refactored our EnumerationBase class to use generics, which centralizes the common logic by using an interface with static members.
I'm looking for feedback on the following points:
- Use of Static Properties/Methods in an Interface: Is it appropriate to use static properties and methods in the
IEnumerationBase
interface? Are there potential issues with this approach? - Best Practices: Are there alternative methods to achieve our goal of reducing duplication while maintaining a clean and maintainable codebase?
I appreciate any feedback or suggestions on how to improve this approach. Thanks!
Original Implementation
Here’s our current EnumerationBase
class:
public abstract class EnumerationBase
{
protected EnumerationBase(int id, string name)
{
Id = id;
Name = name;
}
public int Id { get; private set; }
public string Name { get; private set; }
public static IEnumerable<T> GetAll<T>() where T : EnumerationBase
{
return typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(field => field.GetValue(null))
.OfType<T>();
}
// Other methods for equality and comparing
}
And here’s an example of a concrete Enumeration
class:
public class SellingUnitsEnumeration : EnumerationBase
{
public SellingUnitsEnumeration(int id, string name) : base(id, name) { }
public static readonly SellingUnitsEnumeration Pieces = new(1, nameof(Pieces));
public static readonly SellingUnitsEnumeration Meters = new(2, nameof(Meters));
public static IEnumerable<SellingUnitsEnumeration> List()
{
return new[]
{
Pieces,
Meters,
};
}
public static SellingUnitsEnumeration FromName(string name)
{
var state = List().SingleOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
// null check, using List() method in exception message
return state;
}
public static SellingUnitsEnumeration FromId(int id)
{
var state = List().SingleOrDefault(s => s.Id == id);
// null check, using List() method in exception message
return state;
}
}
Refactored Implementation
To reduce duplication, I refactored the EnumerationBase
class into a generic form:
public interface IEnumerationBase<T> : IComparable where T : EnumerationBase<T>, IEnumerationBase<T>
{
static abstract IReadOnlyCollection<T> GetAll();
static abstract string EnumerationName { get; }
}
public abstract class EnumerationBase<T> : IComparable where T : EnumerationBase<T>, IEnumerationBase<T>
{
protected EnumerationBase(int id, string name)
{
Id = id;
Name = name;
}
public int Id { get; }
public string Name { get; }
public static T FromName(string name)
{
var match = T.GetAll()
.SingleOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
// null check, using T.GetAll() method in exception message
return match;
}
public static T FromId(int id)
{
var match = T.GetAll().SingleOrDefault(s => s.Id == id);
// null check, using T.GetAll() method in exception message
return match;
}
// Other methods for equality and comparison
}
And here’s the refactored SellingUnitsEnumeration
class:
public class SellingUnitsEnumeration : EnumerationBase<SellingUnitsEnumeration>, IEnumerationBase<SellingUnitsEnumeration>
{
public SellingUnitsEnumeration(int id, string name) : base(id, name) { }
public static readonly SellingUnitsEnumeration Pieces = new(1, nameof(Pieces));
public static readonly SellingUnitsEnumeration Meters = new(2, nameof(Meters));
public static string EnumerationName => nameof(SellingUnitsEnumeration);
public static IReadOnlyCollection<SellingUnitsEnumeration> GetAll()
{
return new[]
{
Pieces,
Meters,
};
}
}
-
3\$\begingroup\$ Welcome to Code Review! Please don't modify the code in your question once it has been answered. You could post improved code as a new question, as an answer, or as a link to an external site - as described in I improved my code based on the reviews. What next?. I have rolled back the edit, so that it's clear exactly what version has been reviewed. \$\endgroup\$Toby Speight– Toby Speight2024年08月29日 07:40:23 +00:00Commented Aug 29, 2024 at 7:40
3 Answers 3
I would rather go with extension methods to keep both the base and the derived classes as clean as possible.
Base
- I've replaced the
private set
s toinit
s to be able to use object initializer in the derived classes - I've got rid of the constructor because derived classes would use object initializer
- I've moved the
GetAll
into a helper utility
public abstract class EnumerationBase
{
public int Id { get; init; }
public string Name { get; init; }
}
Derived classes
- Nothing fancy, just the static member declarations
public class AEnumeration : EnumerationBase
{
public static readonly AEnumeration A1 = new() { Id = 1, Name = nameof(A1) };
public static readonly AEnumeration A2 = new() { Id = 2, Name = nameof(A2) };
}
public class BEnumeration : EnumerationBase
{
public static readonly BEnumeration B11 = new() { Id = 11, Name = nameof(B11) };
public static readonly BEnumeration B12 = new() { Id = 12, Name = nameof(B12) };
}
Extension methods
GetAll
was moved here and madeprivate
instead ofpublic
public static class EnumerationBaseExt
{
public static T FromName<T>(this string name) where T: EnumerationBase
=> GetAll<T>().SingleOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
public static T FromId<T>(this int id) where T: EnumerationBase
=> GetAll<T>().SingleOrDefault(s => s.Id == id);
private static IEnumerable<T> GetAll<T>() where T : EnumerationBase
=> typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(field => field.GetValue(null))
.OfType<T>();
}
Usage
- I kept the original method names (
FromName
,FromId
) for the sake of simplicity
var a = "A2".FromName<AEnumeration>();
a.Dump();
var b = 11.FromId<BEnumeration>();
b.Dump();
- but I would suggest to rename them to something like
ToEnum
or simply justTo
:
var a = "A2".To<AEnumeration>();
a.Dump();
var b = 11.To<BEnumeration>();
b.Dump();
Dotnet fiddle link: https://dotnetfiddle.net/RtBh1U
-
1\$\begingroup\$ Thank you for pointing me in the right direction! I appreciate your approach, but I haven’t marked it as the accepted answer because it deviates significantly from the structure I’m aiming for. While your solution using an extension method is certainly valid, the syntax of calling it as
"A2".FromName<AEnumeration>()
feels a bit unintuitive to me. \$\endgroup\$James Black– James Black2024年08月28日 14:47:22 +00:00Commented Aug 28, 2024 at 14:47 -
\$\begingroup\$ @JamesBlack You don't have to use extension methods. The main idea here is to extract the factory methods from the base or derived classes. They can be defined for example inside a static class called
Enumeration
and usage could be more naturalEnumeration.To<AEnumeration>("A2")
orEnumeration.To<BEnumeration>(11)
. It is just matter of taste IMHO. \$\endgroup\$Peter Csala– Peter Csala2024年08月28日 14:54:39 +00:00Commented Aug 28, 2024 at 14:54 -
\$\begingroup\$ I completely agree; However, I find it a bit misleading to mark it as the "accepted answer" since it addresses a slightly different question. If you could revise your answer to better align with the specifics of my question, I’d be happy to mark it as the accepted solution. :)
AEnumeration.From("A2"); public abstract class EnumerationBase<T> where T : EnumerationBase<T> { public EnumerationBase(int id, string name) ... public static T From(string name) ... } public class AEnumeration : EnumerationBase<AEnumeration> { ... }
\$\endgroup\$James Black– James Black2024年08月28日 15:16:00 +00:00Commented Aug 28, 2024 at 15:16 -
2\$\begingroup\$ @JamesBlack Please feel free to post your solution to your own question. \$\endgroup\$Peter Csala– Peter Csala2024年08月28日 16:02:23 +00:00Commented Aug 28, 2024 at 16:02
Thanks to @PeterCsala’s answer, I was able to come up with a better solution that doesn’t require using an interface with static members.
This approach is both cleaner and simpler.
Thank you, @PeterCsala, for your help!
Here is our EnumerationBase
base class:
public abstract class EnumerationBase<T> where T : EnumerationBase<T>
{
public EnumerationBase(int id, string name)
{
Id = id;
Name = name;
}
public int Id { get; }
public string Name { get; }
public static T From(string name)
=> GetAll().SingleOrDefault(s => string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
public static T From(int id)
=> GetAll().SingleOrDefault(s => s.Id == id);
public static IEnumerable<T> GetAll()
=> typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(field => field.GetValue(null))
.OfType<T>();
}
Here is a derived class:
public class AEnumeration : EnumerationBase<AEnumeration>
{
public AEnumeration(int id, string name) : base(id, name) { }
public static readonly AEnumeration A1 = new(1, nameof(A1));
public static readonly AEnumeration A2 = new(2, nameof(A2));
}
Here’s how it can be used:
var a = AEnumeration.From("A2");
a.Dump();
You can simplify your work by caching the enumerations, and reuse them. This means,GetAll
will call the reflection once on every unique type, which would improve the performance (since calling reflection has a performance penalty). The second part of this, you would be implement it once, and reuse it across all inherited classes. Then, the interface will be an optional choice and not a requirement.
Example :
public abstract class EnumerationBase<TValue>
{
private static readonly ConcurrentDictionary<Type, IEnumerable<EnumerationBase<TValue>>> s_cache = new();
public string Name { get; }
public TValue Value { get; }
protected EnumerationBase(TValue value, string name)
{
Name = name;
Value = value;
}
public static T? FromName<T>(string name) where T : EnumerationBase<TValue>
{
if (string.IsNullOrWhiteSpace(name)) return null;
if(s_cache.TryGetValue(typeof(T), out var enumeration))
{
return enumeration.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) as T;
}
return null;
}
public static T? FromValue<T>(TValue value) where T : EnumerationBase<TValue>
{
if(value == null) return null;
if (s_cache.TryGetValue(typeof(T), out var enumeration))
{
return enumeration.FirstOrDefault(s => s.Value!.Equals(value)) as T;
}
return null;
}
public static IEnumerable<T> Enumerate<T>() where T : EnumerationBase<TValue>
{
if (s_cache.TryGetValue(typeof(T), out var enumeration))
{
return (IEnumerable<T>)enumeration;
}
var items = typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(field => field.GetValue(null))
.OfType<T>();
s_cache.TryAdd(typeof(T), items);
return items;
}
}
public abstract class EnumerationBase : EnumerationBase<int>
{
protected EnumerationBase(int value, string name) : base(value, name)
{
}
}
Also, you can do a global scan on the assembly to find all classes that implements EnumerationBase<TValue>
and then add them to the cache at application startup, which would improve your code performance (since all reflection work would run once at the startup to build the cache). (This would be a preferred method, unless you see that your cache size would be a problem, then you could maintain your cache using any method that would best suit your application).
-
\$\begingroup\$ Is there any way to shorten method calls? The current
AEnumeration.FromName<AEnumeration>("A1")
(no type Inference) seems too long to me. Interesting idea, it's rather overkill for our solution, but maybe it will come in handy in the future. Thanks! \$\endgroup\$James Black– James Black2024年08月29日 06:56:48 +00:00Commented Aug 29, 2024 at 6:56 -
\$\begingroup\$ Without the type, there is no guarantee to get the correct result for this to work. As
Name
is not globally unique. \$\endgroup\$iSR5– iSR52024年08月29日 11:17:58 +00:00Commented Aug 29, 2024 at 11:17