-
Notifications
You must be signed in to change notification settings - Fork 1
Version 8.x.x Smart Enums
- Why Smart Enums?
- Getting Started
- What is implemented for you
-
Customization
- Key Member Generation
- Constructor Argument Validation
- Custom Equality Comparer
- Custom Comparer
- Implementation of
IComparable
/IComparable<T>
- Implementation of comparison operators
- Implementation of
IParsable<T>
- Implementation of
IFormattable
- Implementation of ToString
- Conversion from/to non-key type
- Hide fields and properties from Source Generator and Analyzer
- Framework Integration
- Performance Optimizations
- Logging Configuration
- Real-world use cases and ideas
Smart Enums provide a powerful alternative to traditional C# enums, offering type-safety, extensibility, and rich behavior. This library implements Smart Enums through source generation, combining the simplicity of enums with the flexibility of classes.
Traditional C# enums have several limitations:
- They allow only numbers as underlying values
- They can't include additional data or behavior
- They allow invalid values through direct casting of a number
- They require (non-exhaustive)
switch
andif
statements for different behaviors
Smart Enums solve these problems by providing:
- ✨ Support for any type as the underlying value (e.g.
string
) - 🛡️ Type-safety with compile-time validation
- 🧩 Ability to include additional data and behavior
- 🔄 Built-in pattern matching with
Switch
/Map
methods - 🔌 Seamless integration with JSON serializers, MessagePack, Entity Framework Core and ASP.NET
This library provides an easy way to implement Smart Enums with Roslyn Analyzers and Code Fixes guiding you through the process. Additional packages add support for System.Text.Json
, Newtonsoft.Json
, MessagePack
, Entity Framework Core
and ASP.NET Core Model Binding
.
Note: Some types in the library start with
ValueObject...
instead ofSmartEnum...
. These types are shared between Smart Enums and Value Objects, as a Smart Enum can be thought of as a specialized Value Object with limited set of valid instances.
- .NET SDK: Version 8.0.400 or higher (for building projects)
- C# Version: C# 11 or higher (for generated code)
Install core NuGet package:
Depending on your needs, you may want to install additional packages:
Key concepts:
- Key Member Type: Each Smart Enum can have an underlying type (called key-member type) which can be of any type, not just numbers
- Type Safety: Unlike regular enums, Smart Enums (by default) prevent creation of invalid enum items
- Rich Behavior: Add properties and methods to your enum items
-
Pattern Matching: Built-in
Switch
/Map
methods are exhaustive, i.e. no more forgetting to handle an item
New in v7: You can now create keyless Smart Enums without an underlying type. These are perfect for use cases where you just need a set of valid instances without associated values for serialization and deserialization.
Smart Enums are easy to implement. Here are three different examples to start with:
// Use string as the key type - perfect for human-readable identifiers
[SmartEnum<string>]
public partial class ProductType
{
// Define your enum items as static readonly fields
// and pass the corresponding string to constructor
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
// Use int as the key type - great for persistence in a database
[SmartEnum<int>]
public partial class ProductGroup
{
public static readonly ProductGroup Fruits = new(1);
public static readonly ProductGroup Vegetables = new(2);
}
// No key type needed - useful for cases that don't require serialization
[SmartEnum]
public partial class SalesCsvImporterType
{
// Items don't need any key, just the instance itself
public static readonly SalesCsvImporterType Daily = new();
public static readonly SalesCsvImporterType Monthly = new();
}
Key Points:
- Mark your class with
[SmartEnum]
or[SmartEnum<T>]
whereT
is your key type - Make the class
partial
- Define enum items as
public static readonly
fields
[SmartEnum<string>]
public partial class ProductType
{
// The source generator creates a private constructor
public static readonly ProductType Groceries = new("Groceries");
}
// Enumeration over all defined items
IReadOnlyList<ProductType> allTypes = ProductType.Items;
// Value retrieval
ProductType productType = ProductType.Get("Groceries"); // Get by key (throws if not found)
ProductType productType = (ProductType)"Groceries"; // Same as above but by using a cast
bool found = ProductType.TryGet("Groceries", out var productType); // Safe retrieval (returns false if not found)
// Validation with detailed error information
ValidationError? error = ProductType.Validate("Groceries", null, out ProductType? productType);
// IParsable<T> (useful for Minimal APIs)
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedType);
// IFormattable (e.g. for numeric keys)
string formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // "001"
// IComparable
int comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables);
bool isGreater = ProductGroup.Fruits > ProductGroup.Vegetables; // Comparison operators
// Implicit conversion to key type
string key = ProductType.Groceries; // Returns "Groceries"
// Equality comparison
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);
bool equal = ProductType.Groceries == ProductType.Groceries; // Operator overloading
bool notEqual = ProductType.Groceries != ProductType.Housewares;
// Methods inherited from Object
int hashCode = ProductType.Groceries.GetHashCode();
string key = ProductType.Groceries.ToString(); // Returns "Groceries"
// TypeConverter
var converter = TypeDescriptor.GetConverter(typeof(ProductType));
string? keyStr = (string?)converter.ConvertTo(ProductType.Groceries, typeof(string));
ProductType? converted = (ProductType?)converter.ConvertFrom("Groceries");
All Switch
/Map
methods are exhaustive by default ensuring all cases are handled correctly.
ProductType productType = ProductType.Groceries;
// Execute different actions based on the enum value (void return)
productType.Switch(
groceries: () => Console.WriteLine("Processing groceries order"),
housewares: () => Console.WriteLine("Processing housewares order")
);
// Transform enum values into different types
string department = productType.Switch(
groceries: () => "Food and Beverages",
housewares: () => "Home and Kitchen"
);
// Direct mapping to values
// Direct mapping to values - clean and concise
decimal discount = productType.Map(
groceries: 0.05m, // 5% off groceries
housewares: 0.10m // 10% off housewares
);
For scenarios where you only need to handle specific enum values and provide a default behavior for others, you can enable partial methods:
// Enable partial methods in your Smart Enum definition
[SmartEnum<string>(
SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
These methods let you handle specific cases while providing a default for unspecified items:
// SwitchPartially with Action
productType.SwitchPartially(
@default: item => Console.WriteLine($"Default handling for {item}"),
groceries: () => Console.WriteLine("Special handling for groceries")
);
// Omitting default handler is also possible
productType.SwitchPartially(
groceries: () => Console.WriteLine("Only handle groceries specifically")
);
// MapPartially with values
string result = productType.MapPartially(
@default: "Standard department",
groceries: "Food section"
);
Like standard Switch/Map methods, partial versions also offer overloads for state passing to prevent closures:
ILogger logger = ...;
// SwitchPartially with state
productType.SwitchPartially(
logger,
@default: static (l, item) => l.LogInformation("Default: {Item}", item),
groceries: static l => l.LogInformation("Groceries specific logic")
);
For optimal performance Smart Enums provide overloads that prevent closures.
ILogger logger = ...;
// Prevent closures by passing the parameter as first method argument
productType.Switch(logger,
groceries: static l => l.LogInformation("Processing groceries order"),
housewares: static l => l.LogInformation("Processing housewares order")
);
// Use a tuple to pass multiple values
var context = (Logger: logger, OrderId: "123");
productType.Switch(context,
groceries: static ctx => ctx.Logger.LogInformation("Processing groceries order {OrderId}", ctx.OrderId),
housewares: static ctx => ctx.Logger.LogInformation("Processing housewares order {OrderId}", ctx.OrderId)
);
When working with plain C# enums then we may cast virtually any number to the type of enum. By doing so, we can end up working with invalid items which may lead to bugs.
// plain C# enum
public enum MyEnum
{
Groceries = 1,
Housewares = 2
}
MyEnum myEnum = (MyEnum)42;
bool isDefined = Enum.IsDefined(myEnum); // false
Usually, having invalid enumeration items is not a requirement, that's why - by default - a Smart Enum provides no means to create an invalid item. Still, there are use cases where you might want to create an invalid item for later analysis. One of such use cases could be parsing a CSV file, which may contain invalid data. Using a Smart Enum, like our ProductType
, would lead to a UnknownSmartEnumIdentifierException
when trying to convert a string
to a ProductType
.
// Throws UnknownSmartEnumIdentifierException
ProductType productType = ProductType.Get("SomeValue");
// Explicit cast will lead to identical outcome as the line above
ProductType productType = (ProductType)"SomeValue";
The solution in such use cases is to use [SmartEnum<T>(IsValidatable = true)]
.
A validatable Smart Enum provides:
- additional property
bool IsValid
for checking the validity - additional (guard) method
void EnsureValid()
which throws anInvalidOperationException
if item is not valid
A validatable Smart Enum can be either a
class
or areadonly struct
. An always-valid Smart Enum can be aclass
only because the creation of astruct
can bypass any validation by using the default constructor or the keyworddefault
.
Implementation of a validatable Smart Enum:
[SmartEnum<string>(IsValidatable = true)]
public partial class OtherProductType
{
public static readonly OtherProductType Groceries = new("Groceries");
public static readonly OtherProductType Housewares = new("Housewares");
}
Creation of an invalid item is now possible.
OtherProductType productType = OtherProductType.Get("SomeValue");
string key = productType.Key; // "SomeValue"
bool isValid = productType.IsValid; // false
productType.EnsureValid(); // throws InvalidOperationException
The creation of invalid items is done by the method CreateInvalidItem
which is implemented by the source generator. For more control, it is possible to provide own implementation. There are 2 important conditions:
- The provided
key
must not be changed, so it becomes equal to a valid item. - The second constructor argument
isValid
must always befalse
, i.e., don't try to make an invalid item to a valid one.
[SmartEnum<int>(IsValidatable = true)]
public partial class ProductGroup
{
public static readonly ProductGroup Apple = new(1, "Apple");
public static readonly ProductGroup Orange = new(2, "Orange");
public string DisplayName { get; }
private static ProductGroup CreateInvalidItem(int key)
{
// Provide custom values for invalid items
return new(key, false, "Unknown product group");
}
}
Use guard clause EnsureValid()
to prevent invalid items from executing certain operations or IsValid
to check whether the item is a valid one.
public class MyClass
{
public void ProcessOrder(ProductGroup productGroup)
{
productGroup.EnsureValid(); // Throws InvalidOperationException if item is not valid
// Process the order...
}
}
The property Items
and methods Get
, TryGet
, Validate
, Parse
and TryParse
are implementations of static abstract members of interfaces ISmartEnum<TKey, T, TValidationError>
and IParsable<T>
. All interfaces are implemented by the Source Generator. Use generics to access aforementioned members without knowing the concrete type.
// Use T.Items to get all items.
PrintAllItems<ProductType, string>();
private static void PrintAllItems<T, TKey>()
where T : ISmartEnum<TKey, T, ValidationError>
where TKey : notnull
{
Console.WriteLine($"Print all items of '{typeof(T).Name}':");
foreach (T item in T.Items)
{
Console.WriteLine($"Item: {item}");
}
}
------------
// Use T.Get/TryGet/Validate to get the item for provided key.
Get<ProductType, string>("Groceries");
private static void Get<T, TKey>(TKey key)
where T : ISmartEnum<TKey, T, ValidationError>
where TKey : notnull
{
T item = T.Get(key);
Console.WriteLine($"Key '{key}' => '{item}'");
}
The Smart Enums really shine when the enumeration item has to provide additional data (fields/properties) or specific behavior (methods). With plain C# enum there is no other way as to use if-else
or switch-case
clauses to handle a specific item. Having a Smart Enum, like the ProductType
, we can add further fields, properties and methods as to any other classes.
Smart Enums can have multiple properties and even reference other Smart Enums.
[SmartEnum<int>]
public partial class ProductGroup
{
// The constructor is genereted according to defined fields and properties
public static readonly ProductGroup Apple = new(1, "Apple", ProductCategory.Fruits);
public static readonly ProductGroup Orange = new(2, "Orange", ProductCategory.Fruits);
// Custom properties
public string DisplayName { get; }
public ProductCategory Category { get; }
}
Adding a method, which provides the same behavior for all items, requires no special treatment. The method may have any arguments and any return type.
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
public void Do()
{
// do something
}
}
If different items must provide different implementations, then we have (at least) 3 options:
- using the
UseDelegateFromConstructorAttribute
- using delegates (
Func<T>
,Action
, etc.) - via inheritance
Option 1: using UseDelegateFromConstructorAttribute
The UseDelegateFromConstructorAttribute
provides a clean way to implement item-specific behavior without manually managing delegates:
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries", DoForGroceries);
public static readonly ProductType Housewares = new("Housewares", DoForHousewares);
[UseDelegateFromConstructor]
public partial void Do();
private static void DoForGroceries()
{
// Implementation for Groceries
}
private static void DoForHousewares()
{
// Implementation for Housewares
}
}
The source generator will automatically:
- Create a private delegate field
- Assign the field with method/delegate passed to the constructor
- Implement the partial method to call the delegate
Option 2: using delegates
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries", DoForGroceries);
public static readonly ProductType Housewares = new("Housewares", Empty.Action);
private readonly Action _do;
public void Do()
{
_do();
}
private static void DoForGroceries()
{
// do something
}
}
Option 3: inheritance
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new HousewaresProductType();
public virtual void Do()
{
// do default stuff
}
private class HousewaresProductType : ProductType
{
public HousewaresProductType()
: base("Housewares")
{
}
public override void Do()
{
// do something else
}
}
}
New in v8: Smart Enums can be defined as nested classes.
public partial class Container
{
[SmartEnum<string>]
public partial class NestedProductType
{
public static readonly NestedProductType Basic = new("BASIC");
public static readonly NestedProductType Premium = new("PREMIUM");
}
}
Smart Enums support inheritance
[SmartEnum<string>]
public partial class BaseProductType
{
public static readonly BaseProductType Standard = new("Standard");
public static readonly BaseProductType Premium = new PremiumProductType();
protected virtual decimal GetDiscount() => 0m;
protected virtual string GetDescription() => "Standard product";
private sealed class PremiumProductType : BaseProductType
{
public PremiumProductType() : base("Premium") { }
protected override decimal GetDiscount() => 0.1m; // 10% discount
protected override string GetDescription() => "Premium product with special benefits";
}
}
// Usage with polymorphic behavior
foreach (var product in BaseProductType.Items)
{
Console.WriteLine($"{product.Key}: {product.GetDescription()}");
}
Smart Enums support generic derived types, enabling powerful type-safe specializations:
[SmartEnum<string>]
public partial class Operator
{
public static readonly Operator Item1 = new("Operator 1");
public static readonly Operator Item2 = new GenericOperator<int>("Operator 2");
public static readonly Operator Item3 = new GenericOperator<decimal>("Operator 3");
public static readonly Operator Item4 = new GenericOperator<int>("Operator 4");
private sealed class GenericOperator<T> : Operator
{
public GenericOperator(string key)
: base(key)
{
}
}
}
Smart Enums can inherit from regular classes to extend their functionality:
public class SomeBaseClass
{
public int Value { get; }
public SomeBaseClass(int value)
{
Value = value;
}
}
[SmartEnum<string>]
public partial class EnumWithBaseClass : SomeBaseClass
{
public static readonly EnumWithBaseClass Item1 = new("item 1", 42);
}
Smart Enums are highly customizable thanks to Roslyn Source Generators.
The key member is generated by the source generator.
Use KeyMemberName
, KeyMemberAccessModifier
and KeyMemberKind
to change the generation of the key member.
Example: Let source generator generate field private readonly string _name;
instead of property public string Key { get; }
(Default).
[SmartEnum<string>(KeyMemberName = "_name",
KeyMemberAccessModifier = AccessModifier.Private,
KeyMemberKind = MemberKind.Field)]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
--------------------------
----- generated code ------
partial class ProductType
{
...
private readonly string _name;
...
Although the constructor is implemented by the source generator, still, the arguments can be validated in the partial
method ValidateConstructorArguments
. Please note, that the key must never be null
.
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
static partial void ValidateConstructorArguments(ref string key)
{
if (String.IsNullOrWhiteSpace(key))
throw new Exception("Key cannot be empty.");
key = key.Trim();
}
}
Additional fields and properties are passed to the method as well (see DisplayName
below):
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries", "Display name for groceries");
public static readonly ProductType Housewares = new("Housewares", "Display name for housewares");
public string DisplayName { get; }
static partial void ValidateConstructorArguments(ref string key, ref string displayName)
{
// validate
}
}
A validatable enum gets an additional parameter bool isValid
which is an indication what kind of item is being created.
[SmartEnum<string>(IsValidatable = true)]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
static partial void ValidateConstructorArguments(ref string key, bool isValid)
{
if (!isValid)
return;
if (String.IsNullOrWhiteSpace(key))
throw new Exception("Key cannot be empty.");
key = key.Trim();
}
}
By default, the source generator is using the default implementation of Equals
and GetHashCode
, except for strings
. If the key member is a string
, then the source generator is using StringComparer.OrdinalIgnoreCase
.
Additionally, the analyzer will warn you if you don't provide an equality comparer for a string-based value object.
The reason strings are not using default implementation is, because I encountered very few use cases where the comparison must be performed case-sensitive. Most often, case-sensitive string comparisons are bugs because the developers have forgotten to pass appropriate (case-insensitive) comparer.
Use KeyMemberEqualityComparerAttribute<TComparerAccessor, TMember>
to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors
or implement a new one (see below).
The example below changes the comparer from OrdinalIngoreCase
to Ordinal
.
[SmartEnum<string>]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
Implement the interface IEqualityComparerAccessor<T>
to create a new custom accessor. The accessor has 1 static property that returns an instance of IEqualityComparer<T>
. The generic type T
is the type of the member to compare.
public interface IEqualityComparerAccessor<in T>
{
static abstract IEqualityComparer<T> EqualityComparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IEqualityComparerAccessor<string>
{
public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
Use KeyMemberComparerAttribute<TComparerAccessor, TMember>
to specify a comparer. Use one of the predefined ComparerAccessors
or implement a new one (see below).
Please note that this section is about implementation of
IComparable<T>
andIComparer<T>
. Don't confuse theIComparer<T>
withIEqualityComparer<T>
which is being used for equality comparison and the computation of the hash code.
[SmartEnum<string>]
[KeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
Implement the interface IComparerAccessor<T>
to create a new custom accessor. The accessor has 1 static property that returns an instance of IComparer<T>
. The generic type T
is the type of the member to compare.
public interface IComparerAccessor<in T>
{
static abstract IComparer<T> Comparer { get; }
}
Implementation of an accessor for members of type string
.
public class StringOrdinal : IComparerAccessor<string>
{
public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}
Predefined accessors in static class ComparerAccessors
:
// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>
Use SmartEnumAttribute<T>
to set SkipIComparable
to true
to disable the implementation of IComparable
and IComparable<T>
.
[SmartEnum<int>(SkipIComparable = true)]
public partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set ComparisonOperators
to OperatorsGeneration.None
to disable the implementation of comparison operators: >
, >=
, <
, <=
.
Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads
to generate additional operators to be able to compare a Smart Enum with a value of the key-member type.
[SmartEnum<int>(ComparisonOperators = OperatorsGeneration.None)]
public partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipIParsable
to true
to disable the implementation of IParsable<T>
.
[SmartEnum<int>(SkipIParsable = true)]
public partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipIFormattable
to true
to disable the implementation of IFormattable
.
[SmartEnum<int>(SkipIFormattable = true)]
public partial class ProductGroup
{
Use SmartEnumAttribute<T>
to set SkipToString
to true
to disable the implementation of the method ToString()
.
[SmartEnum<int>(SkipToString = true)]
public partial class ProductGroup
{
With ObjectFactoryAttribute<T>
you can implement additional methods to be able to convert an item from/to type T
.
This conversion can be one-way (T
-> Smart Enum) or two-way (T
<-> Smart Enum).
Conversion from a
string
allows ASP.NET Model Binding to bind Smart Enums with any key-member type.
Example: Given is an int
-based Smart Enum MyEnum
.
[SmartEnum<int>]
public partial class MyEnum
{
public static readonly MyEnum Item1 = new(1);
}
The Smart Enum MyEnum
must be convertible from specific strings, e.g. the value =1=
should return Item1
.
By applying ObjectFactoryAttribute<string>
the source generator adds the interface IObjectFactory<MyEnum, string>
which forces you to implement new method Validate(string, IFormatProvider, out MyEnum)
. With new Validate
we have a one-way conversion, i.e. from string
to MyEnum
.
[SmartEnum<int>]
[ObjectFactory<string>]
public partial class MyEnum
{
public static readonly MyEnum Item1 = new(1);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out MyEnum? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
...
}
}
}
Two-way conversion is required if the type T
must be used for serialization/deserialization. This can be achieved by using the property UseForSerialization = SerializationFrameworks.All
. This leads to implementation of the interface IConvertible<string>
which contains the method T ToValue()
. The serialization frameworks (like System.Text.Json
) specified by SerializationFrameworks
are starting to prefer the newly implemented methods instead of using the key-member type, which is int
in our example.
[SmartEnum<int>]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public partial class EnumWithFactory
{
public static readonly EnumWithFactory Item1 = new(1);
public static readonly EnumWithFactory Item2 = new(2);
public static ValidationError? Validate(string? value, IFormatProvider? provider, out EnumWithFactory? item)
{
switch (value)
{
case "=1=":
item = Item1;
return null;
...
}
}
public string ToValue()
{
return $"={Key}=";
}
}
Use this feature with caution!
A Smart Enum must be immutable. Hiding a member from the Generator and Analyzer means that there is no validation of this member anymore.
Use IgnoreMemberAttribute
to hide a member.
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
// With IgnoreMemberAttribute the Analyzer doesn't emit a compiler error that the member is not read-only.
[IgnoreMember]
private string _someValue;
}
Smart Enums can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs.
To enable OpenAPI support for Smart Enums, register the Thinktecture OpenAPI filters with dependency injection:
services.AddEndpointsApiExplorer()
.AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }))
.AddThinktectureOpenApiFilters();
You can customize the OpenAPI schema generation with options:
services.AddThinktectureOpenApiFilters(options =>
{
// Configure the schema filter type
options.SmartEnumSchemaFilter = SmartEnumSchemaFilter.Default;
// Additional configure how Smart Enums are represented in the schema
options.SmartEnumSchemaExtension = SmartEnumSchemaExtension.VarNamesFromStringRepresentation;
});
Schema Filters
The SmartEnumSchemaFilter
option determines how Smart Enums are represented in the OpenAPI schema:
-
Default
: Usesenum: [ key1, key2 ]
-
OneOf
: UsesoneOf: [ { "title": "key1", "const": "key1" } ]
-
AnyOf
: UsesanyOf: [ { "title": "key1", "const": "key1" } ]
-
AllOf
: UsesallOf: [ { "title": "key1", "const": "key1" } ]
-
FromDependencyInjection
: Resolves implementation ofISmartEnumSchemaFilter
from dependency injection
Schema Extensions
The SmartEnumSchemaExtension
option controls what additional information is added to the schema:
-
None
(default): No additional schema extensions -
VarNamesFromStringRepresentation
: Extends the schema withx-enum-varnames
using the string representation of the items -
VarNamesFromDotnetIdentifiers
: Extends the schema withx-enum-varnames
using the .NET identifiers of the items -
FromDependencyInjection
: Resolves implementation ofISmartEnumSchemaExtension
from dependency injection
Smart Enums seamlessly integrate with popular .NET frameworks and libraries. This section covers integration with JSON serialization, MessagePack, ASP.NET Core, and Entity Framework Core.
Smart Enums support both major JSON serialization libraries in .NET:
You have two options for enabling JSON serialization:
The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well.
Both Nuget packages activate generation of additional code that flags the Smart Enum with a JsonConverterAttribute
. This way the Smart Enum can be converted to and from JSON without extra code.
If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.
- Use
ThinktectureJsonConverterFactory
withSystem.Text.Json
- Use
ThinktectureNewtonsoftJsonConverterFactory
withNewtonsoft.Json
An example for ASP.NET Core application using System.Text.Json
:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddJsonOptions(options => options.JsonSerializerOptions
.Converters
.Add(new ThinktectureJsonConverterFactory()));
})
An example for minimal apis:
var builder = WebApplication.CreateBuilder();
builder.Services
.ConfigureHttpJsonOptions(options => options.SerializerOptions
.Converters
.Add(new ThinktectureJsonConverterFactory()));
The code for Newtonsoft.Json
is almost identical:
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc()
.AddNewtonsoftJson(options => options.SerializerSettings
.Converters
.Add(new ThinktectureNewtonsoftJsonConverterFactory()));
})
MessagePack is a fast and compact binary serialization format. Smart Enums provide full support for MessagePack serialization.
You have two options for enabling MessagePack serialization:
The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well.
The Nuget package activates generation of additional code that flags the Smart Enums with a MessagePackFormatterAttribute
. This way the Smart Enum can be converted to and from MessagePack without extra code.
If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ThinktectureMessageFormatterResolver
directly, the Nuget package can be installed in any project where the MessagePack options are configured.
An example of a round-trip-serialization of the Smart Enum ProductType.Groceries
:
// Use "ThinktectureMessageFormatterResolver"
var resolver = CompositeResolver.Create(ThinktectureMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);
var productType = ProductType.Groceries;
// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(productType, options, CancellationToken.None);
// Deserialize from MessagePack
var deserializedproductType = MessagePackSerializer.Deserialize<ProductType>(bytes, options, CancellationToken.None);
For seamless integration with ASP.NET Core we need both, JSON serialization and model binding support.
Tip: See Convert from/to non-key type to customize model binding behavior by implementing string conversion methods.
The parameter binding of Minimal APIs is not as capable as the model binding of MVC controllers. To make a type bindable it has to implement either TryParse
or BindAsync
. A Smart Enum implements TryParse
(interface IParsable<T>
) if the key implements IParsable<T>
, so it can be used with Minimal Apis without any changes.
At the moment, all means (i.e. TryParse
and BindAsync
) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication (i.e. a bool
) whether the parameter could be bound or not.
ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductType
and receive the (invalid) value SomeValue
, then the ASP.NET Core ModelState
must become invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.
By rejecting the request, the client gets the status code BadRequest (400)
and the error:
{
"productType": [
"The enumeration item of type 'ProductType' with identifier 'SomeValue' is not valid."
]
}
To help out the Model Binding we have to register the ThinktectureModelBinderProvider
with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.
Place the "ThinktectureModelBinderProvider" before default providers, so they don't try to bind value objects.
var webHost = new HostBuilder()
.ConfigureServices(collection =>
{
collection.AddMvc(options => options.ModelBinderProviders
.Insert(0, new ThinktectureModelBinderProvider()));
})
The ThinktectureModelBinderProvider
supports several configuration options to customize how Smart Enums are handled during model binding:
// Configure with custom options
var provider = new ThinktectureModelBinderProvider(
skipBindingFromBody: true // Default: true
);
- trimStringBasedEnums: When true (default), automatically trims whitespace from string values before doing lookup.
- skipBindingFromBody: When true (default), skips model binding for values coming from request body, allowing the JSON serializer to handle the conversion instead.
Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, EF Core can convert a Smart Enum (like ProductType
) to and from a primitive type (like string
) when persisting the data and when reading the value from database.
Choose the package that matches your EF Core version:
You have three options for setting up EF Core integration:
The registration of a value converter can be done manually by using one of the method overloads of HasConversion
in OnModelCreating
.
// Entity
public class Product
{
// other properties...
public ProductType ProductType { get; private set; }
}
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.Property(p => p.ProductType)
.HasConversion(p => p.Key,
key => ProductType.Get(key));
});
}
}
Alternatively, you can install the appropriate Nuget package for EF Core 6, EF Core 7, EF Core 8 or EF Core 9 and use the extension method AddThinktectureValueConverters
to register the value converters for you.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddThinktectureValueConverters(validateOnWrite: true);
}
}
You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.
modelBuilder.AddThinktectureValueConverters(validateOnWrite: true,
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
You can also configure value converters at the entity level using the AddThinktectureValueConverters
extension method on EntityTypeBuilder
. This approach allows you to apply value converters only to specific entities.
public class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Product>(builder =>
{
builder.HasKey(p => p.Id);
// Apply value converters to all Smart Enums and Value Objects in this entity
builder.AddThinktectureValueConverters();
});
}
}
You can customize the conversion behavior with additional parameters:
builder.AddThinktectureValueConverters(
validateOnWrite: true, // Validate values before writing to database
useConstructorForRead: true, // Use constructor instead of factory method when reading
addConvertersForOwnedTypes: true, // Apply to owned entities recursively
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
});
This method is also available for owned entities through the OwnedNavigationBuilder
:
modelBuilder.Entity<Order>(builder =>
{
builder.OwnsOne(o => o.ShippingDetails, detailsBuilder =>
{
// Apply value converters to all Smart Enums and Value Objects in this owned entity
detailsBuilder.AddThinktectureValueConverters();
});
});
Method overload for ComplexPropertyBuilder
:
In EF Core 8+, you can also use the AddThinktectureValueConverters
extension method with ComplexPropertyBuilder
to apply value converters to complex types:
modelBuilder.Entity<Product>(builder =>
{
builder.ComplexProperty(p => p.Details, detailsBuilder =>
{
// Apply value converters to all Smart Enums and Value Objects in this complex type
detailsBuilder.AddThinktectureValueConverters(validateOnWrite: true);
});
});
The other option is to use the extension method UseThinktectureValueConverters
for the DbContextOptionsBuilder
.
services
.AddDbContext<DemoDbContext>(builder => builder
.UseThinktectureValueConverters(validateOnWrite: true,
configureEnumsAndKeyedValueObjects: property =>
{
if (property.ClrType == typeof(ProductType))
property.SetMaxLength(20);
})
When running on .NET 8 or higher, Smart Enums automatically use FrozenDictionary
for internal lookups, providing better performance compared to regular dictionaries.
String-based Smart Enums in .NET 9+ support ReadOnlySpan<char>
operations for improved performance:
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
// In .NET 9+, you can use ReadOnlySpan<char> for lookups
public static bool TryGet(ReadOnlySpan<char> key, out ProductType? item)
{
...
}
}
Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath
pointing to an existing(!) folder (like C:\temp\
). You can provide a file name (like samples_logs.txt
) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt
.
Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless,
ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique
is set tofalse
.
With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel
you can specify one of the following log levels: Trace
, Debug
, Information
(DEFAULT), Warning
, Error
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
...
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
<ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
<ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>
</PropertyGroup>
If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log
in the temp folder of the user the IDE/CLI is running with.
I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.
A common e-commerce requirement is handling different shipping methods, each with their own pricing rules and delivery estimates. Smart Enums can elegantly model this without conditional logic:
[SmartEnum<string>]
public partial class ShippingMethod
{
public static readonly ShippingMethod Standard = new(
"STANDARD",
basePrice: 5.99m,
weightMultiplier: 0.5m,
estimatedDays: 5,
requiresSignature: false);
public static readonly ShippingMethod Express = new(
"EXPRESS",
basePrice: 15.99m,
weightMultiplier: 0.75m,
estimatedDays: 2,
requiresSignature: true);
public static readonly ShippingMethod NextDay = new(
"NEXT_DAY",
basePrice: 29.99m,
weightMultiplier: 1.0m,
estimatedDays: 1,
requiresSignature: true);
private readonly decimal _basePrice;
private readonly decimal _weightMultiplier;
private readonly int _estimatedDays;
public bool RequiresSignature { get; }
public decimal CalculatePrice(decimal orderWeight)
{
return _basePrice + (orderWeight * _weightMultiplier);
}
public DateTime GetEstimatedDeliveryDate()
{
return DateTime.Today.AddDays(_estimatedDays);
}
}
Usage example:
public class OrderProcessor
{
public OrderSummary ProcessOrder(Order order, ShippingMethod shipping)
{
var shippingCost = shipping.CalculatePrice(order.Weight);
var deliveryDate = shipping.GetEstimatedDeliveryDate();
return new OrderSummary
{
OrderTotal = order.SubTotal + shippingCost,
EstimatedDelivery = deliveryDate,
RequiresSignature = shipping.RequiresSignature,
ShippingCost = shippingCost
};
}
}
This example demonstrates how Smart Enums can eliminate conditional logic by properly modeling domain concepts:
- Each shipping method encapsulates its pricing rules through properties
- Business logic is simplified by using declarative properties instead of switch statements
- Adding new shipping methods only requires defining their properties
Imagine we need an importer for daily and monthly sales.
The CSV for daily sales has the following columns: id,datetime,volume
. The datetime has a format yyyyMMdd hh:mm
.
id,datetime,volume
1,20230425 10:45,345.67
The CSV for monthly sales differs from time to time. It can have either 3 columns volume,datetime,id
or 4 columns volume,quantity,id,datetime
. If the CSV has 3 columns, then the datetime format is the same in daily imports (yyyyMMdd hh:mm
), but if there are 4 columns, then the format is yyyy-MM-dd
.
volume,datetime,id
123.45,20230426 11:50,2
OR
volume,quantity,id,datetime
123.45,42,2,2023-04-25
We are interested in id
, volume
and datetime
only.
With regular C#-enums we have to use either switch-case
or if-else
. The readability of the code is ok, but not the best one. Furthermore, if there will be added another type in the future, say Yearly
, then we have to remember to adjust the switch-case
otherwise we get ArgumentOutOfRangeException
.
public enum SalesCsvImporterType
{
Daily,
Monthly
}
// Usage
var type = SalesCsvImporterType.Monthly;
var csv = ...;
using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });
csvReader.Read();
csvReader.ReadHeader();
while (csvReader.Read())
{
int articleId;
decimal volume;
DateTime dateTime;
switch (type)
{
case SalesCsvImporterType.Daily:
articleId = csvReader.GetField<int>(0);
volume = csvReader.GetField<decimal>(2);
dateTime = DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null);
break;
case SalesCsvImporterType.Monthly:
articleId = csvReader.GetField<int>(2);
volume = csvReader.GetField<decimal>(0);
dateTime = csvReader.HeaderRecord?.Length == 3
? DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null) // same as "Daily"
: DateTime.ParseExact(csvReader[3], "yyyy-MM-dd", null);
break;
default:
throw new ArgumentOutOfRangeException();
}
logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}
As an alternative to switch-case
, we can move the parts that differ to a Smart Enum. The benefits are: (1) the actual importer is easier to read and to maintain, and (2) it is impossible to forget to adjust the code if another type, like Yearly
, is implemented in the future.
[SmartEnum<string>(KeyMemberName = "Name")]
public partial class SalesCsvImporterType
{
// Constructor is generated according to fields and properties of the smart enum.
// This prevents "forgetting" to provide values to members.
public static readonly SalesCsvImporterType Daily = new(name: "Daily", articleIdIndex: 0, volumeIndex: 2, GetDateTimeForDaily);
public static readonly SalesCsvImporterType Monthly = new(name: "Monthly", articleIdIndex: 2, volumeIndex: 0, GetDateTimeForMonthly);
public int ArticleIdIndex { get; }
public int VolumeIndex { get; }
// Alternative: use inheritance instead of delegate to have different implementations for different types
[UseDelegateFromConstructor]
public partial DateTime GetDateTime(CsvReader csvReader);
private static DateTime GetDateTimeForDaily(CsvReader csvReader)
{
return DateTime.ParseExact(csvReader[1] ?? throw new Exception("Invalid CSV"),
"yyyyMMdd hh:mm",
null);
}
private static DateTime GetDateTimeForMonthly(CsvReader csvReader)
{
return csvReader.HeaderRecord?.Length == 3
? GetDateTimeForDaily(csvReader)
: DateTime.ParseExact(csvReader[3] ?? throw new Exception("Invalid CSV"),
"yyyy-MM-dd",
null);
}
}
The smart enum SalesCsvImporterType
eliminates the need for a switch-case
.
var type = SalesCsvImporterType.Monthly;
var csv = ...;
using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });
csvReader.Read();
csvReader.ReadHeader();
while (csvReader.Read())
{
var articleId = csvReader.GetField<int>(type.ArticleIdIndex);
var volume = csvReader.GetField<decimal>(type.VolumeIndex);
var dateTime = type.GetDateTime(csvReader);
logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}
Discriminated union Jurisdiction requires a custom JSON converter for serialization of polymorphic types. The converter needs to know the type of the concrete object to be deserialized. This can be achieved by using a Smart Enum as a discriminator.
public partial class JurisdictionJsonConverter : JsonConverter<Jurisdiction>
{
public override Jurisdiction? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (!reader.Read() // read StartObject
|| !reader.Read() // read PropertyName
|| !Discriminator.TryGet(reader.GetString(), out var discriminator))
throw new JsonException();
var jurisdiction = discriminator.ReadJurisdiction(ref reader, options);
if (!reader.Read()) // read EndObject
throw new JsonException();
return jurisdiction;
}
public override void Write(
Utf8JsonWriter writer,
Jurisdiction value,
JsonSerializerOptions options)
{
value.Switch(
(writer, options),
country: static (state, country) =>
WriteJurisdiction(state.writer, state.options, country, Discriminator.Country),
federalState: static (state, federalState) =>
WriteJurisdiction(state.writer, state.options, federalState, Discriminator.FederalState),
district: static (state, district) =>
WriteJurisdiction(state.writer, state.options, district, Discriminator.District),
unknown: static (state, unknown) =>
WriteJurisdiction(state.writer, state.options, unknown, Discriminator.Unknown)
);
}
private static void WriteJurisdiction<T>(
Utf8JsonWriter writer,
JsonSerializerOptions options,
T jurisdiction,
string discriminator
)
where T : Jurisdiction
{
writer.WriteStartObject();
writer.WriteString("$type", discriminator);
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName("value") ?? "value");
JsonSerializer.Serialize(writer, jurisdiction, options);
writer.WriteEndObject();
}
[SmartEnum<string>]
internal partial class Discriminator
{
public static readonly Discriminator Country = new("Country", ReadJurisdiction<Jurisdiction.Country>);
public static readonly Discriminator FederalState = new("FederalState", ReadJurisdiction<Jurisdiction.FederalState>);
public static readonly Discriminator District = new("District", ReadJurisdiction<Jurisdiction.District>);
public static readonly Discriminator Unknown = new("Unknown", ReadJurisdiction<Jurisdiction.Unknown>);
[UseDelegateFromConstructor]
public partial Jurisdiction? ReadJurisdiction(ref Utf8JsonReader reader, JsonSerializerOptions options);
private static Jurisdiction? ReadJurisdiction<T>(
ref Utf8JsonReader reader,
JsonSerializerOptions options)
where T : Jurisdiction
{
if (!reader.Read() || !reader.Read()) // read PropertyName and value
throw new JsonException();
return JsonSerializer.Deserialize<T>(ref reader, options);
}
}
}
Usage
// Use JsonConverterAttribute or add the converter to JsonSerializerOptions
[Union]
[JsonConverter(typeof(JurisdictionJsonConverter))]
public abstract partial class Jurisdiction
{
...
}
-----------------
var json = JsonSerializer.Serialize<Jurisdiction>(district);
logger.Information(json); // {"$type":"District","value":"District 42"}
var deserializedJurisdiction = JsonSerializer.Deserialize<Jurisdiction>(json);
// Deserialized jurisdiction: District 42 (District)
logger.Information("Deserialized jurisdiction: {Jurisdiction} ({Type})",
deserializedJurisdiction,
deserializedJurisdiction?.GetType().Name);
In a web API, we can use Smart Enums to dispatch requests to different implementations or map the enum item to some internals. Similar behavior can be achieved with keyed-services for total flexibility, but ...
- Flexibility is not always needed
- Providing of all available options is not the responsibility of the dependency injection
- Tunneling through the DI container is another indirection which raises the complexity
- The keys of keyed-services should not be used inside DTOs or as route parameters directly (security, whitelisting)
// Smart enum representing DTO for notification channel types.
[SmartEnum<string>(KeyMemberName = "Name")]
public abstract partial class NotificationChannelTypeDto
{
public static readonly NotificationChannelTypeDto Email = new NotificationTypeDto<EmailNotificationSender>("email");
public static readonly NotificationChannelTypeDto Sms = new NotificationTypeDto<SmsNotificationSender>("sms");
// Add further properties and methods as needed.
public abstract INotificationSender GetNotificationSender(IServiceProvider serviceProvider);
// Generic derived class that captures the implementation type.
// Having concrete (generic) type, we get a compile time type check (in comparison to "typeof(EmailNotificationSender)")
// and we could do advanced stuff inside the derived class if we want to.
private sealed class NotificationTypeDto<TImplementation> : NotificationChannelTypeDto
where TImplementation : class, INotificationSender
{
public NotificationTypeDto(string key)
: base(key)
{
}
public override INotificationSender GetNotificationSender(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredService<TImplementation>();
}
}
}
// Supporting interface and classes
public interface INotificationSender
{
Task SendAsync(string message);
}
public class EmailNotificationSender(ILogger<EmailNotificationSender> logger) : INotificationSender
{
public Task SendAsync(string message)
{
logger.LogInformation("Sending email: {Message}", message);
return Task.CompletedTask;
}
}
public class SmsNotificationSender(ILogger<SmsNotificationSender> logger) : INotificationSender
{
public Task SendAsync(string message)
{
logger.LogInformation("Sending sms: {Message}", message);
return Task.CompletedTask;
}
}
ASP.NET Core controller or minimal API provide 2 endpoints. One endpoint returns all available channels, the other one sends a notification using the selected channel.
The first endpoint responds with ["email","sms"]
.
// Web API Controller
[HttpGet("notification/channels")]
public IActionResult GetAvailableChannels()
{
var channels = NotificationChannelTypeDto.Items.Select(c => c.Name);
return Ok(channels);
}
// Minimal API
app.MapGet("notification/channels", () =>
{
var channels = NotificationChannelTypeDto.Items.Select(c => c.Name);
return Results.Ok(channels);
});
The NotificationChannelTypeDto
is automatically validated and bound to the route parameter {type}
.
For request POST notification/channels/email
and body "Test email"
the second endpoints emits a log message Sending email: Test email
.
// Web API Controller
[HttpPost("notification/channels/{type}")]
public async Task<IActionResult> SendNotificationAsync(
NotificationChannelTypeDto type,
[FromBody] string message,
[FromServices] IServiceProvider serviceProvider)
{
var notificationSender = type.GetNotificationSender(serviceProvider);
await notificationSender.SendAsync(message);
return Ok();
}
// Minimal API
app.MapPost("notification/channels/{type}",
async (
NotificationChannelTypeDto type,
[FromBody] string message,
[FromServices] IServiceProvider serviceProvider) =>
{
var notificationSender = type.GetNotificationSender(serviceProvider);
await notificationSender.SendAsync(message);
});