Relationships
Learn how to configure many-to-one, one-to-many, and many-to-many relationships between entities using Spiderly attributes.
Overview
Relationships in Spiderly are configured via attributes on your C# entity classes. From those attributes, Spiderly generates:
- EF Core relationship configuration (foreign keys, delete behavior, composite keys)
- API endpoints for loading related data
- Angular UI controls (dropdowns, autocompletes, multi-selects, editable lists)
This guide covers all four relationship types using a cohesive blog domain as an example: authors, categories, tags, posts, and comments.
Many-to-One
A many-to-one relationship is defined by adding a virtual navigation property on the child entity pointing to the parent, decorated with [WithMany].
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[Required]
[WithMany(nameof(Category.BlogPosts))]
public virtual Category Category { get; set; }
}Spiderly auto-generates:
- A shadow foreign key property
CategoryIdon theBlogPosttable - An autocomplete dropdown in the Angular UI for selecting a
Category - DTO fields
CategoryIdandCategoryDisplayName
[Required] makes the FK non-nullable — every blog post must have a category. Without [Required], the FK is nullable and the dropdown allows clearing the selection.
Explicit Foreign Key
By default, the FK is a shadow property — EF Core creates the column but you can't reference it directly on the entity. If you need direct access to the FK value, you can declare it as a scalar property alongside the navigation:
public class BlogPost : BusinessObject<long>
{
public long? CategoryId { get; set; }
[Required]
[WithMany(nameof(Category.BlogPosts))]
public virtual Category Category { get; set; }
}Spiderly resolves the FK name via [ForeignKey("...")] or the {Nav}Id convention. When a scalar FK is declared, all generated code (DTOs, mappers, validators, service queries) routes through it instead of the shadow property.
When to use this:
- You write custom LINQ filters and want to avoid EF Core's unresolved
nav.IdJOIN issue —x.CategoryId == idgenerates a cleanWHEREinstead of a spuriousINNER JOIN - You want to set the FK without first loading the parent entity:
post.CategoryId = 42instead ofpost.Category = await _context.Categories.FindAsync(42) - The FK column name doesn't follow the
{Nav}Idconvention and needs an explicit[ForeignKey]mapping
One-to-Many with [WithMany]
To make the relationship bidirectional, add a List<T> collection property on the parent entity. The [WithMany] attribute on the child points to this collection by name:
public class Category : BusinessObject<int>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
public virtual List<BlogPost> BlogPosts { get; } = new();
}public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[Required]
[WithMany(nameof(Category.BlogPosts))]
public virtual Category Category { get; set; }
}Always use the {"{ get; } = new()"} pattern for collection navigation properties — not {"{ get; set; }"}.
This ensures the list is never null.
Unidirectional many-to-one (pointer-only, no back-collection)
The bidirectional pattern above is the default. A navigation property without a matching collection on the target throws at DbContext init: [WithMany(...)] attribute is required for ManyToOne property: X.Y. When you want only the child-to-parent pointer — Comment.LastReadMessage, Message.ParentMessage, etc. — and a back-collection on the target would just be noise, drop the navigation property entirely and configure the relationship manually in OnModelCreating.
public class Comment : BusinessObject<long>
{
public long PostId { get; set; }
// no Post navigation
}// In OnModelCreating
modelBuilder.Entity<Comment>()
.HasOne<Post>()
.WithMany()
.HasForeignKey(c => c.PostId)
.OnDelete(DeleteBehavior.Cascade);This trades Spiderly's generated UI (autocomplete dropdown, {Nav}DisplayName in DTOs) for hand-managed configuration — use it only when the parent is never displayed alongside the child in the admin UI.
Delete Behavior: [CascadeDelete] vs [SetNull]
By default, Spiderly blocks deletion of a parent entity if it has children referencing it (EF Core DeleteBehavior.NoAction). You can change this with two attributes:
[CascadeDelete]
Deleting the parent automatically deletes all children:
public class Comment : BusinessObject<long>
{
[Required]
[StringLength(1000, MinimumLength = 1)]
public string Text { get; set; }
[Required]
[CascadeDelete]
[WithMany(nameof(BlogPost.Comments))]
public virtual BlogPost BlogPost { get; set; }
}When a BlogPost is deleted, all of its Comment entities are deleted too.
[SetNull]
Deleting the parent sets the foreign key to null on children:
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[SetNull]
[WithMany(nameof(Author.BlogPosts))]
public virtual Author Author { get; set; }
}When an Author is deleted, their blog posts remain but AuthorId becomes null.
[SetNull] requires a nullable foreign key — do not combine it with [Required].
A required FK means the child cannot exist without the parent, which contradicts set-null behavior.
Summary
| Attribute | On Delete | Use When |
|---|---|---|
| (none) | Deletion blocked if children exist | Parent is referenced master data |
[CascadeDelete] | All children deleted | Children have no meaning without parent |
[SetNull] | FK set to null on children | Children can exist independently |
One-to-One with [WithOne]
A one-to-one relationship is defined with [WithOne] on the dependent (foreign-key-holding) side's single-valued navigation. Its presence designates that side as the dependent; the other side is the principal — a plain navigation with no attribute. Use this for a true 1-1 between two independent entities; for a value object that only lives inside its parent, inline the fields or use an EF owned type instead.
public class Conversation : BusinessObject<long> // dependent — owns the FK
{
public long? OwningTaskItemId { get; set; } // explicit FK; nullable => optional 1-1
[WithOne(nameof(TaskItem.Conversation))]
[CascadeDelete] // delete the TaskItem => delete its Conversation
public virtual TaskItem OwningTaskItem { get; set; }
}
public class TaskItem : BusinessObject<long> // principal
{
public virtual Conversation Conversation { get; set; } // inverse navigation, no attribute
}This maps a real one-to-one (HasOne().WithOne().HasForeignKey()), adds an automatic unique index on the FK, flattens the dependent's DTO to {Nav}Id + {Nav}DisplayName (like many-to-one), and renders an autocomplete on the dependent's page.
Required vs optional is the dependent FK's nullability:
[Required]on the[WithOne]navigation → non-nullable FK → the dependent must have a principal.- No
[Required]→ nullable FK → optional; the unique index allows many NULLs (PostgresNULLS DISTINCT/ SQL Server's autoIS NOT NULLfilter — handled by provider conventions, no manual config).
The schema cannot enforce "every principal has a dependent" (that direction is always 0..1), so [Required] on the principal-side navigation is a build error (SPIDERLY021).
Other notes: cascade is app-layer ([CascadeDelete] / [SetNull]), exactly like many-to-one — no DB-level ON DELETE CASCADE. A parameterless [WithOne] makes the relationship unidirectional. Self-referential one-to-one is unsupported (SPIDERLY022). The principal-side inverse renders nothing in the admin by default (the FK lives on the dependent); add [UIDoNotGenerate] to the dependent's nav for a fully code-managed 1-1.
Simple Many-to-Many
A many-to-many relationship requires a junction entity marked with both [M2M] and [SpiderlyEntity], containing two navigation properties decorated with [M2MWithMany]. [M2M] flags the class as a junction; [SpiderlyEntity] enrolls it in the generator pipeline — without it, opposite-M2M navigation lookups return null and the parent entity's generated service fails to compile.
Junction entity
[M2M]
[SpiderlyEntity]
public class BlogPostTag
{
[M2MWithMany(nameof(BlogPost.Tags))]
public virtual BlogPost BlogPost { get; set; }
[M2MWithMany(nameof(Tag.BlogPosts))]
public virtual Tag Tag { get; set; }
}A simple junction entity does not inherit from BusinessObject — it has no Id, Version, or timestamps. Spiderly creates a composite primary key from the two FKs.
Both sides
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[UIControlType(nameof(UIControlTypeCodes.MultiSelect))]
public virtual List<Tag> Tags { get; } = new();
}public class Tag : BusinessObject<int>
{
[DisplayName]
[Required]
[StringLength(50, MinimumLength = 1)]
public string Name { get; set; }
public virtual List<BlogPost> BlogPosts { get; } = new();
}[UIControlType(nameof(UIControlTypeCodes.MultiSelect))] renders a multi-select dropdown. For entities with many items, use MultiAutocomplete instead, which adds search-as-you-type.
[DisplayName] in Relationships
[DisplayName] controls what text appears in dropdowns, autocompletes, and table columns when referencing an entity.
Property-level
Mark a property to use its value as the display text:
public class Author : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
[Required]
[StringLength(200, MinimumLength = 1)]
public string Email { get; set; }
}When a dropdown shows authors, it displays each author's Name.
Class-level
Use dot notation to display a property from a related entity:
[DisplayName("BlogPost.Title")]
public class Comment : BusinessObject<long>
{
[Required]
[StringLength(1000, MinimumLength = 1)]
public string Text { get; set; }
[Required]
[CascadeDelete]
[WithMany(nameof(BlogPost.Comments))]
public virtual BlogPost BlogPost { get; set; }
}When a Comment appears in a list or dropdown, it shows the related BlogPost.Title instead of the comment's own Id.
If no [DisplayName] is specified, the entity falls back to displaying the Id.
[GenerateCommaSeparatedDisplayName]
When displaying many-to-many relationships in list tables, you often want to show all related items as a comma-separated string. Apply [GenerateCommaSeparatedDisplayName] to the collection property:
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[GenerateCommaSeparatedDisplayName]
[UIControlType(nameof(UIControlTypeCodes.MultiSelect))]
public virtual List<Tag> Tags { get; } = new();
}This generates a TagsCommaSeparatedDisplayNames property on the DTO. In the blog post list table, the Tags column shows "Tutorial, C#, Beginner" instead of requiring a separate column for each tag.
Complex Many-to-Many
When a junction entity needs additional fields beyond the two foreign keys, it becomes a complex many-to-many. The junction entity inherits from BusinessObject<T> to get its own Id, Version, and timestamps.
Example: a blog post can have multiple authors, each with a specific role (e.g., "Lead Author", "Contributor"):
[M2M]
[SpiderlyEntity]
public class BlogPostAuthor : BusinessObject<long>
{
[M2MWithMany(nameof(BlogPost.BlogPostAuthors))]
public virtual BlogPost BlogPost { get; set; }
[M2MWithMany(nameof(Author.BlogPostAuthors))]
public virtual Author Author { get; set; }
[Required]
[StringLength(50, MinimumLength = 1)]
public string Role { get; set; }
}Both sides reference the junction entity directly (not the other-side entity):
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[ComplexManyToManyList]
public virtual List<BlogPostAuthor> BlogPostAuthors { get; } = new();
}public class Author : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
public virtual List<BlogPostAuthor> BlogPostAuthors { get; } = new();
}Read-only display: [ComplexManyToManyReadonlyTable]
Use [ComplexManyToManyReadonlyTable] when the junction data is meaningful context for the parent but is created or edited elsewhere — generated by a background job, owned by a different entity's workflow, or part of an audit trail.
Unlike [ComplexManyToManyList], this renders no add/remove/reorder controls and fetches rows lazily, so it is safe for relationships of any size.
public class Author : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
// Posts authored by this user, shown as a read-only summary on the Author page.
// Editing happens on the BlogPost detail page.
[ComplexManyToManyReadonlyTable]
public virtual List<BlogPostAuthor> BlogPostAuthors { get; } = new();
}Choosing the Right M2M UI
Spiderly provides three attributes for controlling how many-to-many relationships render in the Angular admin UI:
| Attribute | UI Behavior | Loading | Best For |
|---|---|---|---|
[ComplexManyToManyList] | Editable inline list with all other-side entities | Eager | Small sets (e.g., 3 warehouses) |
[SimpleManyToManyTableLazyLoad] | Paginated table with add/remove | Lazy | Large sets, pair with [UITableColumn] |
[ComplexManyToManyReadonlyTable] | Read-only paginated table | Lazy | Display-only complex M2M data |
For simple M2M (no extra fields on junction), use [UIControlType] on the collection property instead:
| Control Type | UI Behavior | Best For |
|---|---|---|
MultiSelect | Multi-select dropdown showing all items | Small lists (e.g., tags, permissions) |
MultiAutocomplete | Search-as-you-type multi-select | Large lists (e.g., users, products) |
[ComplexManyToManyList] loads all entities from the other side into memory at once.
Only use it for small sets (under ~50 items). For larger sets, use
[SimpleManyToManyTableLazyLoad].
Complete Example: Blog Domain
Here is a complete blog domain that ties together all relationship types covered in this guide:
namespace YourAppName.Business.Entities
{
public class Category : BusinessObject<int>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
public virtual List<BlogPost> BlogPosts { get; } = new();
}
public class Author : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; }
[Required]
[StringLength(200, MinimumLength = 1)]
public string Email { get; set; }
public virtual List<BlogPostAuthor> BlogPostAuthors { get; } = new();
}
public class Tag : BusinessObject<int>
{
[DisplayName]
[Required]
[StringLength(50, MinimumLength = 1)]
public string Name { get; set; }
public virtual List<BlogPost> BlogPosts { get; } = new();
}
public class BlogPost : BusinessObject<long>
{
[DisplayName]
[Required]
[StringLength(200, MinimumLength = 1)]
public string Title { get; set; }
[UIControlType(nameof(UIControlTypeCodes.TextArea))]
[StringLength(10000, MinimumLength = 1)]
public string Content { get; set; }
// Many-to-one (required)
[Required]
[WithMany(nameof(Category.BlogPosts))]
public virtual Category Category { get; set; }
// One-to-many (cascade delete)
public virtual List<Comment> Comments { get; } = new();
// Simple many-to-many
[GenerateCommaSeparatedDisplayName]
[UIControlType(nameof(UIControlTypeCodes.MultiSelect))]
public virtual List<Tag> Tags { get; } = new();
// Complex many-to-many
[ComplexManyToManyList]
public virtual List<BlogPostAuthor> BlogPostAuthors { get; } = new();
}
[DisplayName("BlogPost.Title")]
public class Comment : BusinessObject<long>
{
[Required]
[StringLength(1000, MinimumLength = 1)]
public string Text { get; set; }
[Required]
[CascadeDelete]
[WithMany(nameof(BlogPost.Comments))]
public virtual BlogPost BlogPost { get; set; }
}
// Simple M2M junction (no Id, no timestamps)
[M2M]
[SpiderlyEntity]
public class BlogPostTag
{
[M2MWithMany(nameof(BlogPost.Tags))]
public virtual BlogPost BlogPost { get; set; }
[M2MWithMany(nameof(Tag.BlogPosts))]
public virtual Tag Tag { get; set; }
}
// Complex M2M junction (has extra fields)
[M2M]
[SpiderlyEntity]
public class BlogPostAuthor : BusinessObject<long>
{
[M2MWithMany(nameof(BlogPost.BlogPostAuthors))]
public virtual BlogPost BlogPost { get; set; }
[M2MWithMany(nameof(Author.BlogPostAuthors))]
public virtual Author Author { get; set; }
[Required]
[StringLength(50, MinimumLength = 1)]
public string Role { get; set; }
}
}What Spiderly generates from this:
BlogPost → Category: required FK, autocomplete dropdown for selecting categoryBlogPost → Comments: one-to-many, deleting a post cascades to its commentsBlogPost ↔ Tags: simple M2M viaBlogPostTag, multi-select dropdown, tags shown as comma-separated string in list tablesBlogPost ↔ Authors: complex M2M viaBlogPostAuthorwith editableRolefield, inline list UI