Blog
Pick and Omit in C# with Facet .NET: TypeScript Utility Types for .NET
If you’ve worked with TypeScript, you know how powerful utility types like Pick<T, K> and Omit<T, K> are. They let you create new types by selecting or excluding properties from existing types, all at compile-time with full type safety.
Read moreMy thoughts on Vibe Coding as a Senior .NET Engineer
My take on where we are, where we are headed, and how to stay relevant.
Read moreFaceted search in .NET
Eliminate boilerplate and build powerful, type-safe search experiences with source generators.
Read moreBlazor vs MVC: Why Developers Still Choose MVC in 2025
Recently, I came across a fascinating discussion in the .NET community that made me pause and reflect. A developer behind Blazorise asked a simple but profound question: "Why would anyone still choose MVC over Blazor with server-side rendering?" As someone who spends significant time in the .NET ecosystem, I found the responses enlightening and worth exploring.
Read moreAdvanced Flattening with Facet .NET
If you've ever found yourself writing DTOs with properties like CustomerAddressStreet, CustomerAddressCity, ShippingAddressLine1, and ShippingAddressZipCode, you know the pain of manually flattening nested object structures. It's tedious, error-prone, and clutters your codebase with boilerplate.
Read moreFacet: A Source Generator Competing with Traditional Mapping Libraries
A look at Facet, as we discover how source generation is changing the mapping landscape.
Read moreFacets in .NET
The Problem with Traditional DTOs
If you’ve worked with modern C# applications, you’ve written this pattern countless times:
public class User
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; } // Sensitive!
public decimal Salary { get; set; } // Sensitive!
}
// Manual DTO definition
public class UserDto
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
}
// Manual mapper
public static UserDto ToDto(this User user) => new()
{
Id = user.Id,
FirstName = user.FirstName,
LastName = user.LastName,
Email = user.Email
};
This is repetitive, error-prone, and becomes a maintenance nightmare. Add a property to User? Update every DTO, mapper, and LINQ projection. Miss one? Runtime bug.
What is Facetting?
Think of a diamond. The whole stone is your domain model—it contains everything. But when you view it from different angles, you see different facets: specific views that show only what matters from that perspective.
Facet - “One part of an object, situation, or subject that has many parts.”
In software terms, facetting is defining focused, compile-time views of your domain models. Instead of manually creating DTOs, you declare what you want, and Facet generates everything at compile-time.
Key Benefits
- Zero runtime cost - Everything generated at compile time
- Type-safe - Compiler errors if properties don’t exist
- No reflection - Pure C# code generation
- Automatic maintenance - Change domain model, facets update automatically
- Works everywhere - LINQ, Entity Framework Core, APIs
Getting Started
dotnet add package Facet
dotnet add package Facet.Extensions # For LINQ helpers
dotnet add package Facet.Extensions.EFCore # For EF Core integration
Basic Usage
// Define a facet - exclude sensitive properties
[Facet(typeof(User), nameof(User.PasswordHash), nameof(User.Salary))]
public partial record UserPublicDto;
// That's it! Facet generates:
// - All properties except PasswordHash and Salary
// - Constructor from User
// - LINQ projection expression
// - Mapping methods
Using it:
// Single object
User user = GetUser();
var dto = user.ToFacet<User, UserPublicDto>();
// Collection
List<User> users = GetUsers();
var dtos = users.SelectFacets<User, UserPublicDto>();
// EF Core - automatic SQL projection!
var dtos = await dbContext.Users
.Where(u => u.IsActive)
.SelectFacet<UserPublicDto>()
.ToListAsync();
Include vs Exclude Patterns
Exclude (when you want most properties):
[Facet(typeof(User), "PasswordHash", "Salary", "InternalNotes")]
public partial record UserApiDto;
Include (when you want specific properties):
[Facet(typeof(User), Include = ["FirstName", "LastName", "Email"])]
public partial record UserContactDto;
Property Renaming with [MapFrom]
V6 Feature: Rename properties declaratively:
[Facet(typeof(User), GenerateToSource = true)]
public partial class UserDto
{
// Rename FirstName to Name
[MapFrom(nameof(User.FirstName), Reversible = true)]
public string Name { get; set; } = string.Empty;
// Computed expression
[MapFrom(nameof(User.FirstName) + " + \" \" + " + nameof(User.LastName))]
public string FullName { get; set; } = string.Empty;
}
var dto = new UserDto(user);
// dto.Name = "John" (from FirstName)
// dto.FullName = "John Doe" (computed)
// Reverse mapping works!
var entity = dto.ToSource();
// entity.FirstName = dto.Name
Nested property paths:
[Facet(typeof(Employee), exclude: [nameof(Employee.Company)])]
public partial class EmployeeDto
{
[MapFrom("Company.Name")]
public string CompanyName { get; set; } = string.Empty;
[MapFrom("Company.Address.City")]
public string CompanyCity { get; set; } = string.Empty;
}
Conditional Mapping with [MapWhen]
V6 Feature: Map properties only when conditions are met:
public class Order
{
public OrderStatus Status { get; set; }
public DateTime? CompletedAt { get; set; }
public string? TrackingNumber { get; set; }
}
[Facet(typeof(Order))]
public partial class OrderDto
{
// Only map when completed
[MapWhen("Status == OrderStatus.Completed")]
public DateTime? CompletedAt { get; set; }
// Multiple conditions (AND logic)
[MapWhen("IsActive")]
[MapWhen("Status != OrderStatus.Cancelled")]
public string? TrackingNumber { get; set; }
}
Works in EF Core projections! The conditions are translated to SQL.
Multi-Source Mapping
V6.2 Feature: One DTO can map from multiple source types:
[Facet(typeof(UserEntity))]
[Facet(typeof(AdminEntity))]
public partial class UserDto
{
// Properties are the union of both sources
}
// Generates:
// - new UserDto(UserEntity source)
// - new UserDto(AdminEntity source)
// - ProjectionFromUserEntity
// - ProjectionFromAdminEntity
// - ToUserEntity()
// - ToAdminEntity()
Enum Conversion
V6 Feature: Convert enums to string or int with full round-trip:
// Convert to strings
[Facet(typeof(User), ConvertEnumsTo = typeof(string), GenerateToSource = true)]
public partial class UserStringDto;
var dto = new UserStringDto(user);
// dto.Status = "Active" (string, not UserStatus enum)
var entity = dto.ToSource();
// entity.Status = UserStatus.Active (enum)
// Works in projections too!
var dtos = await dbContext.Users
.Select(UserStringDto.Projection)
.ToListAsync();
Collection Type Remapping
V6 Feature: Convert EF Core’s Collection<T> to List<T>:
[Facet(typeof(Order),
CollectionTargetType = typeof(List<>),
NestedFacets = [typeof(OrderItemDto)])]
public partial class OrderDto;
// Collection<OrderItem> → List<OrderItemDto>
// ToSource() restores Collection<OrderItem>
Nested Facets
Handle complex object graphs elegantly:
// Bottom-up approach
[Facet(typeof(Address))]
public partial record AddressDto;
[Facet(typeof(Company), NestedFacets = [typeof(AddressDto)])]
public partial record CompanyDto;
[Facet(typeof(Employee),
exclude: ["PasswordHash", "Salary"],
NestedFacets = [typeof(CompanyDto), typeof(AddressDto)])]
public partial record EmployeeDto;
// Collections work automatically!
public class Order
{
public List<OrderItem> Items { get; set; }
public Address ShippingAddress { get; set; }
}
[Facet(typeof(OrderItem))]
public partial record OrderItemDto;
[Facet(typeof(Order), NestedFacets = [typeof(OrderItemDto), typeof(AddressDto)])]
public partial record OrderDto;
// List<OrderItem> automatically becomes List<OrderItemDto>!
EF Core Integration
Automatic Navigation Loading
No .Include() needed!
// WITHOUT Facet
var employees = await dbContext.Employees
.Include(e => e.Company)
.ThenInclude(c => c.Headquarters)
.Include(e => e.HomeAddress)
.Select(e => new EmployeeDto { ... })
.ToListAsync();
// WITH Facet - automatic!
var employees = await dbContext.Employees
.SelectFacet<EmployeeDto>()
.ToListAsync();
// Facet generates proper JOINs automatically!
Patch Updates
[HttpPut("employees/{id}")]
public async Task<IActionResult> UpdateEmployee(int id, EmployeeUpdateDto dto)
{
var employee = await dbContext.Employees.FindAsync(id);
if (employee == null) return NotFound();
// Updates only changed properties
employee.ApplyFacet(dto, dbContext);
await dbContext.SaveChangesAsync();
return NoContent();
}
// With change tracking
var result = employee.ApplyFacetWithChanges(dto, dbContext);
if (result.HasChanges)
{
logger.LogInformation(
"Employee {Id} updated. Changed: {Properties}",
employee.Id,
string.Join(", ", result.ChangedProperties));
}
Custom Mapping
Synchronous Mapping
public class UserMapper : IFacetMapConfiguration<User, UserDetailDto>
{
public static void Map(User source, UserDetailDto target)
{
target.FullName = $"{source.FirstName} {source.LastName}";
target.Age = CalculateAge(source.DateOfBirth);
target.MembershipLevel = source.CreatedAt < DateTime.Now.AddYears(-5)
? "Gold" : "Silver";
}
private static int CalculateAge(DateTime birthDate)
{
var today = DateTime.Today;
var age = today.Year - birthDate.Year;
if (birthDate.Date > today.AddYears(-age)) age--;
return age;
}
}
[Facet(typeof(User),
exclude: ["PasswordHash", "Salary"],
Configuration = typeof(UserMapper))]
public partial record UserDetailDto
{
public string FullName { get; set; } = string.Empty;
public int Age { get; set; }
public string MembershipLevel { get; set; } = string.Empty;
}
Async Mapping with DI
public class UserEnrichedMapper : IFacetMapConfigurationAsyncInstance<User, UserEnrichedDto>
{
private readonly IProfileService _profileService;
public UserEnrichedMapper(IProfileService profileService)
{
_profileService = profileService;
}
public async Task MapAsync(
User source,
UserEnrichedDto target,
CancellationToken cancellationToken = default)
{
target.ProfileUrl = await _profileService
.GetProfileUrlAsync(source.Id, cancellationToken);
}
}
// Usage
var mapper = serviceProvider.GetRequiredService<UserEnrichedMapper>();
var dto = await user.ToFacetAsync(mapper);
Mapping Hooks
V6 Feature: Before/After hooks for validation and computed values:
public class UserBeforeMap : IFacetBeforeMapConfiguration<User, UserDto>
{
public static void BeforeMap(User source, UserDto target)
{
if (string.IsNullOrEmpty(source.Email))
throw new ValidationException("Email required");
target.MappedAt = DateTime.UtcNow;
}
}
public class UserAfterMap : IFacetAfterMapConfiguration<User, UserDto>
{
public static void AfterMap(User source, UserDto target)
{
target.FullName = $"{target.FirstName} {target.LastName}";
}
}
[Facet(typeof(User),
BeforeMapConfiguration = typeof(UserBeforeMap),
AfterMapConfiguration = typeof(UserAfterMap))]
public partial class UserDto
{
public DateTime MappedAt { get; set; }
public string FullName { get; set; } = string.Empty;
}
Advanced Features
Flatten Nested Objects
public class Person
{
public string FirstName { get; set; }
public Address Address { get; set; }
}
public class Address
{
public string Street { get; set; }
public Country Country { get; set; }
}
[Flatten(typeof(Person))]
public partial class PersonFlatDto;
var dto = new PersonFlatDto(person);
// Generated properties:
// - FirstName
// - AddressStreet
// - AddressCountryName
// - AddressCountryCode
Wrapper (Reference-Based Delegation)
[Wrapper(typeof(User), nameof(User.Password), nameof(User.Salary))]
public partial class PublicUserWrapper;
var user = new User { FirstName = "John", Password = "secret" };
var wrapper = new PublicUserWrapper(user);
wrapper.FirstName = "Jane";
// user.FirstName is now "Jane" (changes propagate!)
// Read-only wrappers
[Wrapper(typeof(Product), ReadOnly = true)]
public partial class ReadOnlyProductView;
Auto-Generate CRUD DTOs
[GenerateDtos(Types = DtoTypes.All, OutputType = OutputType.Record)]
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
}
// Automatically generates:
// - CreateProductRequest (excludes Id, CreatedAt)
// - UpdateProductRequest (includes Id)
// - ProductResponse (includes everything)
// - ProductQuery (all properties nullable)
// - UpsertProductRequest
Global Configuration Defaults
V6 Feature: Set defaults project-wide:
<!-- .csproj or Facet.props -->
<PropertyGroup>
<FacetGenerateProjection>true</FacetGenerateProjection>
<FacetGenerateToSource>true</FacetGenerateToSource>
<FacetNullableProperties>false</FacetNullableProperties>
</PropertyGroup>
Source Signature Tracking
V6 Feature: Detect breaking changes:
[Facet(typeof(User), SourceSignature = "a1b2c3d4")]
public partial class UserDto;
// When User structure changes:
// warning FAC022: Source entity 'User' structure has changed.
// Update SourceSignature to 'e5f6g7h8' to acknowledge this change.
IDE provides code fix to auto-update!
Nullable Properties for Filters
[Facet(typeof(User),
Include = ["FirstName", "LastName", "Email", "IsActive"],
NullableProperties = true,
GenerateToSource = false)]
public partial record UserFilterDto;
// All properties nullable - perfect for query params!
public async Task<List<UserDto>> SearchUsers(UserFilterDto filter)
{
var query = dbContext.Users.AsQueryable();
if (filter.FirstName != null)
query = query.Where(u => u.FirstName.Contains(filter.FirstName));
if (filter.IsActive.HasValue)
query = query.Where(u => u.IsActive == filter.IsActive.Value);
return await query.SelectFacet<UserDto>().ToListAsync();
}
Copy Constructors & Equality
V6 Feature:
[Facet(typeof(User),
GenerateCopyConstructor = true,
GenerateEquality = true)]
public partial class UserDto;
var original = new UserDto(user);
var copy = new UserDto(original); // Clone
copy.Name = "Changed";
// original.Name unchanged
// Value equality
var dto1 = new UserDto(user);
var dto2 = new UserDto(user);
dto1 == dto2; // true (value-based)
Performance
Facet is built for performance:
- Compile-time generation - Zero runtime overhead
- No reflection - Pure C# code
- Optimal SQL - Only fetches needed columns
- Competitive benchmarks - As fast as hand-written code
Benchmark Results:
| Method | Mean (ns) | Ratio | Allocated |
|---|---|---|---|
| Facet | 5.922 | baseline | 40 B |
| Mapperly | 6.227 | 1.05x slower | 40 B |
| Mapster | 13.243 | 2.24x slower | 40 B |
| AutoMapper | 31.459 | 5.31x slower | 40 B |
Best Practices
1. Meaningful Names
// ❌ Generic
public partial record UserDto;
// ✅ Descriptive
public partial record UserPublicProfile;
public partial record UserAdminView;
public partial record UserSearchResult;
2. Bottom-Up Approach
// ✅ Correct order
[Facet(typeof(Address))] // No dependencies
public partial record AddressDto;
[Facet(typeof(Company), NestedFacets = [typeof(AddressDto)])]
public partial record CompanyDto;
[Facet(typeof(Employee), NestedFacets = [typeof(CompanyDto), typeof(AddressDto)])]
public partial record EmployeeDto;
3. Use Exclude for APIs, Include for Specific Cases
// ✅ Public APIs - hide sensitive
[Facet(typeof(User), "PasswordHash", "Salary", "SSN")]
public partial record UserPublicApi;
// ✅ Specific features - only what's needed
[Facet(typeof(User), Include = ["Id", "FirstName", "LastName"])]
public partial record UserAutocomplete;
4. Keep Mappers Focused
// ✅ Good - presentation logic
public static void Map(User source, UserDto target)
{
target.FullName = $"{source.FirstName} {source.LastName}";
target.DisplayAge = $"{CalculateAge(source.DateOfBirth)} years old";
}
// ❌ Bad - business logic belongs in domain
public static void Map(User source, UserDto target)
{
target.CanPurchase = source.Age >= 18; // Business logic!
}
Circular References
Use MaxDepth and PreserveReferences:
public class Author
{
public List<Book> Books { get; set; }
}
public class Book
{
public Author Author { get; set; } // Circular!
}
[Facet(typeof(Author), MaxDepth = 2, NestedFacets = [typeof(BookDto)])]
public partial record AuthorDto;
[Facet(typeof(Book), MaxDepth = 2, NestedFacets = [typeof(AuthorDto)])]
public partial record BookDto;
Getting Started
# Install packages
dotnet add package Facet
dotnet add package Facet.Extensions
dotnet add package Facet.Extensions.EFCore
# Check out the samples
git clone https://github.com/Tim-Maes/Facet
Resources:
Conclusion
Facet eliminates DTO boilerplate while maintaining compile-time safety and zero runtime overhead. With V6’s new features like MapFrom, MapWhen, multi-source mapping, and enum conversion, it’s more powerful than ever.
Whether you’re building microservices, clean architecture applications, or complex domain models, Facet helps you focus on business logic instead of plumbing code.
Try it today and reclaim your productivity!
Questions? Found this helpful? Let me know in the comments or join the Discord community!
Read moreBlazorFrame: Enhanced iframes in Blazor
Iframes (inline frames) are one of the most powerful yet dangerous features of the modern web. They allow us to embed third-party content into our applications, but they also open the door to a host of security vulnerabilities that can compromise our users and data.
Read moreSource Generators: The End of T4 Templates?
In the evolving landscape of .NET Development, code generation has been a cornerstone for creating boilerplate code, improving developer productivity and automating processes. T4 (Text Template Transformation Toolkit) has been the go-to solution for well over a decade for this purpose, offering a powerful but also a somewhat underappreciated templating engine right inside Visual Studio. They have enabled developers to generate everything from data models to fully fledged API clients.
Read more