-
-
Notifications
You must be signed in to change notification settings - Fork 51
11_FlattenAttribute
The [Flatten] attribute automatically generates a flattened projection of a source type, expanding all nested properties into top-level properties. This is particularly useful for API responses, reporting, and scenarios where you need a denormalized view of your data.
Flattening transforms a hierarchical object structure into a flat structure with all nested properties as top-level properties. Instead of manually writing DTOs with properties like AddressStreet, AddressCity, etc., Facet generates them automatically by traversing your domain model.
Before (Domain Model):
public class Person
{
public string FirstName { get; set; }
public Address Address { get; set; }
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
}After (Flattened DTO):
[Flatten(typeof(Person))]
public partial class PersonFlatDto
{
// Auto-generated:
// public string FirstName { get; set; }
// public string AddressStreet { get; set; }
// public string AddressCity { get; set; }
}- Automatic Property Discovery: Recursively traverses nested objects
-
Null-Safe Access: Uses null-conditional operators (
?.) in constructors -
LINQ Projection: Generates
Projectionexpression for Entity Framework - Depth Control: Configurable maximum traversal depth
- Exclusion Paths: Exclude specific nested properties
-
ID Filtering: Optional
IgnoreNestedIdsto exclude foreign keys and nested IDs -
FK Clash Detection: Optional
IgnoreForeignKeyClashesto eliminate duplicate foreign key data - Naming Strategies: Prefix or leaf-only naming
- One-Way Operation: Flattening is intentionally one-way (no ToSource method)
[Flatten(typeof(Person))]
public partial class PersonFlatDto
{
// All properties auto-generated
}
// Usage
var person = new Person
{
FirstName = "John",
Address = new Address { Street = "123 Main St", City = "Springfield" }
};
var dto = new PersonFlatDto(person);
// dto.FirstName = "John"
// dto.AddressStreet = "123 Main St"
// dto.AddressCity = "Springfield"// Efficient database projection
var dtos = await dbContext.People
.Where(p => p.IsActive)
.Select(PersonFlatDto.Projection)
.ToListAsync();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; }
}
public class Country
{
public string Name { get; set; }
public string Code { get; set; }
}
[Flatten(typeof(Person))]
public partial class PersonFlatDto
{
// Auto-generated:
// public string FirstName { get; set; }
// public string AddressStreet { get; set; }
// public string AddressCountryName { get; set; }
// public string AddressCountryCode { get; set; }
}| Parameter | Type | Default | Description |
|---|---|---|---|
sourceType |
Type |
(required) | The source type to flatten from. |
exclude |
string[] |
null |
Property paths to exclude from flattening (e.g., "Address.Country", "Password"). |
Exclude |
string[] |
null |
Same as exclude (named property). |
MaxDepth |
int |
3 |
Maximum depth to traverse when flattening nested objects. Set to 0 for unlimited (not recommended). |
NamingStrategy |
FlattenNamingStrategy |
Prefix |
Naming strategy for flattened properties. |
IncludeFields |
bool |
false |
Include public fields in addition to properties. |
IncludeCollections |
bool |
false |
Include collection properties as-is without flattening their contents. |
GenerateParameterlessConstructor |
bool |
true |
Generate a parameterless constructor for object initialization. |
GenerateProjection |
bool |
true |
Generate a LINQ projection expression for database queries. |
UseFullName |
bool |
false |
Use fully qualified type name in generated file names to avoid collisions. |
IgnoreNestedIds |
bool |
false |
When true, only keeps the root-level Id property and excludes all foreign key IDs and nested IDs. |
IgnoreForeignKeyClashes |
bool |
false |
When true, automatically skips nested ID and FK properties that would duplicate foreign key data. |
Concatenates the full path to create the property name:
[Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.Prefix)]
public partial class PersonFlatDto { }
// Generated properties:
// - FirstName
// - AddressStreet
// - AddressCity
// - AddressCountryNameUses only the leaf property name:
[Flatten(typeof(Person), NamingStrategy = FlattenNamingStrategy.LeafOnly)]
public partial class PersonFlatDto { }
// Generated properties:
// - FirstName
// - Street
// - City
// - Name // Warning: Can cause name collisions!Warning: LeafOnly can cause name collisions if multiple nested objects have properties with the same name. Facet will automatically add numeric suffixes (e.g., Name2, Name3) to resolve collisions.
[Flatten(typeof(Person), exclude: "DateOfBirth", "InternalNotes")]
public partial class PersonFlatDto { }Use dot notation to exclude specific nested paths:
[Flatten(typeof(Person), exclude: "Address.Country")]
public partial class PersonFlatDto
{
// Generated:
// - FirstName
// - AddressStreet
// - AddressCity
// (Country properties excluded)
}[Flatten(typeof(Person), exclude: "ContactInfo")]
public partial class PersonFlatDto
{
// ContactInfo and all its nested properties excluded
}The IgnoreNestedIds parameter provides a convenient way to exclude all ID properties except the root-level Id. This is particularly useful for API responses where you want to display data but don't need all the foreign key IDs and nested entity IDs.
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; }
public DateTime OrderDate { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
}
[Flatten(typeof(Order))]
public partial class OrderFlatDto
{
// Generated:
// public int Id { get; set; }
// public int CustomerId { get; set; } // Foreign key from root
// public int CustomerId2 { get; set; } // Customer.Id (name collision, gets suffix)
// public string CustomerName { get; set; }
// public DateTime OrderDate { get; set; }
}[Flatten(typeof(Order), IgnoreNestedIds = true)]
public partial class OrderFlatDto
{
// Generated:
// public int Id { get; set; } // Root-level Id preserved
// public string CustomerName { get; set; }
// public DateTime OrderDate { get; set; }
// (CustomerId and CustomerId2/Customer.Id excluded)
}-
Root-level
Idis always kept: The top-levelIdproperty of the source type is included -
Foreign key IDs are excluded: Properties like
CustomerId,ProductIdare excluded at the root level -
All nested IDs are excluded: Any
Idproperty from nested objects is excluded
- API responses where you don't want to expose database keys
- Reports where IDs clutter the output
- Search results where you only need the primary ID for navigation
- Export files where human-readable data is preferred over foreign keys
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
public int ManufacturerId { get; set; }
public Manufacturer Manufacturer { get; set; }
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Manufacturer
{
public int Id { get; set; }
public string Name { get; set; }
public string Country { get; set; }
}
[Flatten(typeof(Product), IgnoreNestedIds = true)]
public partial class ProductDisplayDto
{
// Generated:
// public int Id { get; set; } // Root ID kept
// public string Name { get; set; }
// public string CategoryName { get; set; } // Category.Id excluded
// public string ManufacturerName { get; set; } // Manufacturer.Id excluded
// public string ManufacturerCountry { get; set; }
// (CategoryId and ManufacturerId excluded at root)
}
// Clean API response without exposing internal IDs
[HttpGet("products/{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await dbContext.Products
.Where(p => p.Id == id)
.Select(ProductDisplayDto.Projection)
.FirstOrDefaultAsync();
return Ok(product);
// Response: { "id": 1, "name": "Widget", "categoryName": "Tools",
// "manufacturerName": "ACME", "manufacturerCountry": "USA" }
}The IgnoreForeignKeyClashes parameter helps eliminate duplicate ID data when flattening entities with foreign key relationships. When enabled, it automatically detects and skips properties that would represent the same data as a foreign key property.
In Entity Framework models with foreign keys and navigation properties, you often have both:
- A foreign key property (e.g.,
AddressId) - A navigation property (e.g.,
Address) - The referenced entity's ID (e.g.,
Address.Id)
When flattening, both AddressId and Address.Id become AddressId, causing naming collisions and representing the same data twice.
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; }
}
[Flatten(typeof(Person))]
public partial class PersonFlatDto
{
// Generated:
// 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; }
}[Flatten(typeof(Person), IgnoreForeignKeyClashes = true)]
public partial class PersonFlatDto
{
// Generated:
// public int Id { get; set; }
// public string Name { get; set; }
// public int? AddressId { get; set; } // FK property kept
// public string AddressLine1 { get; set; }
// public string AddressCity { get; set; }
// (Address.Id is skipped - would be duplicate of AddressId)
}- Detects FK patterns: Identifies properties ending with "Id" that have a matching navigation property
-
Skips nested IDs: When a navigation property is flattened, its
Idproperty is skipped if it would match a FK - Skips nested FKs: Foreign keys within nested objects are also skipped to avoid deep duplicates
- Preserves root FKs: Foreign keys at the root level are always included
-
Works at all depths: Handles complex scenarios like
Customer.HomeAddressIdandCustomer.HomeAddress.Id
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public int CustomerId { get; set; } // Root FK
public Customer Customer { get; set; }
public int? ShippingAddressId { get; set; } // Root FK
public Address ShippingAddress { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public int? HomeAddressId { get; set; } // Nested FK
public Address HomeAddress { get; set; }
}
public class Address
{
public int Id { get; set; }
public string Line1 { get; set; }
public string City { get; set; }
}
[Flatten(typeof(Order), IgnoreForeignKeyClashes = true)]
public partial class OrderFlatDto
{
// Generated:
// public int Id { get; set; }
// public DateTime OrderDate { get; set; }
// public int CustomerId { get; set; } // Root FK included
// public string CustomerName { get; set; }
// public string CustomerEmail { get; set; }
// public string CustomerHomeAddressLine1 { get; set; }
// public string CustomerHomeAddressCity { get; set; }
// public int? ShippingAddressId { get; set; } // Root FK included
// public string ShippingAddressLine1 { get; set; }
// public string ShippingAddressCity { get; set; }
//
// Skipped (would be duplicates):
// - Customer.Id (would clash with CustomerId)
// - Customer.HomeAddressId (nested FK)
// - Customer.HomeAddress.Id (would clash with CustomerHomeAddressId)
// - ShippingAddress.Id (would clash with ShippingAddressId)
}- Entity Framework models with explicit foreign key properties
- API responses that don't need duplicate ID data
- Cleaner DTOs without naming collisions from IDs
- Database-first models that follow FK conventions
You can use both IgnoreNestedIds and IgnoreForeignKeyClashes together:
[Flatten(typeof(Order), IgnoreNestedIds = true, IgnoreForeignKeyClashes = true)]
public partial class OrderDisplayDto
{
// This combination:
// 1. Ignores ALL ID properties except root (IgnoreNestedIds)
// 2. No FK clashes to worry about since FKs are also IDs (both work together)
//
// Generated:
// public int Id { get; set; } // Root ID only
// public DateTime OrderDate { get; set; }
// public string CustomerName { get; set; }
// public string CustomerEmail { get; set; }
// (All foreign keys and IDs excluded)
}Note: When using both together, IgnoreNestedIds takes precedence since it removes all ID properties (which includes FKs). However, IgnoreForeignKeyClashes provides more granular control if you want to keep root-level FKs while avoiding clash duplicates.
[Flatten(typeof(Person))]
public partial class PersonFlatDto { }
// Traverses up to 3 levels deep[Flatten(typeof(Person), MaxDepth = 2)]
public partial class PersonFlatDto { }
// Only traverses 2 levels deep
// Person.Address.Country would NOT be includedEven with MaxDepth = 0 (unlimited), Facet enforces a safety limit of 10 levels to prevent stack overflow from circular references or extremely deep hierarchies.
Facet automatically determines which types should be flattened as "leaf" properties and which should be recursed into:
- Primitive types (
int,bool,decimal, etc.) string- Enums
-
DateTime,DateTimeOffset,TimeSpan Guid- Value types with 0-2 properties
- Complex reference types with properties
- Value types with 3+ properties
- Collections (Lists, Arrays, IEnumerable, etc.) - These are skipped entirely and don't generate any flattened properties
By default, collections are excluded from flattened types. However, you can opt-in to include collection properties using the IncludeCollections parameter. When enabled, collection properties are "hoisted" as-is into the flattened type without attempting to flatten their contents.
public class ApiResponse
{
public int Id { get; set; }
public string Name { get; set; }
public List<Item> Items { get; set; }
public string[] Tags { get; set; }
}
[Flatten(typeof(ApiResponse))]
public partial class ApiResponseFlatDto
{
// Generated:
// public int Id { get; set; }
// public string Name { get; set; }
// (Items and Tags are excluded)
}[Flatten(typeof(ApiResponse), IncludeCollections = true)]
public partial class ApiResponseFlatDto
{
// Generated:
// public int Id { get; set; }
// public string Name { get; set; }
// public List<Item> Items { get; set; } // Collection included as-is
// public string[] Tags { get; set; } // Array included as-is
}- Collections are not flattened: The collection type and its element type are preserved exactly as declared
- Nested objects are still flattened: Scalar properties from nested objects continue to be flattened
-
Works with all collection types:
List<T>,Array,IEnumerable<T>,ICollection<T>,IList<T>,HashSet<T>, etc. - Naming strategies apply: Collection property names follow the same naming strategy as other properties
API Responses:
public class OrderResponse
{
public int OrderId { get; set; }
public Customer Customer { get; set; }
public List<OrderLine> Lines { get; set; }
}
[Flatten(typeof(OrderResponse), IncludeCollections = true)]
public partial class OrderResponseFlat
{
// Generated:
// public int OrderId { get; set; }
// public string CustomerName { get; set; } // Flattened from Customer
// public string CustomerEmail { get; set; } // Flattened from Customer
// public List<OrderLine> Lines { get; set; } // Collection preserved
}Export/Report Data:
public class ProductExport
{
public int Id { get; set; }
public string Name { get; set; }
public Category Category { get; set; }
public string[] Tags { get; set; }
public List<Image> Images { get; set; }
}
[Flatten(typeof(ProductExport), IncludeCollections = true)]
public partial class ProductExportFlat
{
// Scalar properties flattened, collections preserved
}You can combine IncludeCollections with other Flatten options:
[Flatten(typeof(Order),
IncludeCollections = true,
IgnoreNestedIds = true,
NamingStrategy = FlattenNamingStrategy.SmartLeaf)]
public partial class OrderFlatDto
{
// Collections included
// Nested IDs excluded
// SmartLeaf naming for collisions
}public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public Customer Customer { get; set; }
public Address ShippingAddress { get; set; }
public decimal Total { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { 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; }
}
[Flatten(typeof(Order))]
public partial class OrderListDto
{
// Auto-generated:
// public int Id { get; set; }
// public DateTime OrderDate { get; set; }
// public int CustomerId { get; set; }
// public string CustomerName { get; set; }
// public string CustomerEmail { get; set; }
// public string ShippingAddressStreet { get; set; }
// public string ShippingAddressCity { get; set; }
// public string ShippingAddressState { get; set; }
// public string ShippingAddressZipCode { get; set; }
// public decimal Total { get; set; }
}
// API Usage
[HttpGet("orders")]
public async Task<IActionResult> GetOrders()
{
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Completed)
.Select(OrderListDto.Projection)
.ToListAsync();
return Ok(orders);
}public class Employee
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public Department Department { get; set; }
public Address HomeAddress { get; set; }
public decimal Salary { get; set; }
}
public class Department
{
public string Name { get; set; }
public string Code { get; set; }
public Manager Manager { get; set; }
}
public class Manager
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
}
[Flatten(typeof(Employee), exclude: "Salary")] // Exclude sensitive data
public partial class EmployeeReportDto { }
// Generate report
var report = await dbContext.Employees
.Select(EmployeeReportDto.Projection)
.ToListAsync();
await GenerateExcelReport(report);// Deep hierarchy
public class Organization
{
public string Name { get; set; }
public Location Headquarters { get; set; }
}
public class Location
{
public string Name { get; set; }
public Address Address { get; set; }
}
public class Address
{
public string Street { get; set; }
public City City { get; set; }
}
public class City
{
public string Name { get; set; }
public State State { get; set; }
}
public class State
{
public string Name { get; set; }
public Country Country { get; set; }
}
public class Country
{
public string Name { get; set; }
public string Code { get; set; }
}
// Limit depth to 3 levels
[Flatten(typeof(Organization), MaxDepth = 3)]
public partial class OrganizationSummaryDto
{
// Includes:
// - Name
// - HeadquartersName
// - HeadquartersAddressStreet
// Does NOT include City, State, Country (beyond depth 3)
}- Use for Read-Only Scenarios: Flattening is ideal for API responses, reports, and display models
- Set Appropriate MaxDepth: Don't flatten more than you need - it affects both performance and readability
-
Exclude Sensitive Data: Use
excludeparameter to omit passwords, salaries, or internal fields -
Consider IgnoreNestedIds: For public APIs and reports, use
IgnoreNestedIds = trueto avoid exposing database implementation details -
Prefer Prefix Naming: Avoid
LeafOnlyunless you're certain there won't be name collisions -
Combine with LINQ: Use the
Projectionproperty for efficient database queries - Document Flattened Types: Add XML comments to explain what was flattened and why
[Flatten(typeof(SearchResult), MaxDepth = 2)]
public partial class SearchResultDto { }[Flatten(typeof(DashboardMetrics))]
public partial class MetricsSummaryDto { }[Flatten(typeof(Product), exclude: ["InternalNotes", "CostPrice"], IgnoreNestedIds = true)]
public partial class ProductExportDto { }Problem: Multiple properties end up with the same name when using LeafOnly strategy.
Solution: Use Prefix strategy (default) or manually rename properties in your domain model.
Problem: Expected properties aren't included in the flattened DTO.
Solution: Check MaxDepth setting - you might need to increase it, or the property type might not be considered a "leaf" type.
Problem: Worried about circular references causing infinite loops.
Solution: Facet has built-in protection - it tracks visited types and won't recurse infinitely, plus there's a safety limit of 10 levels.
| Feature | [Flatten] |
[Facet] with NestedFacets
|
|---|---|---|
| Property Structure | All properties at top level | Preserves nested structure |
| Naming | AddressStreet |
Address.Street |
| ToSource Method | No (one-way only) | Yes (bidirectional) |
| Use Case | API responses, reports, exports | Full CRUD, domain mapping |
| Setup | Single attribute, automatic | Requires defining each nested facet |
| Flexibility | Less (automatic) | More (explicit control) |
While the [Flatten] attribute flattens nested objects into a single DTO, the FlattenTo property on the [Facet] attribute unpacks collection properties into multiple rows. This is useful for reports, exports, and scenarios where you need to denormalize a parent-child relationship.
FlattenTo transforms a facet with a collection property into multiple rows, combining the parent's properties with each collection item:
// Source entities
public class DataEntity
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public ICollection<ExtendedEntity> Extended { get; set; }
}
public class ExtendedEntity
{
public int Id { get; set; }
public string Name { get; set; }
public int DataValue { get; set; }
}
// Facet for the collection item
[Facet(typeof(ExtendedEntity))]
public partial class ExtendedFacet;
// Facet for the parent with FlattenTo
[Facet(typeof(DataEntity),
NestedFacets = [typeof(ExtendedFacet)],
FlattenTo = [typeof(DataFlattenedDto)])]
public partial class DataFacet;
// Flattened target (you define the properties you want)
public partial class DataFlattenedDto
{
// Properties from parent (DataEntity)
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
// Properties from collection item (ExtendedEntity)
// Use a prefix to avoid Name collision
public string ExtendedName { get; set; }
public int DataValue { get; set; }
}var entity = new DataEntity
{
Id = 1,
Name = "Parent",
Description = "Parent Description",
Extended = new List<ExtendedEntity>
{
new() { Id = 10, Name = "Item 1", DataValue = 100 },
new() { Id = 20, Name = "Item 2", DataValue = 200 }
}
};
var facet = new DataFacet(entity);
var rows = facet.FlattenTo();
// Result: 2 rows
// Row 1: { Id: 1, Name: "Parent", Description: "Parent Description", ExtendedName: "Item 1", DataValue: 100 }
// Row 2: { Id: 1, Name: "Parent", Description: "Parent Description", ExtendedName: "Item 2", DataValue: 200 }- Row-per-item output: Creates one output row for each collection item
- Parent data replication: Parent properties are copied to each row
- Type-safe: Target types are defined explicitly with the properties you need
- Null-safe: Returns empty list when collection is null or empty
When both parent and child have properties with the same name, prefix the child property:
public class FlattenedOutput
{
// From parent
public int Id { get; set; }
public string Name { get; set; }
// From child - prefixed to avoid collision
public int ItemId { get; set; } // Maps from child's Id
public string ItemName { get; set; } // Maps from child's Name
}Report Generation:
var reportRows = invoices
.Select(i => new InvoiceFacet(i))
.SelectMany(f => f.FlattenTo())
.ToList();
await GenerateExcelReport(reportRows);CSV Export:
var exportRows = orderFacet.FlattenTo();
await csvWriter.WriteRecordsAsync(exportRows);| Feature |
FlattenTo property |
[Flatten] attribute |
|---|---|---|
| Purpose | Unpack collections to rows | Flatten nested objects to properties |
| Output | Multiple rows (List) | Single object |
| Input | Facet with collection | Entity with nested objects |
| Control | You define target properties | Properties auto-generated |
| Use case | Reports, exports | API responses, DTOs |