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

KindDescriptionExample
ManyToOneFK reference to a single entityPost.Author
OneToManyCollection of dependent entitiesUser.Posts
ManyToManyJunction table relationshipPost.Tags
OneToOne1:1 relationshipUser.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

ModeDescriptionRequest Format
NoneNo write support(field not accepted)
ByIdWrite via FK field{"authorId": 5}
ByIdListWrite via ID array{"tagIds": [1, 2, 3]}
NestedDisabledNested objects explicitly rejectedReturns 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

RuleDefault
Properties without [CrudRelation]Not included in writes, not expanded
Write mode defaultNone — no writes unless explicitly enabled
Nested object writesNot allowed — relations are written by ID only
Relation write targetsMust exist and be visible (tenant/RLS/soft-delete scoped) — otherwise 400
ExpansionRequires ReadExpandAllowed = true
DepthMax 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 }
}