A C# source generator that auto-generates DTO mapping code from [Facette]-annotated partial records — zero reflection, zero runtime dependencies.
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.Abstractionspackage 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:
FromSource(source)— a static factory that maps source → DTOToSource()— an instance method that maps DTO → sourceProjection— a staticExpression<Func<TSource, TDto>>for LINQ/EF Core queries- Mapper extensions —
ToDto(),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
Facettegenerator package targetsnetstandard2.0so 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:
| Member | Purpose |
|---|---|
Properties (get; init;) | One per included source property |
static FromSource(source) | Maps source → DTO |
ToSource() | Maps DTO → source |
static Projection | Expression<Func<TSource, TDto>> for LINQ |
To write the generated files to disk for inspection, see Inspecting Generated Code.
Next steps
- Inspecting Generated Code — export generated
.g.csfiles to disk - Basic Mapping — understand FromSource, ToSource, and Projection in detail
- Property Control — include, exclude, and rename properties
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 recordwith all mapped properties FromSource(),ToSource(), andProjectionmembers- 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
| Property | Default | Effect |
|---|---|---|
GenerateToSource | true | Generates the ToSource() method |
GenerateProjection | true | Generates the Projection expression |
GenerateMapper | true | Generates 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
IncludeandExcludeon 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:
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 inFromSourceandProjection. Must be astaticmethod on the DTO type that takes the source property type and returns the DTO property type.ConvertBack— used inToSource. Must be astaticmethod on the DTO type that takes the DTO property type and returns the source property type.
You can specify one without the other:
Convertonly —ToSourcewill skip this property (or you can disableGenerateToSource)ConvertBackonly — useful whenFromSourcecan 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:
Tips
- Keep conversion methods simple — they're called per-property, per-mapping operation
- Remember that
Projectionconversions 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 Type | DTO Type | FromSource | ToSource |
|---|---|---|---|
enum | string | .ToString() | Enum.Parse<T>(value) |
string | enum | Enum.Parse<T>(value) | .ToString() |
enum | int | (int)value | (T)value |
int | enum | (T)value | (int)value |
EnumA | EnumB | (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
inton 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
| Mode | Behavior |
|---|---|
Auto (default) | Matches the source property's nullability |
AllNullable | All generated properties become nullable |
AllRequired | All 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
Idproperties — 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
| Hook | Called |
|---|---|
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 = falseon 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 fromprefix— a string prefix added to properties from this source
With the prefix "Profile", the properties from EmployeeProfile are generated as:
ProfileBio(fromBio)ProfilePhotoUrl(fromPhotoUrl)
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 singleIQueryable<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:
- Walks the expression tree
- Replaces references to DTO properties with their corresponding source property paths
- 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 —
WhereFacettewrapsMapExpressionfor convenience - Mapper Extensions —
WhereDtoextension method
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
Convertmethods 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:
- Base DTOs in inheritance hierarchies — avoid conflicting extensions
- DTOs where you prefer explicit
FromSourcecalls - Reducing generated code when extensions aren't needed
Diagnostics
Facette emits compile-time diagnostics to help you catch configuration errors early. All diagnostics use the FCT prefix.
Reference
| Code | Severity | Title | Description |
|---|---|---|---|
| FCT001 | Error | Type must be partial | The type annotated with [Facette] must be declared as partial. |
| FCT002 | Error | Include and Exclude conflict | Both Include and Exclude are specified on the same DTO, which is not allowed. Use one or the other. |
| FCT003 | Error | Source type not found | The SourceType passed to [Facette] could not be resolved. Check that the type exists and is accessible. |
| FCT004 | Warning | Property not found on source | A property name in Include or Exclude doesn't exist on the source type. |
| FCT005 | Warning | MapFrom property not found | The SourcePropertyName in [MapFrom] doesn't exist on the source type. |
| FCT006 | Warning | Circular reference | A nested DTO chain forms a cycle (e.g., A → B → A). Facette skips the cycle to prevent infinite recursion. |
| FCT007 | Warning | Flattened path segment not found | A segment in a convention-flattened or dot-notation path couldn't be resolved on the source type. |
| FCT008 | Warning | Convert method not found | The Convert method specified in [MapFrom] wasn't found as a static method on the DTO type. |
| FCT009 | Warning | ConvertBack method not found | The ConvertBack method specified in [MapFrom] wasn't found as a static method on the DTO type. |
| FCT010 | Error | Ambiguous nested DTO | Multiple DTOs target the same source type and Facette can't determine which to use. Specify NestedDtos on the parent DTO to resolve. |
| FCT011 | Warning | Enum projection warning | An enum conversion (e.g., .ToString()) in a LINQ projection may not be translatable by EF Core. |
| FCT012 | Info | Attribute reconstruction skipped | When CopyAttributes = true, an attribute on the source property couldn't be fully reconstructed and was skipped. |
| FCT013 | Error | Conditional method invalid | The method referenced by [MapWhen] wasn't found, isn't static, isn't parameterless, or doesn't return bool. |
| FCT099 | Warning | Internal generator error | An 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>