Creating Data Types
Custom data types extend the type system with domain-specific storage, validation, and formatting. This guide walks through creating new data types for both string and decimal values.
Base Classes
Choose the appropriate base class for your data type:
| Base Class | CLR Type | Built-in Behavior |
|---|---|---|
DataTypeBase |
Any | Abstract base — no built-in behavior |
StringDataTypeBase |
string |
Automatic trimming to max length |
DecimalDataTypeBase |
decimal |
Automatic rounding to stored precision |
Text |
string |
String with max 100 chars (inherits StringDataTypeBase) |
Decimal |
decimal |
Decimal with 2/2 precision (inherits DecimalDataTypeBase) |
Most custom types inherit from Text or Decimal rather than the abstract bases.
Creating a String Data Type
Step 1: Define the Type
using Benevia.Core.Annotations;
using Benevia.Core.Events.DataTypes;
using Benevia.Core.Events.DataTypes.Attributes;
namespace Benevia.ERP.Model.Products;
[DataType<string>]
[DataAnnotations("Unit of measure name", "MaxLength(10)")]
public partial class UomName : StringDataTypeBase
{
}
Required elements:
partialclass[DataType<string>]— declares the CLR type[DataAnnotations("description", annotations)]— adds data annotation attributes- Inherit from
StringDataTypeBase,Text, or another string type
The [DataAnnotations] attribute takes a description string and one or more annotation strings that map to System.ComponentModel.DataAnnotations attributes (e.g., "MaxLength(50)" generates [MaxLength(50)] on properties using this type).
Step 2: Use It
[Property<UomName>("Name")]
public partial string Name { get; set; }
With Inheritance
Inherit from an existing type to reuse its configuration:
// Inherits Text's behavior but changes max length
[DataType<string>]
[DataAnnotations("Email", "MaxLength(50)")]
public partial class Email : Text
{
public override Type UnderlyingType { get; init; } = typeof(string);
}
Data annotations are cumulative when inheriting — the child type gets both parent and its own annotations.
Creating a Decimal Data Type
Step 1: Define the Type
using Benevia.Core.Annotations;
using Benevia.Core.Events.DataTypes;
using Benevia.Core.Events.DataTypes.Attributes;
namespace Benevia.ERP.Model.Products;
[DataType<decimal>]
[DecimalPrecision(10, 2)]
public partial class ProductQuantity : DataTypes.Decimal
{
}
The [DecimalPrecision(storedDecimals, visualDecimals)] attribute controls:
- Stored decimals: Precision stored in the database (values are rounded to this)
- Visual decimals: Decimal places shown in the UI
// Store up to 10 decimal places, show 2 in the UI
[DecimalPrecision(10, 2)]
public partial class ProductQuantity : DataTypes.Decimal { }
// Store and show 4 decimal places
[DecimalPrecision(4, 4)]
public partial class ExchangeRate : DataTypes.Decimal { }
Step 2: Use It
[Property<ProductQuantity>("Quantity (Each)")]
public partial decimal Quantity { get; set; }
Adding Auto-Correct
Auto-correct transforms values when a property is set. Create a [Logic] class:
using Benevia.Core.Events.BusinessLogicDefinitions;
using Benevia.Core.Events.LogicBuilders;
namespace Benevia.Core.Events.DataTypes;
[Logic]
public class IdTextBL(IdText.Logic dataType)
{
[RegisterLogic]
public void IdText_AutoCorrect()
{
dataType.AutoCorrect().Transform(NormalizeForBarcode);
}
string NormalizeForBarcode(string input)
{
if (string.IsNullOrEmpty(input))
return input;
var upperCase = input.ToUpperInvariant();
var sb = new StringBuilder();
foreach (char c in upperCase)
if (IsValidBarcode39Char(c))
sb.Append(c);
return sb.ToString();
}
static readonly char[] allowedSymbols = [' ', '-', '.', '/', '+', '%'];
bool IsValidBarcode39Char(char c) =>
(c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || allowedSymbols.Contains(c);
}
Key elements:
- Class has
[Logic]attribute - Uses primary constructor injection of
DataType.Logic(e.g.,IdText.Logic) - Methods with
[RegisterLogic]run at startup to wire up event subscriptions AutoCorrect().Transform(func)registers a transformation function
Title Case Example
[Logic]
public class ProperNounBL(ProperNoun.Logic dataType)
{
[RegisterLogic]
public void ProperNoun_TitleCase()
{
dataType.AutoCorrect().Transform(ToTitleCase);
}
string ToTitleCase(string input)
{
if (string.IsNullOrEmpty(input))
return input;
var chars = input.Trim().ToCharArray();
bool capitalizeNext = true;
for (int i = 0; i < chars.Length; i++)
{
if (!char.IsLetter(chars[i]))
{
capitalizeNext = true;
}
else if (capitalizeNext)
{
chars[i] = char.ToUpper(chars[i]);
capitalizeNext = false;
}
}
return new string(chars);
}
}
Adding Validation
Validation runs before save and rejects invalid values with error messages:
using Benevia.Core.Events.BusinessLogicDefinitions;
using Benevia.Core.Events.LogicBuilders;
namespace Benevia.Core.Events.DataTypes;
[Logic]
public class EmailBL(Email.Logic dataType)
{
[RegisterLogic]
public void Email_Validate()
{
dataType.Validate()
.RejectIf(x => !IsValid(x))
.WithMessage("The email address is not valid.");
}
bool IsValid(string emailAddress)
{
if (string.IsNullOrWhiteSpace(emailAddress))
return true;
if (!MailAddress.TryCreate(emailAddress, out var email))
return false;
var hostParts = email.Host.Split('.');
if (hostParts.Length == 1)
return false;
if (hostParts.Any(p => p == string.Empty))
return false;
if (hostParts[^1].Length < 2)
return false;
return email.Address == emailAddress;
}
}
Key elements:
Validate().RejectIf(predicate).WithMessage(message)— rejects when predicate returnstrue- Return
truefor empty/null values if the field is optional (use[Required]separately for that)
Positive Value Validation
[Logic]
public class PositiveDecimalBL(PositiveDecimal.Logic dataType)
{
[RegisterLogic]
public void Validate()
{
dataType.Validate()
.RejectIf(x => x < 0)
.WithMessage("The value must be positive.");
}
}
Where to Place Data Types
| Location | When |
|---|---|
Benevia.Core.Events.DataTypes |
General-purpose types reusable across all projects |
Your model project (e.g., Benevia.ERP.Model) |
Domain-specific types tied to your business logic |
Domain types in the model project can reference core types:
namespace Benevia.ERP.Model.Products;
[DataType<decimal>]
[DecimalPrecision(10, 2)]
public partial class ProductUnitPrice : DataTypes.Decimal
{
}
Complete Walk-Through: Phone Number Type
Here is a complete example combining a type definition with auto-correct formatting:
Type definition:
[DataType<string>]
[DataAnnotations("Formats USA 10 digit phone numbers", "MaxLength(25)")]
public partial class Phone : Text
{
}
Auto-correct logic:
[Logic]
public class PhoneBL(Phone.Logic dataType)
{
[RegisterLogic]
public void Phone_AutoCorrect()
{
dataType.AutoCorrect().Transform(FormatPhone);
}
string FormatPhone(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Extract digits
var digits = new string(input.Where(char.IsDigit).ToArray());
// Format 10-digit USA numbers
if (digits.Length == 10)
return $"({digits[..3]}) {digits[3..6]}-{digits[6..]}";
return input;
}
}
Usage on an entity:
[Property<DataTypes.Phone>("Phone")]
public partial string PhoneNumber { get; set; }
When a user enters 5551234567, the auto-correct transforms it to (555) 123-4567.
Checklist
- Define partial class with
[DataType<CLRType>] - Add
[DataAnnotations]for description and data annotations - For decimals, add
[DecimalPrecision(stored, visual)] - Inherit from appropriate base (
Text,Decimal, or a custom parent type) - Optionally add a
[Logic]class withAutoCorrect()and/orValidate()event handlers - Use with
[Property<YourType>("Label")]on entity properties