A C# source generator that auto-generates DTO mapping code from [Facette]-annotated partial records — zero reflection, zero runtime dependencies.

GitHub Repository

This project is entirely vibe coded with Claude Code.

Introduction

Facette is a C# source generator that automatically creates DTO mapping code from annotated partial records. Instead of writing tedious FromSource, ToSource, and LINQ projection methods by hand — or relying on runtime reflection — Facette generates all of it at compile time.

Why Facette?

  • Zero reflection — all mapping code is generated at compile time
  • Zero runtime dependencies — the Facette.Abstractions package contains only attributes
  • Full IntelliSense — generated code is visible in your IDE
  • LINQ projection support — generates Expression<Func<TSource, TDto>> for efficient database queries
  • Compile-time diagnostics — catches configuration mistakes before you run the app

How it works

Facette uses the Roslyn incremental source generator API. When you annotate a partial record with [Facette(typeof(SourceType))], the generator inspects the source type at compile time and emits:

  1. FromSource(source) — a static factory that maps source → DTO
  2. ToSource() — an instance method that maps DTO → source
  3. Projection — a static Expression<Func<TSource, TDto>> for LINQ/EF Core queries
  4. Mapper extensionsToDto(), ProjectToDto() extension methods on the source type

What you write

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto;

What gets generated

public partial record EmployeeDto
{
    public int Id { get; init; }
    public string FirstName { get; init; }
    public string LastName { get; init; }
    // ... all properties except SocialSecurityNumber

    public static EmployeeDto FromSource(Employee source) { /* ... */ }
    public Employee ToSource() { /* ... */ }
    public static Expression<Func<Employee, EmployeeDto>> Projection => /* ... */;
}

No boilerplate. No runtime overhead. Just annotate and go.

Credits

Facette was heavily inspired by:

  • Facet — a source generator that generates DTOs, mappings, and LINQ projections from domain models, with a similar attribute-driven approach
  • Mapperly — a compile-time object mapper for .NET that pioneered the source-generator approach to mapping

This project is entirely vibe coded with Claude Code.

Getting Started

Installation

Add the Facette NuGet packages to your project:

dotnet add package Facette.Abstractions
dotnet add package Facette

The Facette.Abstractions package provides the attributes you use in your code. The Facette package is the source generator that runs at compile time.

Note: The Facette generator package targets netstandard2.0 so it works with any .NET project (net8.0+).

Your first DTO

Given a model:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Description { get; set; } = "";
    public decimal Price { get; set; }
    public string InternalSku { get; set; } = "";
}

Create a DTO by adding a partial record with the [Facette] attribute:

using Facette.Abstractions;

[Facette(typeof(Product), "InternalSku")]
public partial record ProductDto;

That's it. The generator will create properties for every public property on Product except InternalSku, plus FromSource, ToSource, and Projection members.

Using the generated code

// Source → DTO
var dto = ProductDto.FromSource(product);

// DTO → Source
var entity = dto.ToSource();

// LINQ projection (for EF Core queries)
var dtos = dbContext.Products
    .Select(ProductDto.Projection)
    .ToListAsync();

What gets generated

After building, the generator produces a partial record that extends your declaration with:

MemberPurpose
Properties (get; init;)One per included source property
static FromSource(source)Maps source → DTO
ToSource()Maps DTO → source
static ProjectionExpression<Func<TSource, TDto>> for LINQ

To write the generated files to disk for inspection, see Inspecting Generated Code.

Next steps

Inspecting Generated Code

Source generators produce code at compile time, but by default the generated files only live in memory. You can configure your project to write them to disk for inspection, debugging, or version control.

Enabling file output

Add these properties to your .csproj:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

After building, generated files will appear under the Generated/ directory in your project:

Generated/
└── Facette/
    └── Facette.Generator/
        ├── ProductDto.g.cs
        ├── EmployeeDto.g.cs
        ├── ProductFacetteMapperExtensions.g.cs
        └── ...

Excluding from compilation

When EmitCompilerGeneratedFiles is enabled, the generated .cs files are written to disk and included in compilation by default — which causes duplicate symbol errors since the generator already provides them in memory. Exclude the output directory from compilation:

<ItemGroup>
  <Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs" />
</ItemGroup>

Full example

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
  </PropertyGroup>

  <ItemGroup>
    <Compile Remove="$(CompilerGeneratedFilesOutputPath)/**/*.cs" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Facette.Abstractions" Version="*" />
    <PackageReference Include="Facette" Version="*" />
  </ItemGroup>
</Project>

What you'll find

Each [Facette]-annotated DTO produces a .g.cs file containing:

  • The generated partial record with all mapped properties
  • FromSource(), ToSource(), and Projection members
  • Partial method declarations for hooks (OnAfterFromSource, etc.)

If GenerateMapper = true (the default), a separate mapper extensions file is also generated.

Tips

  • Add Generated/ to your .gitignore — these files are reproducible from source and don't need to be committed
  • Use the generated files to understand exactly what Facette produces, which is helpful for debugging mapping issues
  • Your IDE may already show generated files under Dependencies > Analyzers > Facette.Generator without needing EmitCompilerGeneratedFiles

Basic Mapping

Facette generates three core mapping members for every annotated DTO: FromSource, ToSource, and Projection.

FromSource

A static factory method that creates a DTO instance from a source object:

var dto = ProductDto.FromSource(product);

The generated code copies each property from the source, handling nested objects and collections automatically.

ToSource

An instance method that creates a new source object from the DTO:

var entity = dto.ToSource();

This is useful when accepting DTOs from an API and converting them back to domain objects. You can disable this with GenerateToSource = false:

[Facette(typeof(Product), GenerateToSource = false)]
public partial record ProductReadDto;

Projection

A static Expression<Func<TSource, TDto>> property for use in LINQ queries:

var dtos = dbContext.Products
    .Select(ProductDto.Projection)
    .ToListAsync();

Because it's an expression tree (not a compiled delegate), EF Core can translate it to SQL. The projection inlines nested DTO projections and handles collections with inner Select calls.

You can disable projection generation with GenerateProjection = false:

[Facette(typeof(Employee), GenerateProjection = false)]
public partial record EmployeeCommandDto;

Full example

// Model
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public string InternalSku { get; set; } = "";
}

// DTO — excludes InternalSku
[Facette(typeof(Product), "InternalSku")]
public partial record ProductDto;

// Usage
var product = new Product { Id = 1, Name = "Widget", Price = 9.99m, InternalSku = "WDG-001" };

var dto = ProductDto.FromSource(product);
// dto.Id = 1, dto.Name = "Widget", dto.Price = 9.99
// No InternalSku property exists on ProductDto

var roundTripped = dto.ToSource();
// roundTripped.Id = 1, roundTripped.Name = "Widget", roundTripped.Price = 9.99

Controlling generation

PropertyDefaultEffect
GenerateToSourcetrueGenerates the ToSource() method
GenerateProjectiontrueGenerates the Projection expression
GenerateMappertrueGenerates extension methods on the source type

Property Control

Facette provides several ways to control which properties appear on the generated DTO and how they're mapped.

Exclude

Pass property names to exclude in the [Facette] constructor. The second parameter is params string[], so you can list them inline:

[Facette(typeof(Employee), "SocialSecurityNumber", "Notes")]
public partial record EmployeeDto;

Or pass an explicit array with the named parameter:

[Facette(typeof(Employee), exclude: new[] { "SocialSecurityNumber", "Notes" })]
public partial record EmployeeDto;

Both forms are equivalent. The generated DTO will have all of Employee's public properties except SocialSecurityNumber and Notes.

Include

Use the Include named parameter to specify an allowlist. Only listed properties will be generated:

[Facette(typeof(Employee),
    Include = new[] { "Id", "FirstName", "LastName", "Email" })]
public partial record EmployeeSummaryDto;

Note: You cannot use both Include and Exclude on the same DTO. Doing so produces diagnostic FCT002.

FacetteIgnore

Mark properties you define manually on the DTO to prevent the generator from trying to map them:

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto
{
    [FacetteIgnore]
    public string FullName { get; set; } = "";
}

Without [FacetteIgnore], the generator would look for a FullName property on Employee and fail. With it, the property is left untouched — you can populate it in an AfterMap hook.

MapFrom

Rename a property or map it from a different source property:

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto
{
    [MapFrom("Department")]
    public string DepartmentName { get; init; } = "";
}

Here, DepartmentName on the DTO is mapped from the Department property on Employee. Since Department is an enum and DepartmentName is a string, Facette will also auto-detect the enum conversion.

MapFrom also supports dot-notation for flattened paths:

[MapFrom("HomeAddress.State")]
public string State { get; init; } = "";

MapFrom with value conversion

MapFrom has optional Convert and ConvertBack parameters for value conversions:

[MapFrom("HireDate", Convert = nameof(FormatDate), ConvertBack = nameof(ParseDate))]
public string HiredOn { get; init; } = "";

public static string FormatDate(DateTime dt) => dt.ToString("yyyy-MM-dd");
public static DateTime ParseDate(string s) => DateTime.Parse(s);

Referencing non-existent properties

If an Include, Exclude, or MapFrom reference names a property that doesn't exist on the source type, Facette emits a warning diagnostic:

  • FCT004 — property in Include/Exclude not found
  • FCT005 — MapFrom source property not found

Nested Objects

When a source type has a property whose type is also mapped by a Facette DTO, the generator automatically uses the nested DTO in FromSource, ToSource, and Projection.

Auto-detection

If there is exactly one DTO targeting a nested source type, Facette resolves it automatically:

public class Address
{
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string State { get; set; } = "";
    public string ZipCode { get; set; } = "";
}

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; } = "";
    public Address? HomeAddress { get; set; }
}

[Facette(typeof(Address))]
public partial record AddressDto;

[Facette(typeof(Employee))]
public partial record EmployeeDto;
// EmployeeDto.HomeAddress is of type AddressDto

The generated FromSource will call AddressDto.FromSource(source.HomeAddress), and the Projection will inline AddressDto.Projection as a nested member-init expression.

Resolving ambiguity with NestedDtos

When multiple DTOs target the same source type, Facette cannot auto-detect which one to use and emits FCT010. Resolve this by specifying which DTOs to use:

[Facette(typeof(Employee),
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record EmployeeDto;

This tells the generator to use AddressDto whenever it encounters an Address property.

Nullable nested objects

When a source property is nullable (e.g., Address? HomeAddress), the generated mapping handles null correctly:

  • FromSource: source.HomeAddress is not null ? AddressDto.FromSource(source.HomeAddress) : null
  • ToSource: similar null-guarded reverse mapping
  • Projection: null-conditional in the expression tree

The generated DTO property will also be nullable to match.

Circular references

If nested DTOs form a cycle (A → B → A), Facette detects this and emits FCT006 to prevent stack overflows during mapping.

Collections

Facette handles collection properties automatically, mapping each element using the nested DTO's FromSource/ToSource methods.

Supported collection types

The generator recognizes and handles these collection types:

  • List<T>
  • T[] (arrays)
  • IList<T>, ICollection<T>, IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>
  • HashSet<T>, ISet<T>
  • ImmutableArray<T>, ImmutableList<T>

Example

public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; } = "";
    public List<OrderItem> Items { get; set; } = new();
}

public class OrderItem
{
    public string ProductName { get; set; } = "";
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
}

[Facette(typeof(OrderItem))]
public partial record OrderItemDto;

[Facette(typeof(Order), NestedDtos = new[] { typeof(OrderItemDto) })]
public partial record OrderDto;

The generated code:

  • FromSource: source.Items.Select(x => OrderItemDto.FromSource(x)).ToList()
  • ToSource: Items.Select(x => x.ToSource()).ToList()
  • Projection: Items = source.Items.Select(x => new OrderItemDto { ... }).ToList()

Primitive collections

Collections of primitive types (e.g., List<string>, int[]) are copied directly without element-level mapping:

public class Tag
{
    public List<string> Labels { get; set; } = new();
}

[Facette(typeof(Tag))]
public partial record TagDto;
// TagDto.Labels is List<string>, copied as-is

Custom collection converters

For collection types that Facette doesn't handle natively, you can use value conversions with Convert/ConvertBack on [MapFrom] to provide custom conversion logic.

Flattening

Flattening lets you pull nested properties up to the top level of your DTO, avoiding deeply nested objects for simple read scenarios.

Convention flattening

Name a DTO property by concatenating the navigation path, and Facette will automatically resolve it:

public class Employee
{
    public Address? HomeAddress { get; set; }
}

public class Address
{
    public string City { get; set; } = "";
    public string State { get; set; } = "";
}

[Facette(typeof(Employee),
    Include = new[] { "Id", "FirstName", "LastName", "HomeAddress" },
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record EmployeeSummaryDto
{
    public string HomeAddressCity { get; init; } = "";
}

HomeAddressCity is automatically resolved as HomeAddress.City because the property name starts with HomeAddress (a navigation property) followed by City (a property on Address).

The generated code:

  • FromSource: HomeAddressCity = source.HomeAddress?.City ?? default
  • Projection: HomeAddressCity = source.HomeAddress.City

Explicit dot-notation with MapFrom

For non-conventional names or deeper paths, use [MapFrom] with dot notation:

[MapFrom("HomeAddress.State")]
public string State { get; init; } = "";

This maps State on the DTO from HomeAddress.State on the source. The property name doesn't need to follow any convention.

Reverse flattening in ToSource

When generating ToSource(), Facette reverses flattened properties back into their nested structure:

// Generated ToSource handles nested assignment:
var result = new Employee
{
    HomeAddress = new Address
    {
        City = this.HomeAddressCity,
        State = this.State
    }
};

Limitations

  • Convention flattening only activates for multi-segment paths (at least two parts: a navigation property and a leaf property). Single-segment names are never treated as flattened paths.
  • If a path segment doesn't resolve, Facette emits FCT007.

Value Conversions

Value conversions let you transform property values during mapping — for example, formatting a DateTime as a string or converting between custom types.

Defining conversions

Use the Convert and ConvertBack parameters on [MapFrom]:

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto
{
    [MapFrom("HireDate", Convert = nameof(FormatDate), ConvertBack = nameof(ParseDate))]
    public string HiredOn { get; init; } = "";

    public static string FormatDate(DateTime dt) => dt.ToString("yyyy-MM-dd");
    public static DateTime ParseDate(string s) => DateTime.Parse(s);
}

Method requirements

  • Convert — used in FromSource and Projection. Must be a static method on the DTO type that takes the source property type and returns the DTO property type.
  • ConvertBack — used in ToSource. Must be a static method on the DTO type that takes the DTO property type and returns the source property type.

You can specify one without the other:

  • Convert only — ToSource will skip this property (or you can disable GenerateToSource)
  • ConvertBack only — useful when FromSource can map directly but the reverse needs transformation

Generated output

// FromSource
HiredOn = EmployeeDto.FormatDate(source.HireDate)

// ToSource
HireDate = EmployeeDto.ParseDate(this.HiredOn)

// Projection
HiredOn = EmployeeDto.FormatDate(source.HireDate)

Diagnostics

If the referenced method doesn't exist or isn't a static method on the DTO type:

  • FCT008Convert method not found
  • FCT009ConvertBack method not found

Tips

  • Keep conversion methods simple — they're called per-property, per-mapping operation
  • Remember that Projection conversions must be translatable by your LINQ provider if used with EF Core. Method calls in projections may not translate to SQL
  • For enum↔string/int conversions, see Enum Conversion — Facette handles those automatically without custom converters

Enum Conversion

Facette automatically detects type mismatches between enum properties on the source and their DTO counterparts, generating the appropriate conversion code.

Auto-detection

When a source property is an enum and the DTO property is a different type (or vice versa), Facette chooses the right conversion:

public class Employee
{
    public Department Department { get; set; }  // enum
}

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto
{
    [MapFrom("Department")]
    public string DepartmentName { get; init; } = "";  // string
}

Facette detects Department (enum) → DepartmentName (string) and generates:

// FromSource
DepartmentName = source.Department.ToString()

// ToSource
Department = Enum.Parse<Department>(this.DepartmentName)

Conversion kinds

Source TypeDTO TypeFromSourceToSource
enumstring.ToString()Enum.Parse<T>(value)
stringenumEnum.Parse<T>(value).ToString()
enumint(int)value(T)value
intenum(T)value(int)value
EnumAEnumB(EnumB)(int)value(EnumA)(int)value

Projection considerations

Enum-to-string conversions use .ToString() in projections, which some LINQ providers (like EF Core) may not be able to translate to SQL. Facette emits FCT011 as a warning in this case.

If you use EF Core and need enum properties in projections, consider:

  • Keeping the same enum type on the DTO (no conversion needed)
  • Using int on the DTO (cast is translatable)
  • Configuring EF Core to store enums as strings via HasConversion<string>()

Example with OrderStatus

public class Order
{
    public OrderStatus Status { get; set; }  // enum
}

[Facette(typeof(Order), NestedDtos = new[] { typeof(AddressDto), typeof(OrderItemDto) })]
public partial record OrderDto
{
    [MapFrom("Status")]
    public string StatusText { get; init; } = "";
}
var orderDto = OrderDto.FromSource(order);
Console.WriteLine(orderDto.StatusText); // "Shipped"

Nullable Mode

The NullableMode property on [Facette] controls how nullability is handled on generated DTO properties.

Modes

ModeBehavior
Auto (default)Matches the source property's nullability
AllNullableAll generated properties become nullable
AllRequiredAll generated properties have nullability stripped

AllNullable — PATCH operations

AllNullable makes every property nullable, which is ideal for PATCH/partial update DTOs where omitted fields should remain unchanged:

[Facette(typeof(Employee), "SocialSecurityNumber",
    NullableMode = NullableMode.AllNullable)]
public partial record NullableEmployeeDto;

The generated DTO:

public partial record NullableEmployeeDto
{
    public int? Id { get; init; }           // int → int?
    public string? FirstName { get; init; }  // already nullable-ref
    public decimal? Salary { get; init; }    // decimal → decimal?
    public bool? IsActive { get; init; }     // bool → bool?
    // ...
}

Usage in a PATCH endpoint:

var patchDto = new NullableEmployeeDto
{
    FirstName = "Updated",  // only set what changed
    // everything else is null — meaning "don't change"
};

ToSource with AllNullable

When generating ToSource() for AllNullable DTOs, value types use ?? default to handle null:

// Generated ToSource
Id = this.Id ?? default,
Salary = this.Salary ?? default,
IsActive = this.IsActive ?? default

AllRequired

AllRequired strips nullability from all properties. Use this when you want a strict DTO where every field must be provided:

[Facette(typeof(Employee), "SocialSecurityNumber",
    NullableMode = NullableMode.AllRequired)]
public partial record StrictEmployeeDto;

Properties like Address? HomeAddress would become Address HomeAddress (non-nullable).

Auto

The default mode. Each generated property matches the nullability of the corresponding source property exactly.

Copy Attributes

When CopyAttributes = true, Facette copies data annotation and validation attributes from source properties to the generated DTO properties.

Usage

public class Employee
{
    [Required]
    [StringLength(100)]
    public string FirstName { get; set; } = "";

    [EmailAddress]
    public string Email { get; set; } = "";
}

[Facette(typeof(Employee), "SocialSecurityNumber",
    CopyAttributes = true)]
public partial record EmployeeDto;

The generated DTO will include the same attributes:

public partial record EmployeeDto
{
    [Required]
    [StringLength(100)]
    public string FirstName { get; init; }

    [EmailAddress]
    public string Email { get; init; }
}

This is useful for API validation — your DTOs automatically inherit the validation rules defined on your domain models.

Verifying at runtime

var attrs = typeof(EmployeeDto).GetProperty("FirstName")!
    .GetCustomAttributes(false);
// Contains: RequiredAttribute, StringLengthAttribute

Skipped attributes

Some attributes cannot be fully reconstructed at compile time (e.g., attributes with complex constructor arguments or non-constant property values). When Facette encounters these, it skips them and emits FCT012 as an informational diagnostic.

Attributes from certain system namespaces (like System.Runtime.CompilerServices) are also excluded to avoid noise.

When to use

  • API DTOs — share validation attributes between model and DTO without duplication
  • Swagger/OpenAPI — copied attributes are picked up by schema generators
  • FluentValidation interop — if your validators inspect data annotations

When not to use

If your DTO validation rules differ from your domain model, keep CopyAttributes = false (the default) and define attributes manually on the DTO.

CRUD Presets

Facette presets are shortcuts that configure common DTO patterns for create and read operations.

FacettePreset.Create

Configures a DTO for creation operations:

  • Excludes Id properties — new entities shouldn't have an ID yet
  • Disables projection — create DTOs are not used for queries
[Facette(typeof(Employee), "SocialSecurityNumber",
    Preset = FacettePreset.Create,
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record CreateEmployeeDto;
// No Id property exists
typeof(CreateEmployeeDto).GetProperty("Id"); // null

// No Projection property generated
// CreateEmployeeDto.Projection — does not exist

// FromSource and ToSource still work
var dto = CreateEmployeeDto.FromSource(employee);
var entity = dto.ToSource();

FacettePreset.Read

Configures a DTO for read-only operations:

  • Disables ToSource — read DTOs should not be converted back to entities
[Facette(typeof(Employee), "SocialSecurityNumber",
    Preset = FacettePreset.Read,
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record ReadEmployeeDto;
// Has FromSource and Projection
var dto = ReadEmployeeDto.FromSource(employee);

// No ToSource method generated
typeof(ReadEmployeeDto).GetMethod("ToSource"); // null

FacettePreset.Default

The default preset. All generation flags are enabled (FromSource, ToSource, Projection, mapper extensions). This is equivalent to not specifying a preset.

Combining with other features

Presets can be combined with all other Facette features:

[Facette(typeof(Employee), "SocialSecurityNumber",
    Preset = FacettePreset.Create,
    NullableMode = NullableMode.AllRequired,
    CopyAttributes = true,
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record CreateEmployeeDto;

Conditional Mapping

The [MapWhen] attribute lets you conditionally include or exclude a property during mapping based on a runtime condition.

Usage

[Facette(typeof(Employee), "SocialSecurityNumber",
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record ConditionalEmployeeDto
{
    private static bool _includeSalary = true;

    [MapWhen(nameof(ShouldIncludeSalary))]
    public decimal Salary { get; init; }

    public static bool ShouldIncludeSalary() => _includeSalary;
    public static void SetIncludeSalary(bool value) => _includeSalary = value;
}

Generated code

The generator wraps the property assignment in a conditional:

// Generated FromSource
Salary = ConditionalEmployeeDto.ShouldIncludeSalary()
    ? source.Salary
    : default

When the condition returns false, the property gets the default value for its type (0 for decimal, null for reference types, etc.).

Method requirements

The condition method must be:

  • static — no instance required
  • Parameterless — takes no arguments
  • Returns bool — true to include, false to skip

If the method doesn't meet these requirements, Facette emits FCT013.

Runtime toggling

Because the condition is evaluated at mapping time (not compile time), you can change behavior dynamically:

// Include salary for authorized users
ConditionalEmployeeDto.SetIncludeSalary(true);
var withSalary = ConditionalEmployeeDto.FromSource(employee);
Console.WriteLine(withSalary.Salary); // 95000

// Hide salary for unauthorized users
ConditionalEmployeeDto.SetIncludeSalary(false);
var withoutSalary = ConditionalEmployeeDto.FromSource(employee);
Console.WriteLine(withoutSalary.Salary); // 0

Use cases

  • Role-based field visibility — hide sensitive fields based on user role
  • Feature flags — conditionally include new fields during rollout
  • Environment-based — include debug fields only in development

Hooks

Facette generates partial method hooks that you can implement to run custom logic before or after mapping operations.

Available hooks

HookCalled
OnAfterFromSource(TSource source)After FromSource completes
OnBeforeToSource(TSource target)Before ToSource returns, with the target object
OnAfterToSource(TSource target)After ToSource populates the target, before returning

OnAfterFromSource

Runs after all properties have been mapped from the source. Use this to compute derived properties:

[Facette(typeof(Employee), "SocialSecurityNumber",
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record EmployeeDto
{
    [FacetteIgnore]
    public string FullName { get; set; } = "";

    partial void OnAfterFromSource(Employee source)
    {
        FullName = $"{source.FirstName} {source.LastName}";
    }
}
var dto = EmployeeDto.FromSource(employee);
Console.WriteLine(dto.FullName); // "Alice Johnson"

OnBeforeToSource / OnAfterToSource

Run custom logic during ToSource():

[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDto
{
    partial void OnBeforeToSource(Employee target)
    {
        // Runs before properties are assigned to target
    }

    partial void OnAfterToSource(Employee target)
    {
        // Runs after all properties are assigned
        // Useful for setting computed or derived fields on the source
    }
}

How it works

Since hooks are declared as partial void methods, they compile to no-ops if you don't implement them — zero overhead when unused. The generator emits the partial method declarations, and you provide the implementations in your part of the partial record.

Tips

  • Use [FacetteIgnore] on properties that are populated by hooks, so the generator doesn't try to map them from the source
  • Hooks run synchronously and have access to both the DTO instance (this) and the source/target object
  • Hooks don't affect Projection — expression trees cannot contain imperative code

Inheritance

Facette supports DTO inheritance, allowing you to create base DTOs and extend them with additional properties.

Base and derived DTOs

Define a base DTO with shared properties, then create derived DTOs that inherit from it:

[Facette(typeof(Employee), "SocialSecurityNumber",
    GenerateMapper = false)]
public partial record EmployeeBaseDto;

[Facette(typeof(Employee), "SocialSecurityNumber",
    NestedDtos = new[] { typeof(AddressDto) })]
public partial record EmployeeFullDto : EmployeeBaseDto;

The derived DTO inherits all generated properties from the base and adds its own.

GenerateMapper = false

When using inheritance, set GenerateMapper = false on the base DTO to avoid conflicting extension methods. If both the base and derived DTO generate mapper extensions for the same source type, you'll get ambiguous method errors.

// Base — no mapper extensions
[Facette(typeof(Employee), "SocialSecurityNumber",
    GenerateMapper = false)]
public partial record EmployeeBaseDto;

// Derived — generates mapper extensions
[Facette(typeof(Employee), "SocialSecurityNumber")]
public partial record EmployeeDetailDto : EmployeeBaseDto;

How generated code works

Each DTO in the hierarchy generates its own FromSource, ToSource, and Projection independently. The derived DTO's generated members handle all properties (inherited + own), so you can use any DTO in the hierarchy directly:

var baseDto = EmployeeBaseDto.FromSource(employee);
var fullDto = EmployeeFullDto.FromSource(employee);

Tips

  • Use GenerateMapper = false on base DTOs to prevent extension method conflicts
  • Each level generates complete mapping logic — no need to chain base/derived calls
  • Consider using Include on the base DTO to limit it to core fields

Multi-Source Mapping

The [AdditionalSource] attribute lets a DTO combine properties from multiple source types into a single DTO.

Usage

public class Employee
{
    public int Id { get; set; }
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    // ...
}

public class EmployeeProfile
{
    public string Bio { get; set; } = "";
    public string PhotoUrl { get; set; } = "";
}

[Facette(typeof(Employee), "SocialSecurityNumber",
    NestedDtos = new[] { typeof(AddressDto) },
    GenerateProjection = false)]
[AdditionalSource(typeof(EmployeeProfile), "Profile")]
public partial record EmployeeDetailDto;

How it works

The [AdditionalSource] attribute takes two parameters:

  • sourceType — the additional type to map from
  • prefix — a string prefix added to properties from this source

With the prefix "Profile", the properties from EmployeeProfile are generated as:

  • ProfileBio (from Bio)
  • ProfilePhotoUrl (from PhotoUrl)

Generated FromSource

The generated FromSource method accepts multiple source parameters:

public static EmployeeDetailDto FromSource(Employee source, EmployeeProfile source1)
{
    return new EmployeeDetailDto
    {
        Id = source.Id,
        FirstName = source.FirstName,
        // ... Employee properties ...
        ProfileBio = source1.Bio,
        ProfilePhotoUrl = source1.PhotoUrl,
    };
}

Usage

var employee = new Employee { /* ... */ };
var profile = new EmployeeProfile
{
    Bio = "Senior engineer with 10 years of experience",
    PhotoUrl = "https://example.com/photos/alice.jpg"
};

var dto = EmployeeDetailDto.FromSource(employee, profile);
Console.WriteLine(dto.ProfileBio);      // "Senior engineer with 10 years of experience"
Console.WriteLine(dto.ProfilePhotoUrl); // "https://example.com/photos/alice.jpg"

Notes

  • Projection is typically disabled (GenerateProjection = false) for multi-source DTOs, since LINQ projections work with a single IQueryable<T> source
  • Multiple [AdditionalSource] attributes can be stacked for more than two sources
  • The prefix helps avoid property name collisions between sources
  • An empty prefix ("") means properties are added without a prefix — only safe if names don't collide

Expression Mapping

MapExpression rewrites LINQ expressions written against your DTO type into expressions against the source type. This lets you write filters, sorts, and predicates using DTO property names and have them automatically translated for use with IQueryable<TSource>.

Usage

// Write a predicate against the DTO type
Expression<Func<OrderDto, bool>> dtoPredicate = dto => dto.CustomerName == "Bob Smith";

// Rewrite it to work against the source type
Expression<Func<Order, bool>> sourcePredicate = OrderDto.MapExpression(dtoPredicate);

// Use it in a LINQ query
var results = orders.AsQueryable().Where(sourcePredicate).ToList();

How it works

Facette generates a nested ExpressionVisitor inside the DTO that:

  1. Walks the expression tree
  2. Replaces references to DTO properties with their corresponding source property paths
  3. Returns a new expression targeting the source type

This handles renamed properties ([MapFrom]), flattened paths, and nested DTO property access.

Example with renamed properties

[Facette(typeof(Order), NestedDtos = new[] { typeof(AddressDto), typeof(OrderItemDto) })]
public partial record OrderDto
{
    [MapFrom("Status")]
    public string StatusText { get; init; } = "";
}

// This DTO predicate...
Expression<Func<OrderDto, bool>> pred = dto => dto.StatusText == "Shipped";

// ...gets rewritten to reference Order.Status with the appropriate conversion
var sourcePred = OrderDto.MapExpression(pred);

Combining with LINQ

var orders = dbContext.Orders.AsQueryable();

// Filter using DTO expressions
Expression<Func<OrderDto, bool>> filter = dto => dto.CustomerName.StartsWith("B");
var filtered = orders.Where(OrderDto.MapExpression(filter));

// Project to DTOs
var results = filtered.Select(OrderDto.Projection).ToList();

Generated signature

public static Expression<Func<TSource, TResult>> MapExpression<TResult>(
    Expression<Func<TDto, TResult>> expression)

The return type is generic — it preserves whatever TResult your expression returns (bool for predicates, any type for selectors).

See also

EF Core Integration

The Facette.EntityFrameworkCore package provides extension methods that integrate Facette's projection and expression mapping with Entity Framework Core queries.

Installation

dotnet add package Facette.EntityFrameworkCore

ProjectToFacette

Projects an IQueryable<TSource> to IQueryable<TDto> using the DTO's generated Projection expression:

using Facette.EntityFrameworkCore;

var employeeDtos = await dbContext.Employees
    .ProjectToFacette(EmployeeDto.Projection)
    .ToListAsync();

This translates to a SELECT with only the DTO's properties — no over-fetching. The projection is an expression tree, so EF Core translates it to SQL.

ProjectToFacette also works directly on DbSet<T>:

var dtos = await dbContext.Set<Employee>()
    .ProjectToFacette(EmployeeDto.Projection)
    .Where(dto => dto.IsActive)
    .ToListAsync();

WhereFacette

Filters an IQueryable<TSource> using a predicate written against the DTO type:

Expression<Func<EmployeeDto, bool>> filter = dto => dto.DepartmentName == "Engineering";

var engineers = await dbContext.Employees
    .WhereFacette(filter, EmployeeDto.MapExpression)
    .ToListAsync();

WhereFacette calls MapExpression internally to rewrite the DTO predicate into a source predicate, then applies it as a Where clause. The result is still IQueryable<TSource>, so you can chain further queries or project afterward:

var engineerDtos = await dbContext.Employees
    .WhereFacette(filter, EmployeeDto.MapExpression)
    .ProjectToFacette(EmployeeDto.Projection)
    .ToListAsync();

No reflection

Unlike some mapping libraries, Facette's EF Core integration uses explicit parameters — Projection and MapExpression are passed directly. There's no service registration, no runtime type scanning, and no reflection.

Method signatures

// On IQueryable<TSource>
IQueryable<TDto> ProjectToFacette<TSource, TDto>(
    this IQueryable<TSource> query,
    Expression<Func<TSource, TDto>> projection)

IQueryable<TSource> WhereFacette<TSource, TDto>(
    this IQueryable<TSource> query,
    Expression<Func<TDto, bool>> predicate,
    Func<Expression<Func<TDto, bool>>, Expression<Func<TSource, bool>>> mapExpression)

// On DbSet<TSource>
IQueryable<TDto> ProjectToFacette<TSource, TDto>(
    this DbSet<TSource> dbSet,
    Expression<Func<TSource, TDto>> projection)

IQueryable<TSource> WhereFacette<TSource, TDto>(
    this DbSet<TSource> dbSet,
    Expression<Func<TDto, bool>> predicate,
    Func<Expression<Func<TDto, bool>>, Expression<Func<TSource, bool>>> mapExpression)

Considerations

  • Enum-to-string conversions in projections may not translate to SQL — see FCT011
  • Custom Convert methods in projections must be translatable by your EF Core provider
  • Multi-source DTOs ([AdditionalSource]) typically disable projection since queries have a single source

Mapper Extensions

When GenerateMapper = true (the default), Facette generates extension methods on the source type for convenient mapping.

Generated methods

For a DTO like:

[Facette(typeof(Product), "InternalSku")]
public partial record ProductDto;

Facette generates a static class with these extension methods:

public static class ProductFacetteMapperExtensions
{
    public static ProductDto ToProductDto(this Product source)
        => ProductDto.FromSource(source);

    public static IQueryable<ProductDto> ProjectToProductDto(this IQueryable<Product> query)
        => query.Select(ProductDto.Projection);

    public static IQueryable<Product> WhereProductDto(
        this IQueryable<Product> query,
        Expression<Func<ProductDto, bool>> predicate)
        => query.Where(ProductDto.MapExpression(predicate));
}

Usage

// Instead of ProductDto.FromSource(product)
var dto = product.ToProductDto();

// Instead of query.Select(ProductDto.Projection)
var dtos = dbContext.Products.ProjectToProductDto().ToList();

// Instead of query.Where(ProductDto.MapExpression(pred))
var filtered = dbContext.Products
    .WhereProductDto(dto => dto.Name == "Widget")
    .ToList();

Ambiguity with multiple DTOs

When multiple DTOs target the same source type, the extension methods can become ambiguous. For example, if both ProductDto and ProductSummaryDto target Product, calling product.ToProductDto() and product.ToProductSummaryDto() would both exist and work fine — but if the method names collide, use the explicit static calls instead:

var dto = ProductDto.FromSource(product);
var summary = ProductSummaryDto.FromSource(product);

Disabling mapper generation

Set GenerateMapper = false to suppress extension method generation:

[Facette(typeof(Employee), GenerateMapper = false)]
public partial record EmployeeBaseDto;

This is useful for:

Diagnostics

Facette emits compile-time diagnostics to help you catch configuration errors early. All diagnostics use the FCT prefix.

Reference

CodeSeverityTitleDescription
FCT001ErrorType must be partialThe type annotated with [Facette] must be declared as partial.
FCT002ErrorInclude and Exclude conflictBoth Include and Exclude are specified on the same DTO, which is not allowed. Use one or the other.
FCT003ErrorSource type not foundThe SourceType passed to [Facette] could not be resolved. Check that the type exists and is accessible.
FCT004WarningProperty not found on sourceA property name in Include or Exclude doesn't exist on the source type.
FCT005WarningMapFrom property not foundThe SourcePropertyName in [MapFrom] doesn't exist on the source type.
FCT006WarningCircular referenceA nested DTO chain forms a cycle (e.g., A → B → A). Facette skips the cycle to prevent infinite recursion.
FCT007WarningFlattened path segment not foundA segment in a convention-flattened or dot-notation path couldn't be resolved on the source type.
FCT008WarningConvert method not foundThe Convert method specified in [MapFrom] wasn't found as a static method on the DTO type.
FCT009WarningConvertBack method not foundThe ConvertBack method specified in [MapFrom] wasn't found as a static method on the DTO type.
FCT010ErrorAmbiguous nested DTOMultiple DTOs target the same source type and Facette can't determine which to use. Specify NestedDtos on the parent DTO to resolve.
FCT011WarningEnum projection warningAn enum conversion (e.g., .ToString()) in a LINQ projection may not be translatable by EF Core.
FCT012InfoAttribute reconstruction skippedWhen CopyAttributes = true, an attribute on the source property couldn't be fully reconstructed and was skipped.
FCT013ErrorConditional method invalidThe method referenced by [MapWhen] wasn't found, isn't static, isn't parameterless, or doesn't return bool.
FCT099WarningInternal generator errorAn unexpected error occurred during code generation. Please report it.

Treating warnings as errors

You can promote Facette warnings to errors in your project file:

<PropertyGroup>
  <WarningsAsErrors>FCT004;FCT005;FCT006;FCT007;FCT008;FCT009;FCT011</WarningsAsErrors>
</PropertyGroup>

Suppressing diagnostics

To suppress specific diagnostics:

<PropertyGroup>
  <NoWarn>FCT011;FCT012</NoWarn>
</PropertyGroup>