Skip to content

smart enums adding domain logic

Pawel Gerr edited this page Aug 4, 2025 · 1 revision

Smart Enums: Adding Domain Logic to Enumerations in .NET

Article series

  1. Smart Enums: Beyond Traditional Enumerations in .NET
  2. Smart Enums: Adding Domain Logic to Enumerations in .NET ⬅

Introduction

In the first article, we established Smart Enums as a type-safe, flexible alternative to traditional C# enums, capable of using various key types and encapsulating basic data. Additionally, one of the most significant limitations of standard enums is their inability to directly hold behavior. Logic related to enum values often gets scattered across the codebase in switch statements or extension methods, leading to maintenance challenges.

This article explores how Smart Enums overcome this limitation by allowing you to embed domain-specific logic directly within the enumeration definition. We’ll examine different techniques for implementing behavior-rich Smart Enums using the library Thinktecture.Runtime.Extensions, making the code more cohesive, object-oriented, and easier to maintain.

Providing solutions via a .NET library

The library Thinktecture.Runtime.Extensions was created to address a fundamental challenge in modern C# development: providing robust, type-safe domain modeling without excessive boilerplate code.

The library was initially developed using reflection to provide a more streamlined implementation of Smart Enums. Though this approach worked, it still required some boilerplate code and lacked flexibility in terms of customization options. A significant evolution came with the introduction of Roslyn Source Generators in .NET 5 (2020). This technology opened new possibilities for the library, enabling compile-time code generation with much greater flexibility. Shortly after source generators became available, the library was completely rewritten to leverage this approach.

Roslyn Source Generators are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to generate code at compile time. The source generators can read existing C# code and other files, analyze them, and then produce new C# code that is added to the compilation.

Technical Challenges with Behavior in Traditional Enums

Associating behavior with traditional C# enums typically involves workarounds:

  • Switch Statements: Placing logic in switch statements wherever the enum is used. This duplicates logic and is error-prone – forgetting to update a switch when adding a new enum member leads to runtime errors or incorrect behavior.
  • Extension Methods: Encapsulating behavior in static extension methods. This improves organization but still separates the logic from the enum definition itself.
  • Dictionaries: Mapping enum values to delegates (Func or Action) stored in a dictionary. This requires careful setup and management.
  • Attributes and Reflection: Decorating enum values with custom attributes and using reflection to access this metadata at runtime. This approach incurs performance overhead and lacks compile-time safety.

These approaches decouple the behavior from the enumeration values, increasing the risk of inconsistencies and making the codebase harder to understand and evolve.

The Smart Enum Evolution: From Types to Behaviors

Smart Enums, being classes, can naturally contain methods and properties, allowing behavior to be co-located with the enumeration instances. Thinktecture.Runtime.Extensions supports several ways to implement item-specific behavior:

  1. Standard Methods: If the behavior is the same for all items, add regular instance methods.
  2. Delegate Fields: Store delegates (Func/Action) as fields within the Smart Enum, assigned in the constructor.
  3. UseDelegateFromConstructorAttribute: A streamlined way (provided by the library) to achieve the delegate pattern with less boilerplate.
  4. Inheritance: Define an abstract method in the base Smart Enum class and provide concrete implementations in nested derived classes for specific items.

Let’s briefly recap the basics from the first article and then expand on these approaches with more advanced examples.

In the first article, we introduced how Smart Enums can encapsulate data. Now, let’s see how they can also encapsulate behavior with a practical example. Imagine a logistics system that manages different shipping methods, each with different pricing rules.

Traditional Approach

Below is a typical implementation using traditional C# enums with external helper method. Notice how the enum itself only defines the identifiers, while all logic related to pricing is externalized in the ShippingService class.

public enum ShippingType
{
   Standard = 1,
   Express = 2
}

// ⚠️️ Logic scattered in helper classes or services
public class ShippingService
{
   public decimal CalculatePrice(ShippingType type, decimal weight)
   {
      var (basePrice, multiplier) = type switch
      {
         ShippingType.Standard => (5.99m, 0.5m),
         ShippingType.Express => (15.99m, 0.75m),
         _ => throw new ArgumentOutOfRangeException(nameof(type))
      };

      return basePrice + (weight * multiplier);
   }
}

Adding a new shipping type requires modifying CalculatePrice.

Smart Enum Approach

In contrast, here’s how we can model the same shipping methods using a Smart Enum. This approach encapsulates both the data and behavior within a single class, providing a cleaner, more maintainable solution where related concepts are kept together.

Representing physical quantities like weight accurately is a common use case for Value Objects. See the article Value Objects: Solving Primitive Obsession in .NET for details.

[SmartEnum<string>]
public partial class ShippingMethod
{
   // Constructor now includes parameters for behavior/data
   public static readonly ShippingMethod Standard = new(
      key: "STANDARD",
      basePrice: 5.99m,
      weightMultiplier: 0.5m);

   public static readonly ShippingMethod Express = new(
      key: "EXPRESS",
      basePrice: 15.99m,
      weightMultiplier: 0.75m);

   // Store behavior-related data directly as fields and properties
   private readonly decimal _basePrice;
   private readonly decimal _weightMultiplier;

   // Behavior is implemented as instance methods
   public decimal CalculatePrice(decimal orderWeight)
   {
      return _basePrice + (orderWeight * _weightMultiplier);
   }
}

The usage is as follows:

var method = ShippingMethod.Express;
decimal weight = 2.5m; // in kg (candidate for a value object for more semantics and validation)
decimal price = method.CalculatePrice(weight);

Here, all data (_basePrice, _weightMultiplier) and behavior (CalculatePrice) related to a shipping method are encapsulated within the ShippingMethod class. Adding a new method involves adding one new public static readonly field and providing its specific data in the constructor call. The consuming code doesn’t need switch statements for this logic.

Implementing Item-Specific Behavior

What if the logic itself needs to vary per item, not just the data? Let’s examine this concept using payment methods as our example, where each payment method requires different processing logic.

To demonstrate this, we’ll start by defining our basic data model for payment processing. We will use PaymentRequest as input for processing a payment and PaymentResult as the result.

public record PaymentRequest(decimal Amount, string Currency);
public record PaymentResult(bool Success, string TransactionId);

📝 The combination of properties Amount and Currency in the PaymentRequest is an excellent candidate for being modeled as Complex Value Object to encapsulate its specific rules and constraints. This concept is covered in the article Handling Complexity: Introducing Complex Value Objects in .NET.

⚠️ The PaymentResult class shown here uses a simple boolean flag for success/failure status for simplicity. In production code, a better approach would be to model this as a discriminated union with distinct success and failure cases, each containing appropriate data. This pattern is covered in detail in Discriminated Unions: Representation of Alternative Types in .NET.

We’ll focus on two key implementation approaches here. The manual “Delegate Fields” approach (storing delegates as fields within the Smart Enum) won’t be covered in detail as it follows the same pattern as the UseDelegateFromConstructorAttribute approach but requires more boilerplate code. Instead, we’ll start with the more streamlined approach:

Using UseDelegateFromConstructorAttribute

This attribute tells the source generator to expect a delegate in the constructor and wire it up to a partial method. It’s clean and avoids manual delegate fields.

[SmartEnum<string>]
public partial class PaymentMethod
{
   public static readonly PaymentMethod CreditCard = new("CC", ProcessCreditCardAsync);
   public static readonly PaymentMethod BankTransfer = new("BT", ProcessBankTransferAsync);

   // Define a partial method
   [UseDelegateFromConstructor]
   public partial Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);

   // Static methods implementing the specific logic for each item
   private static async Task<PaymentResult> ProcessCreditCardAsync(PaymentRequest request)
   {
      return new PaymentResult(true, "123");
   }

   private static async Task<PaymentResult> ProcessBankTransferAsync(PaymentRequest request)
   {
      return new PaymentResult(true, "456");
   }
}

// Usage
var method = PaymentMethod.CreditCard;
var request = new PaymentRequest(50.00m, "EUR");
PaymentResult result = await method.ProcessPaymentAsync(request); // Calls ProcessCreditCardAsync

The library creates the necessary private field for the delegate and implements the ProcessPaymentAsync partial method to invoke the correct delegate based on the instance (CreditCard or BankTransfer).

Using Inheritance

Another approach involves defining the Smart Enum as abstract and implementing specific items as nested private classes that inherit from it. This approach leverages object-oriented principles to enable polymorphism, with each subclass implementing its own variation of behavior:

[SmartEnum<string>]
public abstract partial class PaymentMethod
{
   public static readonly PaymentMethod CreditCard = new CreditCardPaymentMethod();
   public static readonly PaymentMethod BankTransfer = new BankTransferPaymentMethod();

   // Base abstract method
   public abstract Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);

   private sealed class CreditCardPaymentMethod : PaymentMethod
   {
      public CreditCardPaymentMethod() : base("CC") { } // Pass key to base constructor

      public override async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
      {
         return new PaymentResult(true, "123");
      }
   }

   private sealed class BankTransferPaymentMethod : PaymentMethod
   {
      public BankTransferPaymentMethod() : base("BT") { }

      public override async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
      {
         return new PaymentResult(true, "456");
      }
   }
}

The usage of the method ProcessPaymentAsync is unchanged.

Inheritance works well but can be slightly more verbose than the delegate approach, especially if the behavior is simple. Choose based on complexity and team preference.

Pattern Matching with Smart Enums

While embedding behavior directly within Smart Enum instances is the preferred approach, there are scenarios where external code needs to perform different actions based on the specific Smart Enum instance. The library generates utility methods to handle these situations.

The Methods "Switch" and "Map"

Every Smart Enum type is automatically equipped with Switch and Map methods that provide an exhaustive, type-safe alternative to traditional switch statements. Here’s a simple example demonstrating how to use these methods:

[SmartEnum<string>]
public partial class PaymentMethod
{
   public static readonly PaymentMethod CreditCard = new("CC");
   public static readonly PaymentMethod BankTransfer = new("BT");
}

// External service with dependencies that wouldn't make sense in the Smart Enum
public class PaymentGateway
{
   public async Task<PaymentResult> ProcessPayment(PaymentMethod method, decimal amount)
   {
      return await method.Switch(
         creditCard: () => ProcessCreditCardPayment(amount),
         bankTransfer: () => ProcessBankTransferPayment(amount)
      );
   }

   public string GetDisplayName(PaymentMethod method)
   {
      return method.Map(
         creditCard: "Credit Card",
         bankTransfer: "Bank Transfer"
      );
   }

   private Task<PaymentResult> ProcessCreditCardPayment(decimal amount) { /* ... */ }
   private Task<PaymentResult> ProcessBankTransferPayment(decimal amount) { /* ... */ }
}

The advantage of these methods over standard switch statements is that they are compile-time safe and have no need for default handlers. If a new PaymentMethod is added but forget to handle it in a Switch or Map call, the compiler will generate an error.

The "Partial" Variants: "SwitchPartially" and "MapPartially"

While the standard Switch and Map methods ensure exhaustive handling of all enum values, there are scenarios where handling only specific cases with a default behavior for others is more practical. For these situations, Thinktecture.Runtime.Extensions provides “Partial” variants: SwitchPartially and MapPartially.

⚠️ These partial methods are not generated by default for safety reasons. The compiler’s ability to detect missing cases is a significant benefit of standard Smart Enum pattern matching, and partial methods deliberately circumvent this safety mechanism. Therefore, they must be explicitly enabled.

To enable the partial variants, set the SwitchMethods and/or MapMethods properties in the SmartEnum attribute:

[SmartEnum<string>(
    SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
    MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class NotificationImportance
{
    public static readonly NotificationImportance Low = new("LOW");
    public static readonly NotificationImportance Medium = new("MEDIUM");
    public static readonly NotificationImportance High = new("HIGH");
    public static readonly NotificationImportance Critical = new("CRITICAL");
}

The methods SwitchPartially/MapPartially allow you to handle specific enum values with dedicated logic while providing a default handler/value for any unspecified enum value:

NotificationImportance importance = NotificationImportance.Critical;

// Only handle Critical and High specifically, use default for others
importance.SwitchPartially(
   critical: () => { /* handle critical notification */ },
   high: () => { /* handle notification with high importance */ },

   // Default handler receives the actual enum instance
   @default: importance => { /* handle the rest */ }
);

SwitchPartially supports usage without a default handler as well:

importance.SwitchPartially(
   critical: () => { /* handle critical notification */ }
);

Similarly, MapPartially lets you handle specific enum values while providing a default value for others:

var displayName = importance.MapPartially(
   critical: "Critical",
   @default: "Default"
);

Technical Example: Notification Dispatcher

The following example demonstrates a technical utility pattern rather than business logic. This approach serves as a means to an end for service resolution, similar to the “keyed dependencies” feature introduced in later .NET versions. However, the Smart Enum implementation provides stronger type safety by leveraging the C# type system rather than relying on runtime keys.

Smart Enums can integrate with Dependency Injection to dispatch operations to different service implementations. Imagine we have a notification sender with 2 concrete implementations, for email and SMS:

public interface INotificationSender
{
   Task SendAsync(string message);
}

public class EmailSender : INotificationSender
{
   public async Task SendAsync(string message)
   {
      // Send email
   }
}

public class SmsSender : INotificationSender
{
   public async Task SendAsync(string message)
   {
      // Send SMS
   }
}

Now that we have our senders, we can create a Smart Enum that acts as a type-safe dispatcher for these different notification channels:

[SmartEnum<string>]
public abstract partial class NotificationChannel
{
   public static readonly NotificationChannel Email = new TypedChannel<EmailSender>("EMAIL");
   public static readonly NotificationChannel Sms = new TypedChannel<SmsSender>("SMS");

   // Abstract method to get the correct sender via DI
   public abstract INotificationSender GetSender(IServiceProvider serviceProvider);

   // Private nested generic class
   private sealed class TypedChannel<TSender> : NotificationChannel
      where TSender : class, INotificationSender // ⚠️ Ensures TSender is a valid sender
   {
      public TypedChannel(string key) : base(key) { }

      public override INotificationSender GetSender(IServiceProvider serviceProvider)
      {
         return serviceProvider.GetRequiredService<TSender>();
      }
   }
}

This NotificationChannel can now be used within a notification service that delegates to the appropriate implementation based on the channel selection:

public class NotificationService(IServiceProvider serviceProvider)
{
   public async Task SendNotificationAsync(NotificationChannel channel, string message)
   {
      // Get the correct sender instance using the Smart Enum and DI
      INotificationSender sender = channel.GetSender(serviceProvider);

      await sender.SendAsync(message);
   }
}

// Usage
NotificationService notificationService = .. .;
NotificationChannel channel = NotificationChannel.Email;

await notificationService.SendNotificationAsync(channel, "Your order has shipped!");

This pattern provides a type-safe way to select and resolve different service implementations based on the Smart Enum value, avoiding manual factory logic or complex DI configurations. Unlike keyed dependencies, it also creates an implicit whitelist of permitted services, reducing potential security risks when the channel selection comes from external sources such as web APIs.

Summary

Smart Enums solve the limitation of traditional enums by allowing behavior to be encapsulated directly within the enumeration definition. The various implementation techniques—direct methods, inheritance, delegate fields, and the UseDelegateFromConstructorAttribute—provide flexible options to match specific domain requirements for item-specific logic. Co-locating data and behavior improves code cohesion, readability, and maintainability, as related concepts stay together rather than being scattered across the codebase. By enabling polymorphic behavior, Smart Enums reduce the need for conditional logic (switch, if/else) in consuming code, leading to cleaner, more object-oriented designs.

The next article in this series discusses the practical considerations of using the Smart Enums with common .NET frameworks like ASP.NET Core, Entity Framework Core, and serialization libraries.

Clone this wiki locally