Flatten your models with Facet .NET
Introduction
Flatten your models with Facet .NET

Introduction
I’m excited to introduce the newest addition to the Facet library: the Flatten attribute! This powerful source generator automatically transforms hierarchical object structures into flat DTOs, eliminating the tedious manual work of creating denormalized data transfer objects.
What is Flatten?
The [Flatten] attribute is a source generator that automatically discovers all properties in a nested object hierarchy and generates a flat DTO with all nested properties promoted to the top level. It handles:
- Automatic Property Discovery: Recursively traverses your domain model to find all flattenable properties
- Null-Safe Access: Generates code with null-conditional operators (
?.) to prevent NullReferenceExceptions - LINQ Projection Support: Creates static
Expressionproperties for efficient Entity Framework queries - Flexible Configuration: Control depth, exclude specific paths, and customize naming strategies
- ID Filtering: Optionally exclude foreign keys and nested IDs for cleaner API responses
- FK Clash Detection: Eliminate duplicate foreign key data automatically
Why Flatten Objects?
Flattening is useful in several real-world scenarios:
1. API Responses
Instead of returning nested JSON with complex object graphs:
{
"firstName": "John",
"lastName": "Doe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"country": {
"name": "USA",
"code": "US"
}
}
}
You can return a cleaner, flat structure:
{
"firstName": "John",
"lastName": "Doe",
"addressStreet": "123 Main St",
"addressCity": "Springfield",
"addressCountryName": "USA",
"addressCountryCode": "US"
}
2. Report Generation
Reports often need all data at one level without nested objects. Flat DTOs are perfect for generating CSV files, Excel spreadsheets, or tabular reports.
3. Search Results
UI components displaying search results often need all relevant information in a flat list structure for easy binding and display.
4. Database Efficiency
Using LINQ projection expressions, you can select only the fields you need and let Entity Framework generate efficient SQL queries that run entirely in the database.
Getting Started
Basic Usage
Let’s start with a simple example. Suppose you have these domain models:
public class Person
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public Address Address { get; set; }
public ContactInfo ContactInfo { get; set; }
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public Country Country { get; set; }
}
public class Country
{
public int Id { get; set; }
public string Name { get; set; }
public string Code { get; set; }
}
public class ContactInfo
{
public string Email { get; set; }
public string Phone { get; set; }
}
To create a flattened DTO, simply add the [Flatten] attribute:
[Flatten(typeof(Person))]
public partial class PersonFlatDto { }
That’s it! The source generator will create all the properties and mapping logic for you:
public partial class PersonFlatDto
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime DateOfBirth { get; set; }
public string AddressStreet { get; set; }
public string AddressCity { get; set; }
public string AddressState { get; set; }
public string AddressZipCode { get; set; }
public int AddressCountryId { get; set; }
public string AddressCountryName { get; set; }
public string AddressCountryCode { get; set; }
public string ContactInfoEmail { get; set; }
public string ContactInfoPhone { get; set; }
// Constructor for easy conversion
public PersonFlatDto(Person source) { /* generated */ }
// Parameterless constructor
public PersonFlatDto() { }
// LINQ projection expression
public static Expression<Func<Person, PersonFlatDto>> Projection { get; }
}
Using the Generated DTO
There are three ways to use the generated DTO:
1. Constructor-Based Mapping
var person = await dbContext.People
.Include(p => p.Address)
.ThenInclude(a => a.Country)
.Include(p => p.ContactInfo)
.FirstAsync(p => p.Id == 1);
var dto = new PersonFlatDto(person);
2. LINQ Projection (Recommended)
// This runs entirely in the database - much more efficient!
var dto = await dbContext.People
.Where(p => p.Id == 1)
.Select(PersonFlatDto.Projection)
.FirstAsync();
3. Batch Projections
var dtos = await dbContext.People
.Where(p => p.IsActive)
.OrderBy(p => p.LastName)
.Select(PersonFlatDto.Projection)
.ToListAsync();
Advanced Features
Excluding Properties
You can exclude specific properties or entire nested objects:
// Exclude specific properties
[Flatten(typeof(Person), "DateOfBirth", "ContactInfo.Phone")]
public partial class PersonPublicDto { }
// Exclude entire nested object
[Flatten(typeof(Person), "ContactInfo")]
public partial class PersonWithoutContactDto { }
Controlling Depth
By default, Flatten traverses up to 3 levels deep. You can customize this:
// Only flatten 2 levels (Person -> Address, but not Address -> Country)
[Flatten(typeof(Person), MaxDepth = 2)]
public partial class PersonShallowDto { }
// Unlimited depth (use with caution!)
[Flatten(typeof(Person), MaxDepth = 0)]
public partial class PersonDeepDto { }
Ignoring Nested IDs
Often, you don’t want foreign keys and nested IDs in your API responses. The IgnoreNestedIds feature helps:
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public int CustomerId { get; set; } // Foreign key
public Customer Customer { get; set; }
}
public class Customer
{
public int Id { get; set; } // Nested ID
public string Name { get; set; }
public string Email { get; set; }
public int? PreferredAddressId { get; set; } // Another FK
public Address PreferredAddress { get; set; }
}
[Flatten(typeof(Order), IgnoreNestedIds = true)]
public partial class OrderDisplayDto { }
The generated DTO will include:
Id(root level ID)OrderDateCustomerNameCustomerEmailCustomerPreferredAddressStreet,CustomerPreferredAddressCity, etc.
But will exclude:
CustomerId(foreign key)CustomerId(nested ID)CustomerPreferredAddressId(nested foreign key)
This creates much cleaner API responses focused on display data rather than relational database implementation details.
Ignoring Foreign Key Clashes
Entity Framework models often have both foreign key properties AND navigation properties, leading to duplicate data when flattened. The new IgnoreForeignKeyClashes feature solves this elegantly!
Consider this common EF pattern:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public int? AddressId { get; set; } // Foreign key
public Address Address { get; set; } // Navigation property
}
public class Address
{
public int Id { get; set; }
public string Line1 { get; set; }
public string City { get; set; }
}
Without IgnoreForeignKeyClashes, flattening creates duplicate ID data:
[Flatten(typeof(Person))]
public partial class PersonFlatDto
{
public int Id { get; set; }
public string Name { get; set; }
public int? AddressId { get; set; } // FK property
public int? AddressId2 { get; set; } // Address.Id (collision!)
public string AddressLine1 { get; set; }
public string AddressCity { get; set; }
}
Notice AddressId2 - this is Address.Id being flattened, but it represents the same data as AddressId!
The Solution
Enable IgnoreForeignKeyClashes to automatically detect and skip these duplicates:
[Flatten(typeof(Person), IgnoreForeignKeyClashes = true)]
public partial class PersonFlatDto
{
public int Id { get; set; }
public string Name { get; set; }
public int? AddressId { get; set; } // FK kept
public string AddressLine1 { get; set; }
public string AddressCity { get; set; }
// Address.Id is automatically skipped!
}
The generator intelligently:
- Detects FK patterns: Identifies properties ending with “Id” that have matching navigation properties
- Skips nested IDs: When flattening a navigation property, skips its
Idif it would clash with a FK - Handles deep nesting: Works at ALL depths, not just one level
- Preserves root FKs: Root-level foreign keys are always included for reference
Naming Strategies
Choose how nested properties are named;
Prefix Strategy (Default)
Properties are prefixed with their full path:
[Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.Prefix)]
public partial class PersonFlatDto { }
// Generated properties:
// AddressStreet
// AddressCity
// AddressCountryName
// ContactInfoEmail
LeafOnly Strategy
Uses only the leaf property name (watch out for collisions!):
[Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.LeafOnly)]
public partial class PersonFlatDto { }
// Generated properties:
// Street
// City
// Name (from Country.Name)
// Email
If there are collisions, numeric suffixes are added automatically:
// If Person.Name and Address.Country.Name both exist:
// Name (from Person.Name)
// Name2 (from Address.Country.Name)
How It Works
The Flatten attribute uses C# source generators to analyze your domain models at compile time and generate optimized code. Here’s what happens behind the scenes:
- Discovery: The generator recursively walks your source type’s property treeFiltering: Properties are filtered based on exclusion rules, depth limits, and type classifications
- Naming: Property names are generated using your chosen naming strategy
- Code Generation: Three things are generated:
- Properties with XML documentation showing the source path
- A constructor that uses null-conditional operators for safe access
- A LINQ
Expressionfor database projections
Type Classification
The generator intelligently classifies types:
- Leaf Types (flattened as properties): primitives, strings, enums, DateTime, Guid, Decimal, simple value types (0–2 properties)
- Complex Types (recursed into): reference types with properties, value types with 3+ properties
- Collections (completely ignored): Lists, Arrays, IEnumerable, etc.
Safety Features
- Null Safety: All nested property access uses null-conditional operators (
?.) - Recursion Protection: Tracks visited types to prevent infinite loops
- Depth Limiting: Default max depth of 3, with a hard safety limit of 10
- Collision Handling: Automatically adds numeric suffixes when property names collide
Best Practices
- Use Projections for Database Queries
Always prefer LINQ projections over constructor-based mapping when querying databases:
// Good - runs in database
var dtos = await dbContext.People
.Select(PersonFlatDto.Projection)
.ToListAsync();
// Not optimal - loads entire object graphs into memory first
var dtos = await dbContext.People
.Include(p => p.Address)
.ThenInclude(a => a.Country)
.ToListAsync()
.Select(p => new PersonFlatDto(p))
.ToList();
2. Limit Depth Appropriately
Don’t flatten too deep unless necessary. Deep nesting can create DTOs with dozens of properties:
// Usually sufficient
[Flatten(typeof(Order), MaxDepth = 3)]
public partial class OrderDto { }
3. Exclude Sensitive Data
Always exclude sensitive information from API DTOs:
[Flatten(typeof(Employee), "Salary", "SSN", "BankAccount")]
public partial class EmployeePublicDto { }
4. Use IgnoreNestedIds for Display DTOs
Clean up your API responses by filtering out implementation details:
[Flatten(typeof(Order), IgnoreNestedIds = true)]
public partial class OrderDisplayDto { }
5. Combine Features for Cleaner APIs
// Ultra-clean public API
[Flatten(typeof(Product),
IgnoreNestedIds = true, // No FKs exposed
exclude: new[] { "InternalNotes", "CostPrice" })] // No internal data
public partial class ProductPublicDto { }
// Admin API with relationships
[Flatten(typeof(Product),
IgnoreForeignKeyClashes = true, // Keep FKs, avoid duplicates
MaxDepth = 2)] // Limit depth
public partial class ProductAdminDto { }
Troubleshooting
Name Collisions
If you see properties like Name2, Name3, etc., you have naming collisions. Consider:
- Using the Prefix naming strategy (default)
- Excluding one of the conflicting properties
- Using
UseFullName = trueto include the full type name in the prefix
Missing Properties
If properties aren’t being generated:
- Check if they exceed your
MaxDepthsetting - Verify they’re not in your
Excludelist - Ensure the type isn’t a collection (collections are always excluded)
- Check if
IgnoreNestedIds = trueis filtering them out
Circular References
The generator tracks visited types to prevent infinite loops. If you have circular references in your domain model, the generator will stop recursing when it detects the cycle.
Conclusion
The Flatten attribute is a powerful addition to the Facet library that eliminates the tedious work of creating flat DTOs. Whether you’re building APIs, generating reports, or optimizing database queries, Flatten can save you time and reduce errors.
Key benefits:
- Zero Boilerplate: No manual DTO property definitions or mapping code
- Type Safe: Compile-time code generation means no runtime reflection
- Null Safe: Generated code handles null nested objects automatically
- Efficient: LINQ projections enable optimal database queries
- Smart FK Handling: Eliminates duplicate foreign key data automatically
- Flexible: Extensive configuration options for every use case
Try it out in your next project and let me know what you think!
Installation
dotnet add package Facet
Resources
Happy flattening!
메타데이터
- post_id
- e578316f4c2e
- slug
- flatten-your-models-with-facet-net-e578316f4c2e
- url
- https://medium.com/@timmaes/flatten-your-models-with-facet-net-e578316f4c2e
- canonical_url
- https://medium.com/@timmaes/flatten-your-models-with-facet-net-e578316f4c2e
- author_url
- https://medium.com/@timmaes
- status
- ok
- fetched_at
- 2026-06-23 17:05:31