Facet C# Source Generator

repository·master·Indexed 22 days ago

https://github.com/tim-maes/facet

A C# source generator that automates the creation of DTOs (Data Transfer Objects) from domain models. It generates constructors, LINQ projections, and mappings at compile time with zero runtime overhead. Key features include declarative property renaming via [MapFrom], conditional mapping with [MapWhen], nested object flattening, and automated CRUD DTO generation via [GenerateDtos]. It provides deep integration with Entity Framework Core through the Facet.Extensions.EFCore package, supporting asynchronous extension methods and automatic navigation loading.

Tokens
94.7K
Snippets
238
Records
330
Agent score
75%

What's inside Facet

  1. What is Facet?

    master

    Facet is a C# source generator designed to eliminate DTO (Data Transfer Object) boilerplate. It allows you to define a single source of truth (a domain model) and automatically generates various 'facets' (views) of that model for different purposes, such as Public APIs, Admin endpoints, or efficient Database projections.

    Key benefits include:

    • Compile-time generation: Everything is generated at compile time with zero runtime overhead and no reflection.
    • Automated boilerplate: Generates constructors, static factories, LINQ projections, and reverse mappings.
    • Type flexibility: Supports generating DTOs as classes, records, structs, or record structs.
    • Deep mapping: Automatically handles nested objects and collections.
  2. Understand the Facet Ecosystem packages

    master

    Facet is a modular ecosystem composed of several specialized NuGet packages. Depending on your requirements for mapping, projection, and database integration, you may need one or more of the following:

    • Facet: The core source generator. It is responsible for generating DTOs, projections, and mapping code at compile time.
    • Facet.Extensions: Provides provider-agnostic extension methods for mapping, projecting, and performing patch updates. It works with any LINQ provider and has no dependency on EF Core.
    • Facet.Mapping: Adds support for advanced static mapping configurations, including async capabilities and dependency injection for complex scenarios.
    • Facet.Mapping.Expressions: Provides expression tree transformation utilities to transform predicates, selectors, and business logic between source entities and Facet projections.
    • Facet.Extensions.EFCore: Provides async extension methods specifically for Entity Framework Core (requires EF Core 6+).
    • Facet.Extensions.EFCore.Mapping: Enables advanced custom async mapper support for EF Core queries, allowing for complex mappings that cannot be expressed as standard SQL projections.
  3. What is the Wrapper attribute and when to use it

    master

    The [Wrapper] attribute generates wrapper classes that implement a reference-based facade pattern. Unlike [Facet], which creates independent value copies, a [Wrapper] maintains a reference to the source object. This means any changes made to properties through the wrapper are directly applied to the underlying source object.

    Comparison: Wrapper vs Facet

    Use CaseUse WrapperUse Facet
    DTOs for API/serialization
    EF Core query projections
    Facade pattern (hide properties)
    ViewModel with live binding
    Decorator pattern
    Read-only views
    Memory efficiency (avoid duplication)
    Disconnected data transfer
  4. Choose the right mapping interface based on scenario

    master

    Select the appropriate interface to balance performance and complexity:

    ScenarioRecommended InterfaceReason
    Simple property transformationsIFacetMapConfiguration (static)Zero overhead, compile-time optimized
    Complex transformations needing servicesIFacetMapConfigurationInstanceFull DI support, easier testing
    Database lookupsIFacetMapConfigurationAsyncInstanceProper async/await with injected DbContext
    API callsIFacetMapConfigurationAsyncInstanceNon-blocking I/O with injected HttpClient
    Mixed fast/slow operationsIFacetMapConfigurationHybridInstanceBest of both worlds with DI
    Large collectionsParallel async methods with instancesImproved throughput with shared services
  5. Understand ToSource behavior with Include mode

    master

    When a Facet is defined using Include mode, calling .ToSource() on the DTO will create a source object where any property not included in the Include list is assigned its default value (e.g., 0 for int, false for bool, null or string.Empty for string).

    [Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName)])]
    public partial class UserContactDto;
    
    var dto = new UserContactDto { FirstName = "John", LastName = "Doe" };
    var sourceUser = dto.ToSource();
    
    // sourceUser.FirstName is "John"
    // sourceUser.Id is 0 (default)
    // sourceUser.IsActive is false (default)
  6. Handle inheritance in Facet mapping

    master

    Facet supports inheritance in two directions:

    1. Source Inheritance: If your domain models use inheritance, Facet automatically includes properties from base classes in the DTO. You can exclude specific properties (like sensitive data) by passing their names to the [Facet] attribute.

      • Example: [Facet(typeof(Employee), "Password", "Salary")] excludes Password and Salary from the EmployeeDto.
    2. DTO Inheritance: Your facet types can inherit from base classes. Facet will not duplicate properties that are already defined in the base class of the DTO.

    Generic Base Classes: Facet correctly handles generic base classes (e.g., BaseEntity<TKey>). You can exclude properties from generic bases just like standard properties.

    // Base domain model
    public class User
    {
        public int Id { get; set; }
        public string FirstName { get; set; }
        public string Password { get; set; }
    }
    
    // Derived domain model
    public class Employee : User
    {
        public string Department { get; set; }
    }
    
    // Facet for Employee - includes User properties automatically, but excludes Password
    [Facet(typeof(Employee), "Password")]
    public partial class EmployeeDto;
  7. Use OutputType.Interface as a contract producer

    master

    Setting OutputType = OutputType.Interface generates an interface declaring each entity-mapped property as a get-only member. This is ideal when you want to write your own DTOs (e.g., positional records with validation) but want the build to fail if the domain entity changes in a way that the DTO no longer satisfies.

    Key Behaviors:

    • Naming: Prepends I to the name. Prefix and Suffix are placed between I and the entity name (e.g., IAdminUpdateUserRequest).
    • Emitted Content: Only property declarations ({ get; }) are emitted. Constructors, projections, ToSource/BackTo methods, and the [Facet] attribute are not emitted.
    • Patch DTOs: DtoTypes.Patch is skipped because it requires concrete implementation for Optional<T> and ApplyTo.

    Example: Contract Enforcement

    // Entity defines the contract
    [GenerateDtos(Types = DtoTypes.Update, OutputType = OutputType.Interface)]
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string? Email { get; set; }
        public bool IsActive { get; set; }
    }
    
    // Hand-written record satisfies the interface
    // If User.Name is removed, this record will cause a compile error.
    public sealed record UpdateUserRequest(
        int Id,
        [Required] string Name,
        string? Email,
        bool IsActive) : IUpdateUserRequest;
    [GenerateDtos(Types = DtoTypes.Update, OutputType = OutputType.Interface)]
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string? Email { get; set; }
        public bool IsActive { get; set; }
    }
    
    public sealed record UpdateUserRequest(
        int Id,
        [Required] string Name,
        string? Email,
        bool IsActive) : IUpdateUserRequest;
  8. Compare ApplyFacet vs UpdateFromFacet

    master

    When performing reverse mapping (Facet $\rightarrow$ Source), choose between these two approaches based on your persistence layer:

    1. ApplyFacet (Facet.Extensions): Works with any object. No EF Core dependency. Uses reflection. Best for general-purpose repositories.
    2. UpdateFromFacet (Facet.Extensions.EFCore): Requires a DbContext. Integrates directly with EF Core's change tracking for selective updates.
  9. Compose Interfaces with Concrete DTOs using OutputType

    master

    When you use both OutputType.Interface and a concrete output kind (like Class, Record, Struct, or PartialClass) with overlapping DtoTypes, the generator automatically pairs them. The concrete type will declare the generated interface as its base, creating a contract + implementation set.

    Requirements for Pairing

    • The two attributes must have equal Prefix, Suffix, and Namespace.
    • The DtoTypes must overlap. If an interface covers Create | Update and a partial class covers Update | Response, only Update will be paired. Create will be interface-only, and Response will be a plain partial class.

    Combining via Flags

    Since OutputType is a [Flags] enum, you can collapse multiple attributes into one using the bitwise OR (|) operator. This is cleaner than writing multiple attributes.

    Error Codes

    • FAC101: Occurs when combining multiple concrete kinds (e.g., Class | Record). This is rejected because they would generate identically-named types.
    • FAC102: Occurs when setting OutputType.Partial without any kind bits (e.g., Class, Record, etc.).
    // One attribute: a partial record implementing a partial interface.
    [GenerateDtos(Types = DtoTypes.Update, 
        OutputType = OutputType.Interface | OutputType.Record | OutputType.Partial)]
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
  10. Configure Naming Strategies for flattened properties

    master

    You can choose how the names of the generated properties are constructed using the NamingStrategy parameter:

    Prefix Strategy (Default)

    Concatenates the full path of the property to create the name. Example: Address.Street becomes AddressStreet.

    LeafOnly Strategy

    Uses only the name of the final property in the path. Example: Address.Street becomes Street.

    Warning: LeafOnly can cause name collisions if multiple nested objects have properties with the same name. Facet resolves these by adding numeric suffixes (e.g., Name2, Name3).

    // Prefix Strategy
    [Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.Prefix)]
    public partial class PersonFlatDto { }
    // Result: FirstName, AddressStreet, AddressCity
    
    // LeafOnly Strategy
    [Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.LeafOnly)]
    public partial class PersonFlatDto { }
    // Result: FirstName, Street, City
  11. Use Include Mode in Facet to select specific properties

    master

    In Include Mode, you explicitly define which properties from the source should be mapped using the Include option in the [Facet] attribute. Only the specified properties will be generated in the DTO. When GenerateToSource = true is used with Include Mode, the generated ToSource() method initializes excluded properties with their default values.

    [Facet(typeof(User), Include = [nameof(User.FirstName), nameof(User.LastName)])]
    public partial class UserIncludeDto;