Relationships
June 10, 2026 · View on GitHub
DataSurface supports navigation property relationships between resources, with configurable expansion (read) and write behavior — all declared via the [CrudRelation] attribute.
Defining Relationships
[CrudResource("posts")]
public class Post
{
[CrudKey]
public int Id { get; set; }
[CrudField(CrudDto.Read | CrudDto.Create | CrudDto.Filter)]
public int AuthorId { get; set; }
[CrudRelation(
ReadExpandAllowed = true,
WriteMode = RelationWriteMode.ById,
WriteFieldName = "authorId",
RequiredOnCreate = true)]
public User Author { get; set; } = default!;
[CrudRelation(
Kind = RelationKind.ManyToMany,
ReadExpandAllowed = true,
WriteMode = RelationWriteMode.ByIdList,
WriteFieldName = "tagIds")]
public List<Tag> Tags { get; set; } = new();
}
Relation Kinds
| Kind | Description | Example |
|---|---|---|
ManyToOne | FK reference to a single entity | Post.Author |
OneToMany | Collection of dependent entities | User.Posts |
ManyToMany | Junction table relationship | Post.Tags |
OneToOne | 1:1 relationship | User.Profile |
The Kind can often be inferred from the property type. Specify it explicitly when inference is ambiguous.
Read Behavior — Expansion
Enabling Expansion
[CrudRelation(ReadExpandAllowed = true)]
public User Author { get; set; } = default!;
Clients can then request expansion:
GET /api/posts?expand=author
Default Expansion
Automatically expand a relation without the client requesting it:
[CrudRelation(ReadExpandAllowed = true, DefaultExpanded = true)]
public User Author { get; set; } = default!;
DefaultExpanded requires ReadExpandAllowed = true — this is validated at startup.
Depth Limit
Maximum expansion depth is configurable per resource to prevent deep recursive loading:
[CrudResource("posts", MaxExpandDepth = 2)]
public class Post { /* ... */ }
Default is 1. This means expanding author.posts.author would be rejected if depth limit is 1.
Multiple Expansions
GET /api/posts?expand=author,tags
Only relations with ReadExpandAllowed = true can be expanded. Non-expandable relations are silently ignored.
Write Behavior
Write Modes
| Mode | Description | Request Format |
|---|---|---|
None | No write support | (field not accepted) |
ById | Write via FK field | {"authorId": 5} |
ByIdList | Write via ID array | {"tagIds": [1, 2, 3]} |
NestedDisabled | Nested objects explicitly rejected | Returns 400 if nested object sent |
ById — Single FK Reference
For ManyToOne relations, write the FK value directly:
[CrudRelation(WriteMode = RelationWriteMode.ById, WriteFieldName = "authorId")]
public User Author { get; set; } = default!;
Create request:
{
"title": "My Post",
"authorId": 5
}
The target entity must exist and be visible to the caller — targets are loaded through the same tenant isolation, row-level security, and soft-delete scope as reads. An unknown or inaccessible id returns 400 with the write field name (authorId) in errors.
ByIdList — Many-to-Many
For ManyToMany relations, write an array of IDs:
[CrudRelation(WriteMode = RelationWriteMode.ByIdList, WriteFieldName = "tagIds")]
public List<Tag> Tags { get; set; } = new();
Create request:
{
"title": "My Post",
"tagIds": [1, 2, 3]
}
Every requested id must resolve to an existing, visible row (tenant/row-level-security/soft-delete scoped). If any id is unknown or inaccessible, the request fails with 400 and the write field name (tagIds) in errors — ids are never silently dropped. Target ids are matched against the target resource's contract key, falling back to the Id / {TypeName}Id convention.
Required on Create
Make a relation required when creating a resource:
[CrudRelation(WriteMode = RelationWriteMode.ById, RequiredOnCreate = true)]
public User Author { get; set; } = default!;
A missing authorId on POST returns 400.
Foreign Key Property
Explicitly specify the CLR foreign key property when inference is insufficient:
[CrudRelation(
WriteMode = RelationWriteMode.ById,
WriteFieldName = "authorId",
ForeignKeyProperty = "AuthorId")]
public User Author { get; set; } = default!;
Safety Defaults
| Rule | Default |
|---|---|
Properties without [CrudRelation] | Not included in writes, not expanded |
| Write mode default | None — no writes unless explicitly enabled |
| Nested object writes | Not allowed — relations are written by ID only |
| Relation write targets | Must exist and be visible (tenant/RLS/soft-delete scoped) — otherwise 400 |
| Expansion | Requires ReadExpandAllowed = true |
| Depth | Max 1 level by default |
Contract Representation
In the ResourceContract, relations are represented as RelationContract:
{
"name": "Author",
"apiName": "author",
"kind": "ManyToOne",
"targetResourceKey": "User",
"read": { "expandAllowed": true, "defaultExpanded": false },
"write": { "mode": "ById", "writeFieldName": "authorId", "requiredOnCreate": true }
}
Related
- Querying — Expansion — How clients request expansion
- Contracts — RelationContract schema details
- Attributes Reference —
[CrudRelation]properties