Skip to content

PawelGerr/Thinktecture.Runtime.Extensions

Repository files navigation

Build TestResults NuGet Downloads

Thinktecture.Runtime.Extensions
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7
Thinktecture.Runtime.Extensions.EntityFrameworkCore8
Thinktecture.Runtime.Extensions.Json
Thinktecture.Runtime.Extensions.Newtonsoft.Json
Thinktecture.Runtime.Extensions.MessagePack
Thinktecture.Runtime.Extensions.AspNetCore

This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums and Value Objects.

Documentation for version 7

See wiki for more documentation.

I recently started writing down some ideas and real-world use cases I used in the past to show the developers the benefits of value objects and smart enums. More examples will come very soon!

Smart Enums:

Value objects:

Requirements

  • Version 7:
    • C# 11 (or higher) for generated code
    • SDK 7.0.401 (or higher) for building projects
  • Version 6:
    • C# 11 (or higher) for generated code
    • SDK 7.0.202 (or higher) for building projects
  • Version 5:
    • C# 9 (or higher) for generated code
    • SDK 6.0.300 (or higher) for building projects

Migrations

Smart Enums

Install: Install-Package Thinktecture.Runtime.Extensions

Documentation: Smart Enums

Features:

Definition of a 2 Smart Enums without any custom properties and methods. All other features mentioned above are generated by the Roslyn Source Generators in the background.

// Smart Enum with a string as the underlying type
[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

// Smart Enum with an int as the underlying type
[SmartEnum<int>]
public sealed partial class ProductGroup
{
   public static readonly ProductGroup Apple = new(1);
   public static readonly ProductGroup Orange = new(2);
}

// Smart Enum without identifier (keyless)
[SmartEnum]
public sealed partial class SalesCsvImporterType
{
   public static readonly SalesCsvImporterType Daily = new(articleIdIndex: 0, volumeIndex: 2);
   public static readonly SalesCsvImporterType Monthly = new(articleIdIndex: 2, volumeIndex: 0);

   public int ArticleIdIndex { get; }
   public int VolumeIndex { get; }
}

Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...

// A private constructor which takes the key "Groceries" and additional members (if we had any)
[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   ...

------------

// A property for iteration over all items
IReadOnlyList<ProductType> allTypes = ProductType.Items;

------------

// Getting the item with specific name, i.e. its key.
// Throws UnknownEnumIdentifierException if the provided key doesn't belong to any item
ProductType productType = ProductType.Get("Groceries");

// Alternatively, using an explicit cast (behaves the same as "Get")
ProductType productType = (ProductType)"Groceries";

------------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool found = ProductType.TryGet("Groceries", out ProductType? productType);

------------

// similar to TryGet but accepts `IFormatProvider` and returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductType.Validate("Groceries", null, out ProductType? productType);

if (validationError is null)
{
    logger.Information("Product type {Type} found with Validate", productType);
}
else
{
    logger.Warning("Failed to fetch the product type with Validate. Validation error: {validationError}", validationError.ToString());
}

------------

// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"

------------

// Equality comparison
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);

------------

// Equality comparison with '==' and '!='
bool equal = ProductType.Groceries == ProductType.Groceries;
bool notEqual = ProductType.Groceries != ProductType.Groceries;

------------

// Hash code
int hashCode = ProductType.Groceries.GetHashCode();

------------

// 'ToString' implementation
string key = ProductType.Groceries.ToString(); // "Groceries"

------------

ILogger logger = ...;

// Switch-case (with "Action")
productType.Switch(ProductType.Groceries, () => logger.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, () => logger.Information("Switch with Action: Housewares"));
                   
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
                   ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));

// Switch case returning a value (Func<TResult>)
var returnValue = productType.Switch(ProductType.Groceries, static () => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, static () => "Switch with Func<T>: Housewares");

// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
var returnValue = productType.Switch(logger,
                                     ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, static l => "Switch with Func<T>: Housewares");

// Map an item to another instance
returnValue = productType.Map(ProductType.Groceries, "Map: Groceries",
                              ProductType.Housewares, "Map: Housewares");
------------

// Implements IParsable<T> which is especially helpful with minimal apis.
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedProductType);

------------

// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
var formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // 001

------------

// Implements IComparable and IComparable<T> if the key member type (like int) is an IComparable itself.
var comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables); // -1

// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
var isBigger = ProductGroup.Fruits > ProductGroup.Vegetables;       

Definition of a new Smart Enum with 1 custom property RequiresFoodVendorLicense and 1 method Do with different behaviors for different enum items.

[SmartEnum<string>]
public partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries",  requiresFoodVendorLicense: true);
   public static readonly ProductType Housewares = new HousewaresProductType();

   public bool RequiresFoodVendorLicense { get; }

   public virtual void Do()
   {
      // do default stuff
   }

   private sealed class HousewaresProductType : ProductType
   {
      public HousewaresProductType()
         : base("Housewares", requiresFoodVendorLicense: false)
      {
      }

      public override void Do()
      {
         // do special stuff
      }
   }
}

Value Objects

Install: Install-Package Thinktecture.Runtime.Extensions

Documentation: Value Objects

Features:

Simple Value Object

A simple value object has 1 field/property only, i.e., it is kind of wrapper for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some rules. In DDD (domain-driven design), working with primitive types, like string, directly is called primitive obsession and should be avoided.

Most simple value objects with a key member of type string and another one (which is a struct) with an int.

[ValueObject<string>]
public sealed partial class ProductName
{
}

[ValueObject<int>]
public readonly partial struct Amount
{
}

After the implementation of a value object, a Roslyn source generator kicks in and implements the rest. Following API is available from now on.

// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
ProductName apple = ProductName.Create("Apple");

// Alternatively, using an explicit cast, which behaves the same way as calling "ProductName.Create"
ProductName apple = (ProductName)"Apple";

-----------

// The same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Chocolate", out ProductName? chocolate);

-----------

// Similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductName.Validate("Chocolate", null, out var chocolate);

if (validationError is null)
{
    logger.Information("Product name {Name} created", chocolate);
}
else
{
    logger.Warning("Failed to create product name. Validation result: {validationError}", validationError.ToString());
}

-----------

// Implicit conversion to the type of the key member
string valueOfTheProductName = apple; // "Apple"

-----------

// Equality comparison compares the key member using default comparer by default.
// Key members of type `string` are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = apple.Equals(apple);

-----------

// Equality comparison operators: '==' and '!='
bool equal = apple == apple;
bool notEqual = apple != apple;

-----------

// Hash code: combined hash code of type and key member. 
// Strings are using 'StringComparer.OrdinalIgnoreCase' by default.
int hashCode = apple.GetHashCode();

-----------

// 'ToString' implementation return the string representation of the key member
string value = apple.ToString(); // "Apple"

------------

// Implements IParsable<T> which is especially helpful with minimal apis.
bool success = ProductName.TryParse("New product name", null, out var productName);

ProductName productName = ProductName.Parse("New product name", null);

------------

// Implements "IFormattable" if the key member is an "IFormattable".
Amount amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"

------------

// Implements "IComparable<ProductName>" if the key member is an "IComparable",
// or if custom comparer is provided.
Amount amount = Amount.Create(1);
Amount otherAmount = Amount.Create(2);

int comparison = amount.CompareTo(otherAmount); // -1

------------

// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
bool isBigger = amount > otherAmount;

// Implements comparison operators to compare the value object with an instance of key-member-type,
// if "ComparisonOperators" is set "OperatorsGeneration.DefaultWithKeyTypeOverloads"
bool isBigger = amount > 2;

------------

// Implements addition / subtraction / multiplication / division if the key member supports corresponding operators
Amount sum = amount + otherAmount;

// Implements operators that accept an instance of key-member-type,
// if the "OperatorsGeneration" is set "DefaultWithKeyTypeOverloads"
Amount sum = amount + 2;

------------

// Provides a static default value "Empty" (similar to "Guid.Empty"), if the value object is a struct
Amount defaultValue = Amount.Empty; // same as "Amount defaultValue = default;"

Complex Value Objects

A complex value object is an immutable class or a readonly struct with a ComplexValueObjectAttribute. Complex value object usually has multiple readonly fields/properties.

A simple example would be a Boundary with 2 properties. One property is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.

[ComplexValueObject]
public sealed partial class Boundary
{
   public decimal Lower { get; }
   public decimal Upper { get; }
}

The rest is implemented by a Roslyn source generator, providing the following API:

// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
Boundary boundary = Boundary.Create(lower: 1, upper: 2);

-----------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = Boundary.TryCreate(lower: 1, upper: 2, out Boundary? boundary);

-----------

// similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = Boundary.Validate(lower: 1, upper: 2, out Boundary? boundary);

if (validationError is null)
{
    logger.Information("Boundary {Boundary} created", boundary);
}
else
{
    logger.Warning("Failed to create boundary. Validation result: {validationError}", validationError.ToString());
}

-----------

// Equality comparison compares the members using default or custom comparers.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = boundary.Equals(boundary);

-----------

// Equality comparison with '==' and '!='
bool equal = boundary == boundary;
bool notEqual = boundary != boundary;

-----------

// Hash code of the members according default or custom comparers
int hashCode = boundary.GetHashCode();

-----------

// 'ToString' implementation
string value = boundary.ToString(); // "{ Lower = 1, Upper = 2 }"