← All articles

Game of Thrones as a data model: parties, typed relationships, and a TreeList of alliances

Using a domain everyone knows to stress-test a stakeholder model — people, houses, sworn banners and shifting alliances — and rendering the result as a navigable tree.
Game of Thrones as a data model: parties, typed relationships, and a TreeList of alliances

There's a classic modeling failure that shows up in almost every line-of-business codebase: a Customer table, a Supplier table, an Employee table — and then the same company appears in two of them, a person is both an employee and a customer, and every new role means a new table, a new controller and a new set of duplicated address columns.

The fix is old and good: model the party, not the role. One aggregate for every person or organization the system deals with, subtyped where the data differs (people have family names; organizations have legal forms), with roles expressed as typed relationships between parties rather than as tables.

The trouble with explaining party models is that invoice-flavored examples put readers to sleep before the interesting part. So let's use a domain with unusually rich stakeholder data, where everyone already knows the ground truth: Westeros. Houses are organizations, characters are people, and "customer of" becomes "sworn to" — structurally identical, considerably more memorable. The model below is the Stakeholders blueprint that ships with Regira Entities — trimmed of a few conveniences for the article (the salutation field, the relationship-scoped contact details, the non-generic contact-data bridge; the full version is in the blueprint doc) but otherwise as shipped, with only the seed data swapped; at the end we'll fold in Regira.TreeList to turn the web of oaths into an actual tree.

One table for every party in the realm

The base type is abstract, and the subtypes share a single table via TPH — table-per-hierarchy, with a string discriminator:

public abstract class Party(string partyType) : IEntityWithSerial, IHasCode, IHasDescription,
    IHasTimestamps, IArchivable, IHasStartEndDate, IHasNormalizedTitle, IHasNormalizedContent
{
    public int Id { get; set; }
    [MaxLength(16)] public string PartyType { get; init; } = partyType;   // TPH discriminator
    [MaxLength(16)] public string? Code { get; set; }

    public abstract string? Title { get; }                     // computed per subtype
    [MaxLength(256)] public abstract string? NormalizedTitle { get; set; }
    [MaxLength(2048)] public string? Description { get; set; }
    [MaxLength(2048)] public virtual string? NormalizedContent { get; set; }

    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public DateTime Created { get; set; }
    public DateTime? LastModified { get; set; }
    public bool IsArchived { get; set; }

    public ICollection<PartyContactDetails>? ContactData { get; set; }
    public ICollection<PartyAddress>? Addresses { get; set; }
    public ICollection<PartyRelationship>? ChildRelationships { get; set; }   // this party is the Parent
    public ICollection<PartyRelationship>? ParentRelationships { get; set; }  // this party is the Child
}

public static class PartyTypes
{
    public const string Person = "PERSON";
    public const string Organization = "ORGANIZATION";
}

public class Person() : Party(PartyTypes.Person)
{
    [MaxLength(128)] public string? GivenName { get; set; }
    [MaxLength(128)] public string? FamilyName { get; set; }
    [Normalized(SourceProperties = [nameof(FamilyName), nameof(GivenName)])]
    public override string? NormalizedTitle { get; set; }
    public override string Title => $"{GivenName} {FamilyName}".Trim();
}

public class Organization() : Party(PartyTypes.Organization)
{
    [Required, MaxLength(256)] public string Name { get; set; } = null!;
    [MaxLength(32)] public string? LegalEntity { get; set; }    // "Great House", "Sellsword Company", …
    [Normalized(SourceProperty = nameof(Name))]
    public override string? NormalizedTitle { get; set; }
    public override string Title => Name;
}

A few decisions worth pausing on, because they're the ones that pay off later:

  • The discriminator is set by the primary constructor and init-only — a Person physically cannot be saved with the wrong PartyType. Invariants you can't violate beat invariants you validate.
  • Title is computed per subtype and deliberately get-only; keyword search still works because the [Normalized] attribute on each leaf names the real source columns, and the normalizer fills the stored (and worth-indexing) NormalizedTitle on save. The built-in ?q= filter searches NormalizedContent — the same save-time normalizer fills that one too, which is why the base implements IHasNormalizedContent. Search "stark" and you'll find Arya and the House alike.
  • Contact data and addresses hang off the base, so a raven address book works identically for people and houses. (In EF terms these are owned child collections synced by the parent's save; in the blueprint they're the same reusable ContactDetailsBase/AddressBase shapes any entity can adopt.)
  • Everything is IArchivable, which will turn out to be thematically appropriate: in this domain, parties get archived a lot.

In TPH, both subtypes cost one entity registration and one endpoint — a JSON-polymorphic DTO ([JsonPolymorphic] + PersonDto/OrganizationDto) lets GET /parties return a mixed list with each row carrying its partyType.

Oaths as data: typed relationships

Roles are edges, and edges get their own vocabulary table instead of an enum — the realm's political science should be data, editable without a deploy:

public class RelationshipType : IEntityWithSerial, IHasCode, IHasTitle
{
    public int Id { get; set; }
    [MaxLength(16)] public string? Code { get; set; }        // SWORN_TO, MEMBER_OF, MARRIED_TO, ALLIED_WITH
    [Required, MaxLength(64)] public string? Title { get; set; }
}

public class PartyRelationship : IEntityWithSerial, IHasStartEndDate, ISortable
{
    public int Id { get; set; }
    public int ParentId { get; set; }            // liege / house / the "one" side
    public int ChildId { get; set; }             // vassal / member / the "many" side
    public int RelationshipTypeId { get; set; }
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }       // oaths, notoriously, end
    public int SortOrder { get; set; }

    public Party? Parent { get; set; }
    public Party? Child { get; set; }
    public RelationshipType? RelationshipType { get; set; }
}

The DbContext wiring maps the discriminator onto our own PartyType column and adds the two constraints that keep a political database honest:

modelBuilder.Entity<Party>()
    .HasDiscriminator(p => p.PartyType)              // our column, not EF's shadow "Discriminator"
    .HasValue<Person>(PartyTypes.Person)
    .HasValue<Organization>(PartyTypes.Organization);

modelBuilder.Entity<PartyRelationship>(entity =>
{
    entity.HasOne(r => r.Parent).WithMany(p => p.ChildRelationships)
          .HasForeignKey(r => r.ParentId).OnDelete(DeleteBehavior.Restrict);
    entity.HasOne(r => r.Child).WithMany(p => p.ParentRelationships)
          .HasForeignKey(r => r.ChildId).OnDelete(DeleteBehavior.Restrict);
    entity.HasIndex(r => new { r.ParentId, r.ChildId, r.RelationshipTypeId }).IsUnique();
});

Restrict on both FKs because cascading deletes across a self-reference is both rejected by SQL Server and bad history-keeping — deleting House Tyrell should not silently vaporize the record that the Tarlys were ever sworn to them (that's what IsArchived and EndDate are for). The unique index makes a duplicate oath a constraint violation instead of silent data drift.

Now the seed — houses, people, and the edges between them:

var stark     = new Organization { Name = "House Stark",     LegalEntity = "Great House" };
var bolton    = new Organization { Name = "House Bolton" };
var karstark  = new Organization { Name = "House Karstark" };
var mormont   = new Organization { Name = "House Mormont" };
var baratheon = new Organization { Name = "House Baratheon", LegalEntity = "Great House" };

var eddard = new Person { GivenName = "Eddard", FamilyName = "Stark" };
var jorah  = new Person { GivenName = "Jorah",  FamilyName = "Mormont" };

var swornTo  = new RelationshipType { Code = "SWORN_TO",  Title = "Sworn to" };
var memberOf = new RelationshipType { Code = "MEMBER_OF", Title = "Member of" };

static DateTime Y(int year) => new(year, 1, 1);   // Westerosi years fit DateTime fine

List<PartyRelationship> oaths =
[
    new() { Parent = baratheon, Child = stark,    RelationshipType = swornTo,
            StartDate = Y(283) },                 // Warden of the North, since the rebellion
    new() { Parent = stark,     Child = bolton,   RelationshipType = swornTo  },
    new() { Parent = stark,     Child = karstark, RelationshipType = swornTo  },
    new() { Parent = stark,     Child = mormont,  RelationshipType = swornTo  },
    new() { Parent = stark,     Child = eddard,   RelationshipType = memberOf },
    new() { Parent = mormont,   Child = jorah,    RelationshipType = memberOf,
            EndDate = Y(298) },                   // exile: the edge ends, the row remains
];

Note Jorah: the relationship isn't deleted when he flees to Essos — it gets an EndDate. Ten minutes of Westeros trivia buys you the exact discipline temporal business data needs.

From edge list to tree: enter TreeList

Relational storage is the right home for the oaths; it's just the wrong shape for displaying them. "Show me the northern alliance" wants indentation, and that's a one-liner once the edges are in memory — ToTreeList with a parents-selector walks the edge list and wires up the hierarchy, cycles guarded:

using Regira.TreeList;

var parties = new Party[] { baratheon, stark, bolton, karstark, mormont, eddard, jorah };

var tree = parties.ToTreeList(party =>
    oaths.Where(o => o.Child == party && o.EndDate == null)
         .Select(o => o.Parent!));

foreach (var node in tree.OrderByHierarchy())
    Console.WriteLine($"{new string(' ', node.Level * 2)}{node.Value.Title}");
House Baratheon
  House Stark
    House Bolton
    House Karstark
    House Mormont
    Eddard Stark
Jorah Mormont

(Siblings come out in input order — sort the flat list before building the tree if you want them alphabetical; the keyed OrderByHierarchy(keySelector) overload sorts a root's whole subtree instead, which breaks the indentation contract here.)

(Jorah's exiled edge was filtered out by EndDate == null, so he surfaces as a root of his own — sworn to no one. Politically accurate.) Because the selector returns a collection of parents, the model survives the realm's messier arrangements: a house sworn to two lieges, a person who's a member of one organization and sworn to another, appears as a node under each parent. tree.GetSelf(someParty) returns all of that party's nodes; asking .GetRoots() on them answers "which great houses does this party ultimately serve?" — potentially more than one, which is precisely the data structure treason is made of.

And every any-depth question becomes navigation instead of SQL:

var starkNode = tree.First(n => n.Value == stark);

starkNode.GetOffspring()          // the whole northern alliance, any depth
starkNode.GetAncestors()          // the chain of fealty above House Stark
tree.GetBottom()                  // parties nobody is sworn to — the leaf vassals
tree.ReverseTree()                // invert the realm: leaves become roots —
                                  // "who ultimately serves whom", bottom-up

Drawing the realm: from TreeList to picture

A tree you can only traverse in code is half the fun. The same data that fed the TreeList renders as a diagram with surprisingly little work, because the model already is a graph: organizations become boxes, MEMBER_OF relationships become containment, and every other typed relationship becomes a labeled edge — dashed, with a †year, when it has an EndDate.

Three mapping rules do all the work, and each one is a data rule, not a drawing rule: organizations become boxes and MEMBER_OF becomes containment (members drawn inside their organization, no edge needed); every other relationship becomes a typed edge, one color per RelationshipType; and anything with an EndDate draws dashed, marked †year. Hand those rules to Graphviz — a ~40-line emitter walks the parties and relationships and prints DOT — and it lays out a correct, if bureaucratically flat, map of the realm:

The realm, as Graphviz first sees it

That's the honest automatic output, and for many domains it's already enough. The lead figure below is the same dataset after one more pass — panel positions assigned by hand from the TreeList's levels (level 0 at the top, vassals below their lieges, the exiles across a "Narrow Sea" divider), members rendered as rows inside their house panel, and the crossing edges routed deliberately. How both passes work — the emitter, the small SVG renderer behind the polished figures, and the exact commands to regenerate every image in this post — is out of scope for the data model, so it lives in an unlisted companion: How to: drawing the Westeros alliance maps. The content of both pictures is identical; only the layout opinion differs — which is exactly the division of labor you want: the data model decides what is true, the renderer decides where it sits.

The alliances of Westeros, c. 298–300 AC

Reading it is the payoff of the whole model: solid edges are the realm as it stands, dashed edges are the history that shaped it — Bolton's oath to Winterfell ends in 299 exactly where its new oath to the crown begins, Jorah's broken exile edge explains the long loyal one that crosses the sea, and the two dashed marriages into Highgarden tell you House Tyrell's betrothal strategy survived both grooms. EndDate, not DELETE — the diagram is the argument for it.

Pivotal moments: a SearchObject as a time machine

Everything so far drew the whole era at once. The practical payoff of dating every relationship is subtler: the realm at any moment becomes a query. No history tables, no event sourcing — one filter on the data you already store.

In Regira Entities terms, that filter lives where all filters live: on a typed search object, translated by a query builder. Give the relationship its own vocabulary:

public record PartyRelationshipSearchObject : SearchObject
{
    public ICollection<int>? PartyId { get; set; }             // either side of the edge
    public ICollection<int>? RelationshipTypeId { get; set; }
    public DateTime? ActiveAt { get; set; }                    // ← the time machine
}

services.For<PartyRelationship, int, PartyRelationshipSearchObject>(e =>
{
    e.Includes((q, _) => q.Include(x => x.Parent).Include(x => x.Child)
                          .Include(x => x.RelationshipType));
    e.Filter((query, so) =>
    {
        if (so?.PartyId?.Any() == true)
            query = query.Where(x => so.PartyId.Contains(x.ParentId)
                                  || so.PartyId.Contains(x.ChildId));
        if (so?.RelationshipTypeId?.Any() == true)
            query = query.Where(x => so.RelationshipTypeId.Contains(x.RelationshipTypeId));
        if (so?.ActiveAt is { } at)
            query = query.Where(x => (x.StartDate == null || x.StartDate <= at)
                                  && (x.EndDate == null || x.EndDate > at));
        return query;
    });
});

[ApiController, Route("party-relationships")]
public class PartyRelationshipController : EntityControllerBase<PartyRelationship, int,
    PartyRelationshipSearchObject, PartyRelationshipDto, PartyRelationshipInputDto>;

Two conventions carry the correctness. The interval is half-openStartDate <= at < EndDate — so the day a relation ends it is already gone, and back-to-back relations (Bolton leaves Stark, Bolton joins the crown) never overlap at the boundary. And null means open-ended on that side, so ancient oaths and current ones need no sentinel dates. (The framework ships this exact predicate as a ready-made FilterIsActiveOn for IHasStartEndDate entities — it's written out here so the mechanics are visible.) (One registration note: in the blueprint these rows are owned children synced by the party's save — giving them their own endpoint is fine as long as the party's input DTO leaves the collections null, keeping one writer per save path.)

Because ActiveAt is just another SearchObject field, it composes with everything else for free: ?activeAt=0299-06-15&relationshipTypeId=1 is "who was sworn to whom mid-war", ?activeAt=0299-06-15&partyId=2 is "House Stark's world at that moment", and paging, counts and DTO mapping behave as on any other endpoint.

One query gives you two complementary views, and it's worth being deliberate about which is which. The TreeList is the structured view of a point in time — feed the active relations into ToTreeList and print it depth-first, and you have the realm as a machine-readable hierarchy: diffable, testable, assertable in a unit test. The diagram is the scannable view — and the era map at the top of this post is a third thing again: a period, with its dashed, dagger-marked history. Point in time as a tree, point in time as a picture, period as a picture.

var realm = houses.ToTreeList(h => LiegesOf(h, at));   // only relations active at `at`
foreach (var node in realm.OrderByHierarchy())
    Console.WriteLine($"{new string(' ', node.Level * 3)}{node.Value.Title}");

Run that for two pivotal dates and history draws itself — same data, one query parameter apart. (The ledgers below come from the full seed — the realm-wide dataset of ~45 dated relations, with the Iron Throne itself as an institution-party, that ships as the JS mirror alongside the companion post; its generator computes exactly these texts — whitespace alignment aside — using a chattier variant of the loop above that appends each edge's label and a †year for ended oaths. This article's inline seed is the excerpt that fits on a page.) First the realm Robert built:

The Iron Throne
├─ House Greyjoy    — sworn 289
├─ House Stark      — sworn 283
│  ├─ House Mormont — sworn
│  ├─ House Bolton  — sworn
│  └─ House Karstark — sworn
├─ House Tully      — sworn 283
│  └─ House Frey    — sworn
├─ House Arryn      — sworn 283
├─ House Baratheon  — holds the crown
├─ House Lannister  — sworn 283
├─ House Tyrell     — sworn 283
└─ House Martell    — sworn 283
Night's Watch       — sworn to no crown
House Targaryen     — in exile
Drogo's Khalasar    — across the sea

The King's Peace, 297 AC

GET /party-relationships?activeAt=0297-06-15 — the realm Robert built: every great house sworn to the Iron Throne, one royal marriage binding Baratheon to Lannister, Theon a ward at Winterfell, nobody claiming anything. Boring, as peace tends to look in graph form.

Three years later the same loop prints a different realm — the TreeList makes the damage structural: houses whose oaths ended are no longer children of anything, so they surface as roots, each annotated with the edge that used to hold it in place:

The Iron Throne
├─ House Arryn      — sworn 283
├─ House Baratheon  — holds the crown
├─ House Lannister  — sworn 283
├─ House Bolton     — sworn 299
├─ House Tyrell     — sworn 283
└─ House Martell    — sworn 283
Night's Watch       — sworn to no crown
House Greyjoy       — oath to the Iron Throne ended †299
House Stark         — oath to the Iron Throne ended †299
└─ House Mormont    — sworn
House Tully         — oath to the Iron Throne ended †299
House Frey          — oath to House Tully ended †299
House Karstark      — oath to House Stark ended †299
House Targaryen     — in exile
Drogo's Khalasar    — across the sea

After the Red Wedding, 300 AC

GET /party-relationships?activeAt=0300-06-15 — three years later, the same query. The northern and riverland oaths have simply vanished from the result set (their EndDate passed); Bolton's edge now rises straight to the throne; Tommen wears the crown his brother's edge no longer reaches; Stannis and Daenerys both claim it; and the Lannister–Tyrell–Frey triangle that ended the war stands out in orange. The greyed, struck-through names are the other half of the trick — Party implements IHasStartEndDate too, so "dead by this date" is the same one-line predicate applied to people instead of edges.

That before/after pair is the practical argument for dating relations instead of deleting them: an audit view, a "state as of contract date" report, or a customer's supplier network at the moment of an incident are all this exact query with duller nouns.

Three centuries, zero migrations

How far back does the time machine go? As far as you have rows. Westeros helpfully ships a prequel, so let's load the Dance of the Dragons — the Targaryen succession war, 170 years before Robert — into the same tables. No new entity, no schema change: the throne as an institution and this era's political vocabulary all arrive as ordinary rows:

// the crown becomes a party of its own, and the era brings its own edge vocabulary
var throne = new Organization { Name = "The Iron Throne", LegalEntity = "Institution" };
var holds      = new RelationshipType { Code = "HOLDS",       Title = "Holds" };
var king       = new RelationshipType { Code = "KING",        Title = "King" };
var heir       = new RelationshipType { Code = "HEIR",        Title = "Named heir" };
var claims     = new RelationshipType { Code = "CLAIMS",      Title = "Claims" };
var marriedTo  = new RelationshipType { Code = "MARRIED_TO",  Title = "Married to" };
var alliedWith = new RelationshipType { Code = "ALLIED_WITH", Title = "Allied with" };

// houses that matter in this era (Stark and Baratheon already exist — different members)
var targaryen = new Organization { Name = "House Targaryen", LegalEntity = "Great House" };
var hightower = new Organization { Name = "House Hightower" };
var velaryon  = new Organization { Name = "House Velaryon" };

var viserys1 = new Person { GivenName = "Viserys",  FamilyName = "Targaryen", EndDate = Y(129) };
var rhaenyra = new Person { GivenName = "Rhaenyra", FamilyName = "Targaryen", EndDate = Y(130) };
var aegon2   = new Person { GivenName = "Aegon",    FamilyName = "Targaryen", EndDate = Y(131) };
var daemon   = new Person { GivenName = "Daemon",   FamilyName = "Targaryen", EndDate = Y(130) };
var alicent  = new Person { GivenName = "Alicent",  FamilyName = "Hightower" };
var corlys   = new Person { GivenName = "Corlys",   FamilyName = "Velaryon" };

List<PartyRelationship> history =
[
    // the crown itself is a dated relation: Targaryen holds it from the Conquest to Robert's rebellion
    new() { Parent = throne, Child = targaryen, RelationshipType = holds,   StartDate = Y(1),   EndDate = Y(283) },
    new() { Parent = throne, Child = viserys1,  RelationshipType = king,    StartDate = Y(103), EndDate = Y(129) },
    new() { Parent = throne, Child = aegon2,    RelationshipType = king,    StartDate = Y(129), EndDate = Y(131) },
    // succession is two dated rows, not an edit: heir until the crown is taken, claimant after
    new() { Parent = throne, Child = rhaenyra,  RelationshipType = heir,    StartDate = Y(105), EndDate = Y(129) },
    new() { Parent = throne, Child = rhaenyra,  RelationshipType = claims,  StartDate = Y(129), EndDate = Y(130) },
    // the court's marriages…
    new() { Parent = viserys1, Child = alicent, RelationshipType = marriedTo, StartDate = Y(106), EndDate = Y(129) },
    new() { Parent = rhaenyra, Child = daemon,  RelationshipType = marriedTo, StartDate = Y(120), EndDate = Y(130) },
    // …and the war: Blacks vs Greens, all starting the year the king dies
    new() { Parent = rhaenyra, Child = velaryon,  RelationshipType = alliedWith, StartDate = Y(129) },
    new() { Parent = rhaenyra, Child = stark,     RelationshipType = alliedWith, StartDate = Y(129) }, // the Pact of Ice and Fire
    new() { Parent = aegon2,   Child = hightower, RelationshipType = alliedWith, StartDate = Y(129) },
    new() { Parent = aegon2,   Child = baratheon, RelationshipType = alliedWith, StartDate = Y(129), EndDate = Y(130) },
];

Two modeling notes hiding in that seed. Succession is two rows, not an update: Rhaenyra is named heir from 105 until the crown is taken from her in 129, then claimant until 130 — both facts remain queryable forever, which is exactly what an UPDATE would have destroyed. And even the crown is a dated edge: Targaryen holds the throne (1–283) sits in the same table as Baratheon holds the throne (283–), so "who ruled when" needs no special casing.

The same endpoint from the previous section now answers questions about a war fought seventeen decades before our lead diagram:

The TreeList view first — five houses, one crown, one tidy tree:

The Iron Throne
├─ House Targaryen  — holds the crown 1–
├─ House Stark      — sworn since the Conquest
├─ House Baratheon  — sworn since the Conquest
├─ House Hightower  — sworn since the Conquest
└─ House Velaryon   — sworn since the Conquest

The peace of Viserys I, 128 AC

GET /party-relationships?activeAt=0128-06-15 — the old king's court in balance: Rhaenyra his named heir (one legitimate aqua edge), his marriage binding the crown to Oldtown, hers binding her to Daemon, and every house sworn to a throne nobody is fighting over. Note the Velaryon panel already carrying its dead — Laenor and Laena, †120 — history accumulating in the rows.

A year later the interesting tree isn't fealty at all. Build the TreeList over the war relations — claims and alliances active in 129 — and the realm's actual structure appears: two roots, because two people call themselves the rightful monarch:

Aegon II — king 129 (the Greens)
├─ House Hightower — allied 129
└─ House Baratheon — allied 129
Rhaenyra — claimant 129 (the Blacks)
├─ House Velaryon — allied 129
└─ House Stark    — the Pact of Ice and Fire 129

That's the same ToTreeList call with a different relationship filter — which relations you treat as tree edges is itself a query decision, and a civil war is precisely the moment a realm stops being one tree.

The Dance of the Dragons, 129 AC

GET /party-relationships?activeAt=0129-06-15 — one year and one dead king later: Aegon II wears a crown that arrived as a new row, Rhaenyra's heir edge has ended and her claimant edge begun, and the war draws itself in orange — Velaryon and Stark with the Blacks, Hightower and Baratheon with the Greens. Same houses as the lead diagram, different century, different names inside the panels: House Stark contains Cregan here and Eddard 170 years later, because parties are durable and membership is just another dated relation.

That last observation is the enterprise punchline. Companies restructure, people change roles, brands get acquired and sold — and a party model with dated edges keeps every era queryable with one parameter. The prequel is just a backfill.

The API angle

In the blueprint, this materialization lives on the party's repository and is exposed as a family endpoint — the flattened, depth-first tree of everything above and below a set of parties. Both halves are code you copy from the blueprint rather than a shipped API: GetFamily is the repository method from its Recursive entities section (recursive TVFs for big graphs, the in-memory ToTreeList walk for small ones), and the closing helper is a one-liner over new ListResult { Items = tree.ToTreeView() }:

[HttpGet("family")]
public async Task<IActionResult> GetFamily([FromQuery] IList<int> ids, [FromQuery] int level = 9)
    => Ok((await service.GetFamily(ids, level)).ToTreeViewListResult());

GET /parties/family?ids=2 and a SPA gets House Stark's full alliance web in parent-before-children order, ready to rebuild client-side (the front-end TreeList article picks it up from there). And when the alliance graph outgrows memory — a realm's worth of parties rather than a demo's — the in-memory walk hands over to recursive SQL, the pattern from the hierarchy deep dive, without the model changing at all: the same edge table feeds both.

Why this generalizes

Swap the names back and nothing structural changes: houses become companies, SWORN_TO becomes subsidiary of or distributor for, MEMBER_OF becomes employee of, and the exiled knight is a sales rep who changed employers — EndDate, not DELETE. One party table with typed, dated, multi-parent edges plus a tree view over them covers org charts, holding structures, distribution networks and referral webs — and consolidating your Customer/Supplier/Employee triplet into it usually simplifies the schema you already have.

And if you'd rather read the non-fiction version: the Regira-PIM sample (live demo) runs this exact pairing in production shape — a Stakeholders domain with Party/Person/Organization and PartyRelationship hierarchies (manufacturer → distributor → retailer), a hierarchical taxonomy, and Regira.TreeList behind its ancestor/offspring/family queries.

The realm is bloody; the model is clean.

The full Stakeholders blueprint — contact data, addresses, polymorphic DTOs, registration and the party-to-user link — ships with the Regira Entities documentation, and Regira.TreeList is an Apache-2.0 NuGet package.