Facet Attribute Reference

May 23, 2026 · View on GitHub

The [Facet] attribute is used to declare a new projection (facet) type based on an existing source type.

Usage

Exclude Mode (Default)

[Facet(typeof(SourceType), exclude: "Property1", "Property2")]
public partial class MyFacet { }

Include Mode (New)

[Facet(typeof(SourceType), Include = [nameof(SourceType.Property1), nameof(SourceType.Property2)])]
public partial class MyFacet { }

Parameters

ParameterTypeDescription
sourceTypeTypeThe type to project from (required).
excludestring[]Names of properties/fields to exclude from the generated type (optional).
Includestring[]Names of properties/fields to include in the generated type (optional). Mutually exclusive with exclude.
NestedFacetsType[]?Array of nested facet types to automatically map nested objects (default: null).
IncludeFieldsboolInclude public fields from the source type (default: false for include mode, false for exclude mode).
GenerateConstructorboolGenerate a constructor that copies values from the source (default: true).
GenerateParameterlessConstructorboolGenerate a parameterless constructor for testing and initialization (default: true).
ChainToParameterlessConstructorboolChain generated constructors to the user-defined parameterless constructor using : this() (default: false). See Constructor Chaining below.
ConfigurationType?Custom mapping config type for forward mapping (Entity > DTO). See Custom Mapping.
ToSourceConfigurationType?Custom mapping config type for reverse mapping (DTO > Entity), called inside ToSource(). Requires GenerateToSource = true. See Reverse Mapping.
GenerateProjectionboolGenerate a static LINQ projection (default: true).
GenerateToSourceboolGenerates ToSource() (creates a new source instance) and ApplyToSource(source) (mutates an existing source instance) methods. Default: false. ApplyToSource is not generated for positional-record source types.
PreserveInitOnlyPropertiesboolPreserve init-only modifiers from source properties (default: true for records).
PreserveRequiredPropertiesboolPreserve required modifiers from source properties (default: true for records).
NullablePropertiesboolMake all properties nullable in the generated facet (default: false).
CopyAttributesboolCopy attributes from source type members to generated facet members (default: false). See Attribute Copying below.
CopyDocsboolCopy XML documentation comments from source type members to generated facet members (default: true). See XML Documentation Copying below.
UseFullNameboolUse full type name in generated file names to avoid collisions (default: false).
MaxDepthintMaximum depth for nested facet recursion to prevent stack overflow (default: 10). Set to 0 for unlimited (not recommended). See Circular Reference Protection below.
MaxDepthToSourceintMaximum depth for ToSource() reverse mapping, independent of MaxDepth. Default is 0 (no limit, same as existing behavior). Set to a positive value to limit how many levels ToSource() maps back. See MaxDepthToSource below.
PreserveReferencesboolEnable runtime circular reference detection using object tracking (default: true). See Circular Reference Protection below.
SourceSignaturestring?Hash signature to track source entity changes. Emits FAC022 warning when source structure changes. See Source Signature Change Tracking.
ConvertEnumsToType?When set, all enum properties are converted to the specified type (typeof(string) or typeof(int)) in the generated facet. Default is null (enums retain their original types). See Enum Conversion.
CollectionTargetTypeType?Overrides the collection type used for all mapped collection properties on this facet. Use an open generic type such as typeof(List<>) to remap source collections (e.g. Collection<T> from EF Core entities) to a different target type. See Collection Type Mapping below.
GenerateCopyConstructorboolGenerate a copy constructor that accepts another instance of the same facet type and copies all member values (default: false). See Copy Constructor below.
GenerateEqualityboolGenerate value-based equality members (Equals, GetHashCode, ==, !=) and implement IEquatable<T> (default: false). Ignored for records. See Equality Generation below.
SetAccessorPropertySetAccessorOverride the set accessor emitted on all generated properties. Preserve (default) keeps the source accessor; Set forces { get; set; }; Init forces { get; init; }. See Set Accessor Override below.

Include vs Exclude

The Include and Exclude parameters are mutually exclusive:

  • Exclude Mode: Include all properties except those listed in exclude (default behavior)
  • Include Mode: Only include properties listed in the Include array

Include Mode Behavior

When using Include mode:

  • Only the properties specified in the Include array are copied to the facet
  • IncludeFields defaults to false (disabled by default for include mode)
  • All other properties from the source type are excluded
  • Works with inheritance - you can include properties from base classes

Examples

Basic Include Usage

// Only include FirstName, LastName, and Email
[Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName), nameof(User.Email)])]
public partial class UserContactDto;

Single Property Include

// Only include the Name property
[Facet(typeof(Product), Include = [nameof(Product.Name)])]
public partial class ProductNameDto;

Include with Custom Properties

// Include specific properties and add custom ones
[Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName)])]
public partial class UserSummaryDto
{
    public string FullName { get; set; } = string.Empty; // Custom property
}

Include with Fields

// Include fields as well as properties
[Facet(typeof(EntityWithFields), Include = [nameof(EntityWithFields.Name), nameof(EntityWithFields.Age)], IncludeFields = true)]
public partial class EntityDto;

Include with Records

// Generate a record type with only specific properties
[Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName)])]
public partial record UserNameRecord;

Traditional Exclude Usage

// Exclude sensitive properties (original behavior)
[Facet(typeof(User), exclude: nameof(User.Password))]
public partial record UserDto;

Nullable Properties for Query Models

// Make all properties nullable for query/filter scenarios
[Facet(typeof(Product), nameof(Product.InternalNotes), NullableProperties = true, GenerateToSource = false)]
public partial class ProductQueryDto;

// Usage: All fields are optional for filtering
var query = new ProductQueryDto
{
    Name = "Widget",
    Price = 50.00m
    // Other fields remain null
};

Note: When using NullableProperties = true, it's recommended to set GenerateToSource = false since mapping nullable properties back to non-nullable source properties is not logically sound.

Nested Facets for Composing DTOs

// Define facets for nested types
[Facet(typeof(Address))]
public partial record AddressDto;

[Facet(typeof(Company), NestedFacets = [typeof(AddressDto)])]
public partial record CompanyDto;

[Facet(typeof(Employee),
    exclude: [nameof(Employee.PasswordHash), nameof(Employee.Salary)],
    NestedFacets = [typeof(CompanyDto), typeof(AddressDto)])]
public partial record EmployeeDto;

// Usage - automatically handles nested mapping
var employee = new Employee
{
    FirstName = "John",
    Company = new Company
    {
        Name = "Acme Corp",
        HeadquartersAddress = new Address { City = "San Francisco" }
    },
    HomeAddress = new Address { City = "Oakland" }
};

var employeeDto = new EmployeeDto(employee);
// employeeDto.Company is CompanyDto
// employeeDto.Company.HeadquartersAddress is AddressDto
// employeeDto.HomeAddress is AddressDto

// ToSource also handles nested types automatically
var mappedEmployee = employeeDto.ToSource();
// All nested objects are properly reconstructed

How NestedFacets Works:

  • The generator automatically detects which properties in your source type match the source types of the nested facets
  • For each match, it replaces the property type with the nested facet type
  • Constructors automatically call new NestedFacetType(source.Property) for nested properties
  • Projections work seamlessly for EF Core queries through constructor chaining
  • ToSource methods call .ToSource() on nested facets to reconstruct the original type hierarchy

Benefits:

  • No manual property declarations for nested types
  • Automatic mapping in constructors, projections, and ToSource methods
  • Works with multiple levels of nesting
  • Supports multiple nested facets on the same parent type

When to Use Include vs Exclude

Use Include when:

  • You want a facet with only a few specific properties from a large source type
  • Creating focused DTOs (e.g., summary views, contact info only)
  • Building API response models that should only expose certain fields
  • Creating search result DTOs with minimal data

Use Exclude when:

  • You want most properties but need to hide a few sensitive ones
  • The majority of the source type should be included in the facet
  • Following the original Facet pattern for backward compatibility

Use NullableProperties when:

  • Creating query/filter DTOs where all search criteria are optional
  • Building patch/update models where only changed fields are provided
  • Implementing flexible API request models that support partial data
  • Generating DTOs similar to the Query DTOs in GenerateDtos

Important considerations:

  • Value types (int, bool, DateTime, enums) become nullable (int?, bool?, etc.)
  • Reference types (string, objects) remain reference types but are marked nullable
  • Disable GenerateToSource to avoid mapping issues from nullable to non-nullable types

Constructor Chaining

The ChainToParameterlessConstructor parameter allows the generated constructor to chain to your user-defined parameterless constructor using : this(). This ensures any custom initialization logic in your constructor runs before property mapping.

Usage

public class ModelType
{
    public int MaxValue { get; set; }
    public string Name { get; set; } = string.Empty;
}

[Facet(typeof(ModelType), GenerateParameterlessConstructor = false, ChainToParameterlessConstructor = true)]
public partial class MyDto
{
    public int Value { get; set; }
    public bool Initialized { get; set; }

    public MyDto()
    {
        // Custom initialization logic that runs before mapping
        Value = 100;
        Initialized = true;
    }
}

// Usage
var source = new ModelType { MaxValue = 42, Name = "Test" };
var dto = new MyDto(source);
// dto.Value == 100 (from parameterless constructor)
// dto.Initialized == true (from parameterless constructor)
// dto.MaxValue == 42 (from source mapping)
// dto.Name == "Test" (from source mapping)

Generated Code

With ChainToParameterlessConstructor = true, the generated constructor chains to your parameterless constructor:

public MyDto(ModelType source) : this()  // <-- Chains to your constructor
{
    this.MaxValue = source.MaxValue;
    this.Name = source.Name;
}

When to Use

  • When you have initialization logic in your parameterless constructor that needs to run during mapping
  • When you need to set default values that aren't simply copied from the source
  • When you have computed or derived properties that need initial values

Note: Set GenerateParameterlessConstructor = false to prevent the generator from creating its own parameterless constructor, which would conflict with yours.

Attribute Copying

The CopyAttributes parameter allows you to copy attributes from the source type's members to the generated facet members. This is particularly useful for preserving data validation attributes when creating DTOs for API models.

Usage

public class User
{
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string FirstName { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;

    [Range(0, 150)]
    public int Age { get; set; }

    public string Password { get; set; } = string.Empty;
}

[Facet(typeof(User), nameof(User.Password), CopyAttributes = true)]
public partial class UserDto;

The generated UserDto will include all the validation attributes:

public partial class UserDto
{
    public int Id { get; set; }

    [Required]
    [StringLength(50)]
    public string FirstName { get; set; }

    [Required]
    [EmailAddress]
    public string Email { get; set; }

    [Range(0, 150)]
    public int Age { get; set; }
}

What Gets Copied

The attribute copying feature intelligently filters attributes to copy only those that make sense on the target:

Commonly copied attributes include:

  • Data validation attributes: Required, StringLength, Range, EmailAddress, Phone, Url, RegularExpression, CreditCard, etc.
  • Display attributes: Display, DisplayName, Description
  • JSON serialization attributes: JsonPropertyName, JsonIgnore, etc.
  • Custom validation attributes that inherit from ValidationAttribute

Automatically excluded attributes:

  • Internal compiler-generated attributes (e.g., System.Runtime.CompilerServices.*)
  • The base ValidationAttribute class itself (only derived validation attributes are copied)
  • Attributes that are not valid for the target member type based on AttributeUsage

Attribute Parameters

All attribute parameters are preserved with correct C# syntax:

public class Product
{
    [Required]
    [StringLength(100, MinimumLength = 3, ErrorMessage = "Name must be 3-100 characters")]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 10000.00)]
    public decimal Price { get; set; }

    [RegularExpression(@"^[A-Z]{3}-\d{4}$", ErrorMessage = "Invalid SKU format")]
    public string Sku { get; set; } = string.Empty;
}

[Facet(typeof(Product), CopyAttributes = true)]
public partial class ProductDto;

All parameters including named parameters, string literals with escape sequences, and numeric values are correctly preserved.

With Nested Facets

CopyAttributes works seamlessly with NestedFacets:

[Facet(typeof(Address), CopyAttributes = true)]
public partial class AddressDto;

[Facet(typeof(Order), nameof(Order.InternalNotes), CopyAttributes = true, NestedFacets = [typeof(AddressDto)])]
public partial class OrderDto;

Both the parent and nested facets will have their attributes copied from their respective source types.

When to Use CopyAttributes

Use CopyAttributes = true when:

  • Creating API request/response DTOs that need validation
  • Building DTOs for ASP.NET Core model validation
  • Preserving display metadata for UI frameworks
  • Maintaining JSON serialization attributes
  • You want consistent validation between your domain models and DTOs

Don't use it when:

  • You want different validation rules for your DTOs
  • Your source types have attributes specific to their domain concerns (e.g., ORM mapping attributes)
  • You prefer to define validation attributes directly on the facet

Default Behavior

By default, CopyAttributes = false, meaning no attributes are copied. This maintains backward compatibility and gives you explicit control over when attributes should be copied.

XML Documentation Copying

The CopyDocs parameter allows you to copy XML documentation comments from the source type's members to the generated facet members. This is particularly useful when creating DTOs for API models that use tools like OpenAPI/Swagger, which can extract documentation from XML comments.

Usage

public class User
{
    public int Id { get; set; }

    /// <summary>
    /// The user's first name.
    /// </summary>
    [Required]
    [StringLength(50)]
    public string FirstName { get; set; } = string.Empty;

    /// <summary>
    /// The user's email address. Must be a valid email format.
    /// </summary>
    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;

    /// <summary>
    /// The user's age in years.
    /// </summary>
    [Range(0, 150)]
    public int Age { get; set; }

    public string Password { get; set; } = string.Empty;
}

[Facet(typeof(User), nameof(User.Password), CopyDocs = true)]
public partial class UserDto;

The generated UserDto will include all the XML documentation comments:

public partial class UserDto
{
    public int Id { get; set; }

    /// <summary>
    /// The user's first name.
    /// </summary>
    public string FirstName { get; set; }

    /// <summary>
    /// The user's email address. Must be a valid email format.
    /// </summary>
    public string Email { get; set; }

    /// <summary>
    /// The user's age in years.
    /// </summary>
    public int Age { get; set; }
}

Combining CopyDocs and CopyAttributes

CopyDocs and CopyAttributes are independent options that can be used together:

// Copy both attributes and XML documentation
[Facet(typeof(User), nameof(User.Password), CopyAttributes = true, CopyDocs = true)]
public partial class UserDto;

// Copy only attributes
[Facet(typeof(User), nameof(User.Password), CopyAttributes = true)]
public partial class UserDto;

// Copy only documentation
[Facet(typeof(User), nameof(User.Password), CopyDocs = true)]
public partial class UserDto;

When to Use CopyDocs

Use CopyDocs = true when:

  • Creating API DTOs for OpenAPI/Swagger documentation
  • Building DTOs that expose the same semantics as the domain models
  • Maintaining consistent documentation across layers
  • Generating client SDKs from your API that need inline documentation
  • Using XML documentation for UI tooltips or help text

Don't use it when:

  • You want different documentation for your DTOs
  • Source model documentation includes internal implementation details
  • You prefer to define DTO-specific documentation

Default Behavior

By default, CopyDocs = true, meaning XML documentation is automatically copied from source types to DTOs. This is the most useful default for API DTOs and OpenAPI/Swagger scenarios. Set CopyDocs = false to disable copying if you want different documentation on your DTOs.

Circular Reference Protection

When working with nested facets, circular references in your object graph can cause stack overflow exceptions and IDE crashes. The Facet library provides two complementary features to prevent this:

MaxDepth

Controls how many levels deep nested facets can be instantiated. This prevents infinite recursion during both code generation and runtime.

Default: 10 (handles most real-world scenarios including deep non-circular nesting)

// Handles: Order -> LineItems -> Product -> Category
[Facet(typeof(Order), NestedFacets = [typeof(LineItemDto)])]
public partial record OrderDto;

// For deeper nesting, increase MaxDepth
[Facet(typeof(Organization), MaxDepth = 5, NestedFacets = [typeof(DepartmentDto)])]
public partial record OrganizationDto;

// To disable depth limiting (use with caution!)
[Facet(typeof(SimpleType), MaxDepth = 0)]
public partial record SimpleTypeDto;

How MaxDepth Works:

  • Level 0: Root object (e.g., Order)
  • Level 1: First level nested objects (e.g., LineItems)
  • Level 2: Second level nested objects (e.g., Product)
  • Level 3: Third level nested objects (e.g., Category)
  • Properties that would exceed MaxDepth are set to null (with default MaxDepth = 10, this covers most real-world scenarios)

ApplyToSource

When GenerateToSource = true, Facet generates both ToSource() and ApplyToSource(). While ToSource() creates a new source instance, ApplyToSource() writes the mapped properties back onto an existing source instance without allocating a new one.

[Facet(typeof(User), "Password", "CreatedAt", GenerateToSource = true)]
public partial class UserDto { }

// Load an existing entity from the database
var entity = await dbContext.Users.FindAsync(id);

// Apply DTO changes onto it - EF Core change tracking stays intact
dto.ApplyToSource(entity);

await dbContext.SaveChangesAsync();

This is the recommended pattern for update operations because the original entity reference is retained, which means ORM change tracking and audit hooks continue to work correctly.

Limitations:

  • Not generated when the source type uses a positional constructor (e.g. record User(string Id, string Name)), because positional record properties are init-only and cannot be set after construction. Use ToSource() and re-attach the new instance instead.
  • Only properties included in the facet and marked as reversible are written. All other source properties are left untouched.

MaxDepthToSource

Controls how many levels deep ToSource() maps nested objects back to the source entity. This is independent of MaxDepth, which only controls the forward source-to-DTO direction.

Default: 0 (no limit - preserves existing behavior)

Use case: Backends with separation of duties often only want the top-level entity updated via ToSource(), without cascading saves into child entities. Setting MaxDepthToSource = 1 maps only the root object and leaves all nested entity references as null (or empty collections).

// ToFacet (source -> DTO) goes up to 5 levels deep.
// ToSource (DTO -> entity) only maps the top-level object.
[Facet(typeof(Order), GenerateToSource = true,
       MaxDepth = 5, MaxDepthToSource = 1,
       NestedFacets = [typeof(LineItemDto)])]
public partial class OrderDto;

How MaxDepthToSource Works:

When MaxDepthToSource > 0, Facet generates two overloads of ToSource():

  • public ToSource() - the public entry point, calls ToSource(0)
  • internal ToSource(int __depth) - carries the depth counter through nested calls

At the root level __depth = 0, so children at depth 0 are still mapped (the guard is __depth < MaxDepthToSource). Once the limit is reached, nested entity references become null and collections become empty.

// Only the root Order entity properties are set; LineItems is empty.
[Facet(typeof(Order), GenerateToSource = true,
       MaxDepthToSource = 1,
       NestedFacets = [typeof(LineItemDto)])]
public partial class OrderDto;

var entity = dto.ToSource(); // entity.LineItems is []
// Both Order and its LineItems are mapped; LineItem.Product is null.
[Facet(typeof(Order), GenerateToSource = true,
       MaxDepthToSource = 2,
       NestedFacets = [typeof(LineItemDto)])]
public partial class OrderDto;

MaxDepthToSource only applies to facets that have GenerateToSource = true. Child facets that do not set MaxDepthToSource are called via their normal ToSource() and are not depth-limited.

PreserveReferences

Enables runtime tracking of object instances to detect when the same object is being processed multiple times. This prevents circular references where objects reference each other.

Default: true (recommended for safety)

// Enable circular reference detection (default)
[Facet(typeof(Author), PreserveReferences = true, NestedFacets = [typeof(BookDto)])]
public partial record AuthorDto;

[Facet(typeof(Book), PreserveReferences = true, NestedFacets = [typeof(AuthorDto)])]
public partial record BookDto;

// Disable for maximum performance (only if you're certain no circular refs exist)
[Facet(typeof(FlatDto), PreserveReferences = false)]
public partial record FlatDto;

How PreserveReferences Works:

  • Uses a HashSet<object> with reference equality to track processed objects
  • When creating nested facets, checks if the source object was already processed
  • Returns null for already-processed objects to break circular references
  • Filters out duplicates from collections using .Where(x => x != null)

Best Practices

For circular references (e.g., Author <> Book, Employee <> Manager):

[Facet(typeof(Author), MaxDepth = 2, PreserveReferences = true,
       NestedFacets = [typeof(BookDto)])]
public partial record AuthorDto;

[Facet(typeof(Book), MaxDepth = 2, PreserveReferences = true,
       NestedFacets = [typeof(AuthorDto)])]
public partial record BookDto;

For self-referencing types (e.g., Employee -> Manager -> Manager):

[Facet(typeof(Employee), MaxDepth = 5, PreserveReferences = true,
       NestedFacets = [typeof(EmployeeDto)])]
public partial record EmployeeDto;

For simple hierarchies with no circular references:

// Can reduce overhead if certain no circular refs
[Facet(typeof(Category), MaxDepth = 10, PreserveReferences = false,
       NestedFacets = [typeof(CategoryDto)])]
public partial record CategoryDto;

For flat DTOs with no nested facets:

// Can disable both for maximum performance
[Facet(typeof(Product), MaxDepth = 0, PreserveReferences = false)]
public partial record ProductDto;

Performance Considerations

  • MaxDepth: Negligible overhead - just depth counter checks
  • PreserveReferences: Minimal overhead - HashSet reference lookups (typically < 1% performance impact)
  • Both features are safe to leave enabled by default
  • Only disable if you have profiled your application and identified these as bottlenecks

Common Scenarios

ScenarioMaxDepthPreserveReferencesExample
Flat DTO (no nesting)0falseSimple user profile
Simple parent-child2falseOrder -> Customer
Multi-level hierarchy3-5falseOrder -> LineItem -> Product -> Category
Circular references2-3trueAuthor <> Book, Post <> Comments
Self-referencing3-5trueEmployee tree, Category tree
Complex object graphs3-5trueAny complex domain model

Troubleshooting

Stack overflow during code generation:

  • Increase MaxDepth, the source generator is hitting infinite recursion
  • Ensure MaxDepth > 0 when using PreserveReferences = true

Stack overflow at runtime:

  • Enable PreserveReferences = true
  • Increase MaxDepth if your legitimate nesting depth exceeds the current value

Missing nested data:

  • Check if your nesting depth exceeds MaxDepth
  • Verify PreserveReferences isn't filtering out valid references

MapWhen Attribute

The [MapWhen] attribute enables conditional property mapping based on source values.

Basic Usage

[Facet(typeof(Order))]
public partial class OrderDto
{
    [MapWhen("Status == OrderStatus.Completed")]
    public DateTime? CompletedAt { get; set; }
}

Parameters

ParameterTypeDescription
ConditionstringThe condition expression to evaluate (required)
Defaultobject?Custom default value when condition is false
IncludeInProjectionboolInclude condition in Projection expression (default: true)

Supported Conditions

  • Boolean: [MapWhen("IsActive")]
  • Equality: [MapWhen("Status == OrderStatus.Completed")]
  • Null checks: [MapWhen("Email != null")]
  • Comparisons: [MapWhen("Age >= 18")]
  • Negation: [MapWhen("!IsDeleted")]

Multiple Conditions

Multiple attributes are combined with AND logic:

[MapWhen("IsActive")]
[MapWhen("Status == OrderStatus.Completed")]
public DateTime? CompletedAt { get; set; }

See MapWhen Conditional Mapping for full documentation.


Enum Conversion

The ConvertEnumsTo property converts all enum properties to string or int in the generated facet.

Basic Usage

// Convert enums to strings (for API responses)
[Facet(typeof(User), ConvertEnumsTo = typeof(string))]
public partial class UserDto;

// Convert enums to integers (for storage)
[Facet(typeof(User), ConvertEnumsTo = typeof(int))]
public partial class UserDto;

With Reverse Mapping

[Facet(typeof(User), ConvertEnumsTo = typeof(string), GenerateToSource = true)]
public partial class UserDto;

var dto = new UserDto(user);
dto.Status // "Active" (string)

var entity = dto.ToSource();
entity.Status // UserStatus.Active (enum)

Nullable Enums

Nullable enum properties preserve their nullability after conversion:

  • UserStatus? ? string? (null when source is null)
  • UserStatus? ? int? (nullable int)

See Enum Conversion for full documentation.


Copy Constructor

The GenerateCopyConstructor property generates a constructor that accepts another instance of the same facet type and copies all member values. This is useful for MVVM scenarios, cloning DTOs, or creating independent copies.

Basic Usage

[Facet(typeof(User), GenerateCopyConstructor = true)]
public partial class UserDto;

// Usage
var original = new UserDto(user);
var copy = new UserDto(original); // Copy constructor

// Modify the copy without affecting the original
copy.FirstName = "Changed";
original.FirstName; // Still "John"

Generated Code

public partial class UserDto
{
    /// <summary>
    /// Initializes a new instance by copying all member values from another instance.
    /// </summary>
    public UserDto(UserDto other)
    {
        if (other is null) throw new ArgumentNullException(nameof(other));
        this.Id = other.Id;
        this.FirstName = other.FirstName;
        this.LastName = other.LastName;
        this.Email = other.Email;
    }
}

When to Use

  • MVVM ViewModels: Clone a view model for editing while preserving the original for cancel/revert
  • DTO Cloning: Create independent copies of DTOs for caching or comparison
  • Undo/Redo: Snapshot DTO state before modifications
  • Inheritance Scenarios: Use with base class inheritance where you need to copy facet properties to derived types

Equality Generation

The GenerateEquality property generates value-based equality members for class and struct facets. This includes Equals(T), Equals(object), GetHashCode(), and the == / != operators. The generated type also implements IEquatable<T>.

Note: This option is ignored for record types, which already have built-in value-based equality from the C# language.

Basic Usage

[Facet(typeof(User), GenerateEquality = true)]
public partial class UserDto;

// Value-based comparison
var dto1 = new UserDto(user);
var dto2 = new UserDto(user);
dto1.Equals(dto2); // true
dto1 == dto2;      // true
dto1.GetHashCode() == dto2.GetHashCode(); // true

// Works in collections
var set = new HashSet<UserDto> { dto1 };
set.Contains(dto2); // true, same values

Combining with Copy Constructor

[Facet(typeof(User), GenerateCopyConstructor = true, GenerateEquality = true)]
public partial class UserDto;

var original = new UserDto(user);
var copy = new UserDto(original);
original == copy; // true , same values, different instances

Generated Code

public partial class UserDto : System.IEquatable<UserDto>
{
    public bool Equals(UserDto? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return this.Id == other.Id
            && EqualityComparer<string>.Default.Equals(this.FirstName, other.FirstName)
            && EqualityComparer<string>.Default.Equals(this.LastName, other.LastName);
    }

    public override bool Equals(object? obj) => obj is UserDto other && Equals(other);

    public override int GetHashCode()
    {
        unchecked
        {
            int hash = 17;
            hash = hash * 31 + Id.GetHashCode();
            hash = hash * 31 + (FirstName?.GetHashCode() ?? 0);
            hash = hash * 31 + (LastName?.GetHashCode() ?? 0);
            return hash;
        }
    }

    public static bool operator ==(UserDto? left, UserDto? right) { ... }
    public static bool operator !=(UserDto? left, UserDto? right) => !(left == right);
}

When to Use

  • Class-based DTOs that need value comparison without converting to records
  • Change detection: Compare DTOs to check if data has been modified
  • Caching: Use DTOs as dictionary keys or in hash sets
  • Testing: Assert DTO equality in unit tests

When NOT to Use

  • Records: Records already have value-based equality � GenerateEquality is automatically ignored
  • Reference equality needed: If you need identity-based comparison, don't enable this

Collection Type Mapping

By default, Facet preserves the source collection type when mapping nested facet collections. For example, if an EF Core entity uses Collection<T>, the generated facet property will also use Collection<T>. Use CollectionTargetType to override this for all collection properties on a facet.

Supported Types

Any open generic collection type is accepted:

CollectionTargetTypeGenerated property typeMaterialized via
typeof(List<>)List<TFacet>.ToList()
typeof(IList<>)IList<TFacet>.ToList()
typeof(ICollection<>)ICollection<TFacet>.ToList()
typeof(IEnumerable<>)IEnumerable<TFacet>lazy (no materialization)
typeof(IReadOnlyList<>)IReadOnlyList<TFacet>.ToList()
typeof(IReadOnlyCollection<>)IReadOnlyCollection<TFacet>.ToList()
typeof(Collection<>)Collection<TFacet>new Collection<>(…)

Facet-Level Override

Remap all Collection<T> (or any source collection type) to List<T>:

public class UnitEntity
{
    public int Id { get; set; }
    public Collection<UnitItemEntity> Items { get; set; } = new();
}

[Facet(typeof(UnitEntity),
    NestedFacets = [typeof(UnitItemDto)],
    CollectionTargetType = typeof(List<>))]
public partial class UnitDto { }

// Generated: public List<UnitItemDto> Items { get; set; }

Per-Property Override with [MapFrom]

Use AsCollection on [MapFrom] to override a single property:

[Facet(typeof(UnitEntity), NestedFacets = [typeof(UnitItemDto)])]
public partial class UnitDto
{
    [MapFrom(nameof(UnitEntity.Items), AsCollection = typeof(List<>))]
    public List<UnitItemDto> Items { get; set; } = new();
}

Round-Trip Support

When GenerateToSource = true, ToSource() restores the original source collection type even when the facet uses a different type:

[Facet(typeof(UnitEntity),
    NestedFacets = [typeof(UnitItemDto)],
    CollectionTargetType = typeof(List<>),
    GenerateToSource = true)]
public partial class UnitDto { }

// facet.Items is List<UnitItemDto>
// facet.ToSource().Items is Collection<UnitItemEntity> , original source type restored

Set Accessor Override

The SetAccessor parameter controls which set accessor is emitted on every generated property, overriding the default behaviour.

ValueGenerated accessorWhen to use
PropertySetAccessor.Preserve (default)Same as the source propertyUsual DTO/projection work
PropertySetAccessor.Set{ get; set; }Force mutability even when source uses init
PropertySetAccessor.Init{ get; init; }Immutable DTOs — all properties settable only during construction

Basic Usage

// All generated properties use { get; init; }
[Facet(typeof(Foo), SetAccessor = PropertySetAccessor.Init)]
public partial class ImmutableFoo;

// All generated properties use { get; set; }
[Facet(typeof(Foo), SetAccessor = PropertySetAccessor.Set)]
public partial class MutableFoo;

Builder Pattern Example

SetAccessor is ideal for a mutable builder → immutable read model workflow:

public class Order
{
    public int Id { get; set; }
    public string Reference { get; set; } = string.Empty;
    public decimal Total { get; set; }
}

// Mutable version — build up the object in multiple steps
[Facet(typeof(Order))]
public partial class OrderBuilder;

// Immutable version, locked down after construction
[Facet(typeof(Order), SetAccessor = PropertySetAccessor.Init)]
public partial class ImmutableOrder;
// Build
var builder = new OrderBuilder();
builder.Reference = "ORD-001";
builder.Total = 99.99m;

// Freeze into an immutable snapshot
var order = new ImmutableOrder(builder.ToSource());
// order.Reference = "x"; // <- compile error: init-only

Generated Code

// Source
public class Foo
{
    public int Id { get; set; }
    public string Name { get; set; }
}

// SetAccessor = PropertySetAccessor.Init
public partial class ImmutableFoo
{
    public int Id { get; init; }
    public string Name { get; init; } = default!;
}

Interaction with PreserveInitOnlyProperties

SetAccessor takes full precedence:

  • SetAccessor = Init > all properties get init, regardless of source or PreserveInitOnlyProperties
  • SetAccessor = Set > all properties get set, even if PreserveInitOnlyProperties = true
  • SetAccessor = Preserve (default) > falls back to the normal PreserveInitOnlyProperties logic

See Custom Mapping for advanced scenarios.