diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index aaaad74..164558f 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -18,14 +18,14 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Partial struct with some interface - if (node is StructDeclarationSyntax sds && sds.Modifiers.Any(SyntaxKind.PartialKeyword) && sds.BaseList is not null) + // Partial (record) struct with some interface + if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } && tds.Modifiers.Any(SyntaxKind.PartialKeyword) && tds.BaseList is not null) { // With SourceGenerated attribute - if (sds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName)) + if (tds.HasAttributeWithPrefix(Constants.SourceGeneratedAttributeShortName)) { // Consider any type with SOME 1-param generic "IIdentity" inheritance/implementation - foreach (var baseType in sds.BaseList.Types) + foreach (var baseType in tds.BaseList.Types) { if (baseType.Type.HasArityAndName(1, Constants.IdentityInterfaceTypeName)) return true; @@ -33,7 +33,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella } } - // Concrete, non-generic class + // Concrete, non-generic class with any inherited/implemented types if (node is ClassDeclarationSyntax cds && !cds.Modifiers.Any(SyntaxKind.AbstractKeyword) && cds.Arity == 0 && cds.BaseList is not null) { // Consider any type with SOME 2-param generic "Entity" inheritance/implementation @@ -107,6 +107,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.IsIIdentity = true; result.IdTypeExists = true; + result.IsRecord = type.IsRecord; result.SetAssociatedData(new Tuple(null, type, underlyingType)); result.ContainingNamespace = type.ContainingNamespace.ToString(); result.IdTypeName = type.Name; @@ -124,17 +125,21 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.Constructor.If(type.Constructors.Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); - existingComponents |= IdTypeComponents.ToStringOverride.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= IdTypeComponents.ToStringOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(ToString) && member is IMethodSymbol method && method.Parameters.Length == 0)); - existingComponents |= IdTypeComponents.GetHashCodeOverride.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= IdTypeComponents.GetHashCodeOverride.If(!result.IsRecord && members.Any(member => member.Name == nameof(GetHashCode) && member is IMethodSymbol method && method.Parameters.Length == 0)); + // Records irrevocably and correctly override this, checking the type and delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.EqualsOverride.If(members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())); - existingComponents |= IdTypeComponents.EqualsMethod.If(members.Any(member => + // Records override this, but our implementation is superior + existingComponents |= IdTypeComponents.EqualsMethod.If(!result.IsRecord && members.Any(member => member.Name == nameof(Equals) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); @@ -142,11 +147,13 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.EqualsOperator.If(members.Any(member => member.Name == "op_Equality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + // Records irrevocably and correctly override this, delegating to IEquatable.Equals(T) existingComponents |= IdTypeComponents.NotEqualsOperator.If(members.Any(member => member.Name == "op_Inequality" && member is IMethodSymbol method && method.Parameters.Length == 2 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && @@ -298,7 +305,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable #endif "; - string ? propertyNameParseStatement = null; + string? propertyNameParseStatement = null; if (idType.IsOrImplementsInterface(interf => interf.Name == "ISpanParsable" && interf.ContainingNamespace.HasFullName("System") && interf.Arity == 1 && interf.TypeArguments[0].Equals(idType, SymbolEqualityComparer.Default), out _)) propertyNameParseStatement = $"return reader.GetParsedString<{idTypeName}>(System.Globalization.CultureInfo.InvariantCulture);"; else if (underlyingType.IsType()) @@ -354,7 +361,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} {(hasSourceGeneratedAttribute ? "" : "[SourceGenerated]")} - {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}> + {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(idType.IsRecord ? " record" : "")} struct {idTypeName} : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} {nonNullStringSummary} @@ -548,6 +555,7 @@ private sealed record Generatable : IGeneratable public bool IdTypeExists { get; set; } public string EntityTypeName { get; set; } = null!; public bool IsIIdentity { get; set; } + public bool IsRecord { get; set; } public string ContainingNamespace { get; set; } = null!; public string IdTypeName { get; set; } = null!; public string UnderlyingTypeFullyQualifiedName { get; set; } = null!; diff --git a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs b/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs index 19f8827..db0406b 100644 --- a/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs +++ b/DomainModeling.Generator/SourceGeneratedAttributeAnalyzer.cs @@ -55,7 +55,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella else if (type.IsOrInheritsClass(type => type.Arity == 2 && type.IsType(Constants.DummyBuilderTypeName, Constants.DomainModelingNamespace), out _)) expectedTypeName = tds is ClassDeclarationSyntax ? null : "class"; // Expect a class else if (type.IsOrImplementsInterface(type => type.Arity == 1 && type.IsType(Constants.IdentityInterfaceTypeName, Constants.DomainModelingNamespace), out _)) - expectedTypeName = tds is StructDeclarationSyntax ? null : "struct"; // Expect a struct + expectedTypeName = tds is StructDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "struct" } ? null : "struct"; // Expect a struct else expectedTypeName = "*"; // No suitable inheritance found for source generation diff --git a/DomainModeling.Generator/TypeSyntaxExtensions.cs b/DomainModeling.Generator/TypeSyntaxExtensions.cs index 36a40d3..01a8998 100644 --- a/DomainModeling.Generator/TypeSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeSyntaxExtensions.cs @@ -6,8 +6,12 @@ namespace Architect.DomainModeling.Generator; /// Provides extensions on . /// internal static class TypeSyntaxExtensions -{ - public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string unqualifiedName) +{ + /// + /// Returns whether the given has the given arity (type parameter count) and (unqualified) name. + /// + /// Pass null to accept any arity. + public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName) { int actualArity; string actualUnqualifiedName; @@ -32,6 +36,20 @@ public static bool HasArityAndName(this TypeSyntax typeSyntax, int arity, string return false; } - return actualArity == arity && actualUnqualifiedName == unqualifiedName; + return (arity is null || actualArity == arity) && actualUnqualifiedName == unqualifiedName; + } + + /// + /// Returns the given 's name, or null if no name can be obtained. + /// + public static string? GetNameOrDefault(this TypeSyntax typeSyntax) + { + return typeSyntax switch + { + SimpleNameSyntax simpleName => simpleName.Identifier.ValueText, + QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, + AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText, + _ => null, + }; } } diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 35c3eb1..2146de7 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -18,7 +18,7 @@ public override void Initialize(IncrementalGeneratorInitializationContext contex private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { - // Partial subclass + // Partial subclass with any inherited/implemented types if (node is not ClassDeclarationSyntax cds || !cds.Modifiers.Any(SyntaxKind.PartialKeyword) || cds.BaseList is null) return false; @@ -29,11 +29,9 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return true; } - /* Supporting records has the following disadvantages: - * - Allows the use of automatic properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern. - * - Overrides equality without an easy way to specify (or even think of) how to compare strings. - * - Overrides equality without special-casing collections. - * - Omits IComparable and comparison operators. + /* Supporting records has the following issues: + * - Cannot inherit from a non-record class (and vice versa). + * - Promotes the use of "positional" (automatic) properties. This generates a constructor and init-properties, stimulating non-validated ValueObjects, an antipattern. * - Provides multiple nearly-identical solutions, reducing standardization. // Partial record with some interface/base if (node is RecordDeclarationSyntax rds && rds.Modifiers.Any(SyntaxKind.PartialKeyword) && rds.BaseList is not null) diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index c3053cf..8257593 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -104,6 +104,28 @@ public void Equals_WithUnintializedObject_ShouldReturnExpectedResult() Assert.Equal(new StringId(""), left); } + [Fact] + public void ObjectEquals_WithRegularStruct_ShouldReturnExpectedResult() + { + var one = (object)new IntId(1); + var alsoOne = (object)new IntId(1); + var two = (object)new IntId(2); + + Assert.Equal(one, alsoOne); + Assert.NotEqual(one, two); + } + + [Fact] + public void ObjectEquals_WithRecordStruct_ShouldReturnExpectedResult() + { + var one = (object)new DecimalId(1); + var alsoOne = (object)new DecimalId(1); + var two = (object)new DecimalId(2); + + Assert.Equal(one, alsoOne); + Assert.NotEqual(one, two); + } + [Theory] [InlineData(0, 0)] [InlineData(0, 1)] @@ -495,14 +517,10 @@ internal partial struct IntId : IIdentity } [SourceGenerated] - internal partial struct DecimalId : IIdentity - { - } + internal partial record struct DecimalId : IIdentity; [SourceGenerated] - internal partial struct StringId : IIdentity - { - } + internal partial record struct StringId : IIdentity; [SourceGenerated] internal partial struct IgnoreCaseStringId : IIdentity diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 7f660f5..93686d3 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -1,6 +1,5 @@ using System.Collections; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.InteropServices; using Architect.DomainModeling.Tests.ValueObjectTestTypes; @@ -399,6 +398,84 @@ public void ContainsWhitespaceOrNonPrintableCharacters_Regularly_ShouldReturnExp } } + [Fact] + public void ContainsNonPrintableCharactersOrDoubleQuotes_WithFlagNewLinesAndTabs_ShouldReturnExpectedResult() + { + for (var i = 0; i < UInt16.MaxValue; i++) + { + var chr = (char)i; + + var category = Char.GetUnicodeCategory(chr); + var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned; + var isNotDoubleQuote = chr != '"'; + + var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1); + var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: true); + + if (isPrintable && isNotDoubleQuote) + Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true."); + else + Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (disallowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false."); + + var longVersion = new string(chr, count: 33); + var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: true); + Assert.Equal(result, longResult); + } + } + + [Fact] + public void ContainsNonPrintableCharactersOrDoubleQuotes_WithoutFlagNewLinesAndTabs_ShouldReturnExpectedResult() + { + for (var i = 0; i < UInt16.MaxValue; i++) + { + var chr = (char)i; + + var category = Char.GetUnicodeCategory(chr); + var isPrintable = category != UnicodeCategory.Control && category != UnicodeCategory.PrivateUse && category != UnicodeCategory.OtherNotAssigned; + isPrintable = isPrintable || chr == '\r' || chr == '\n' || chr == '\t'; + var isNotDoubleQuote = chr != '"'; + + var span = MemoryMarshal.CreateReadOnlySpan(ref chr, length: 1); + var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(span, flagNewLinesAndTabs: false); + + if (isPrintable && isNotDoubleQuote) + Assert.False(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been false, but was true."); + else + Assert.True(result, $"{nameof(ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes)} (allowing newlines and tabs) for '{chr}' ({i}) should have been true, but was false."); + + var longVersion = new string(chr, count: 33); + var longResult = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(longVersion, flagNewLinesAndTabs: false); + Assert.Equal(result, longResult); + } + } + + [Theory] + [InlineData("_12345678901234561234567890123456", false)] + [InlineData("12345678901234561234567890123456_", false)] + [InlineData("12345678901234561234567890123456ë", false)] + [InlineData("12345678901234561234567890123456💩", false)] + [InlineData("1234567890123456123456789012345💩", false)] + [InlineData("123456789012345612345678901234💩", false)] + [InlineData("💩12345678901234561234567890123456", false)] + [InlineData("12345678901234💩561234567890123456", false)] + [InlineData("12345678901234561234567890123456", true)] // Ends with an invisible control character + [InlineData("12345678901234561234567890123456\0", true)] + [InlineData("1234567890123456123456789012345\0", true)] + [InlineData("123456789012345612345678901234\0", true)] + [InlineData("\012345678901234561234567890123456", true)] + [InlineData("12345678901234561234567890123456\"", true)] + [InlineData("1234567890123456123456789012345\"", true)] + [InlineData("123456789012345612345678901234\"", true)] + [InlineData("12345678901234\0561234567890123456", true)] + [InlineData("\"12345678901234561234567890123456", true)] + [InlineData("12345678901234\"561234567890123456", true)] + public void ContainsNonPrintableCharactersOrDoubleQuotes_WithLongInput_ShouldReturnExpectedResult(string text, bool expectedResult) + { + var result = ManualValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs: true); + + Assert.Equal(expectedResult, result); + } + [Theory] [InlineData("_12345678901234561234567890123456", false)] [InlineData("12345678901234561234567890123456_", false)] @@ -964,6 +1041,11 @@ public ManualValueObject(int id) return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs); } + public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs); + } + public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) { return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text); diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 20ca875..8870537 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -13,7 +13,7 @@ - 2.0.1 + 2.1.0 A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. @@ -21,6 +21,11 @@ https://github.com/TheArchitectDev/Architect.DomainModeling Release notes: +2.1.0: +- Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names. +- Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted. +- Added the IEntity interface, implemented by the Entity class and its derivatives. + 2.0.1: - Fixed a bug where arrays in (Wrapper)ValueObjects would trip the generator. diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 95b621a..64676ce 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -100,6 +100,6 @@ public virtual bool Equals(Entity? other) /// /// [Serializable] -public abstract class Entity : DomainObject +public abstract class Entity : DomainObject, IEntity { } diff --git a/DomainModeling/IEntity.cs b/DomainModeling/IEntity.cs new file mode 100644 index 0000000..11762e3 --- /dev/null +++ b/DomainModeling/IEntity.cs @@ -0,0 +1,10 @@ +namespace Architect.DomainModeling; + +/// +/// +/// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. +/// +/// +public interface IEntity : IDomainObject +{ +} diff --git a/DomainModeling/ValueObject.ValidationHelpers.cs b/DomainModeling/ValueObject.ValidationHelpers.cs index 9d38eef..51acaf5 100644 --- a/DomainModeling/ValueObject.ValidationHelpers.cs +++ b/DomainModeling/ValueObject.ValidationHelpers.cs @@ -372,6 +372,59 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) } } + /// + /// + /// This method detects double quotes (") and non-printable characters, such as control characters. + /// It does not detect whitespace characters, even if they are zero-width. + /// + /// + /// It returns true, unless the given consists exclusively of printable characters that are not double quotes ("). + /// + /// + /// + /// + /// A parameter controls whether this method flags newline and tab characters, allowing single-line vs. multiline input to be validated. + /// + /// + /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. + protected static bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return flagNewLinesAndTabs + ? EvaluateIncludingNewLinesAndTabs(text) + : EvaluateOverlookingNewLinesAndTabs(text); + + // Local function that performs the work while including \r, \n, and \t characters + static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + return true; + } + + return false; + } + + // Local function that performs the work while overlooking \r, \n, and \t characters + static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + { + if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt + return true; + } + } + + return false; + } + } + /// /// /// This method detects whitespace characters and non-printable characters. diff --git a/README.md b/README.md index 7929d68..0c475c7 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ public partial class Description : WrapperValueObject } ``` -The `IComparable` interface can optionally be added, if the type is considered to have a natural order. +To also have comparison methods generated, the `IComparable` interface can optionally be added, if the type is considered to have a natural order. ## Entity @@ -147,17 +147,47 @@ public class Payment : Entity The `Entity` base class is what triggers source generation of the `TId`, if no such type exists. The `TIdPrimitive` type parameter specifies the underlying primitive to use. Note that the generated ID type is itself a value object, as is customary in DDD. -The entity could then be modified as follows to create a new, unique ID on construction: +For performance reasons, the `Entity` base class is only recognized when inherited from _directly_. If it is _indirectly_ inherited from (i.e. via a custom base class), then the ID type must be [explicitly declared](#identity). + +Next, the entity could be modified to create a new, unique ID on construction: ```cs - public Payment(string currency, decimal amount) - : base(new PaymentId(Guid.NewGuid().ToString("N"))) - { - // Snip - } +public Payment(string currency, decimal amount) + : base(new PaymentId(Guid.NewGuid().ToString("N"))) +{ + // Snip +} +``` + +For a more database-friendly alternative to UUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). + +## Identity + +Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs: + +- The enforced default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. +- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct can be used, e.g. `PaymentId?`. +- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid representing `null`. + +Since an application is expected to work with many ID instances, using structs for them is a nice optimization that reduces heap allocations. + +Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: + +```cs +[SourceGenerated] +public readonly partial struct PaymentId : IIdentity +{ +} +``` + +Records may be used for an even shorter syntax: + +```cs +[SourceGenerated] +public readonly partial record struct ExternalId : IIdentity; ``` -For a more database-friendly alternative to GUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). +Note that an [entity](#entity) has the option of having its own ID type generated implicitly, with practically no code at all. ## DummyBuilder