← All articles

Westeros, the Iron Throne, and a Model for Complex Relationships

One table for every customer, supplier and employee, with roles and alliances as dated relationships instead of extra tables. Tested on Westeros, where everyone knows who betrayed whom and when.
Westeros, the Iron Throne, and a Model for Complex Relationships

Most business systems meet the same people and companies in more than one role: a supplier that is also a customer, an employee who buys from the shop, a contact who moves to another company. The classic design gives every role its own table — Customer, Supplier, Employee — and then the same company appears in two of them with two addresses that drift apart, and every new role means a new table, a new screen and another copy of the address columns.

The fix is old and good: model the party, not the role. One record for every person or organization the system deals with, with the few fields that differ kept per kind (people have family names; organizations have legal forms), and roles expressed as typed relationships between parties — "works for", "supplies", "is a customer of" — rather than as tables.

Universal or Regira? The party model is the pattern, and it predates every library. The Stakeholders blueprint and TreeList are the Regira pieces the sample builds it with; the maps are plain Graphviz.

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, lightly trimmed for the page, with the seed data swapped and one index widened; at the end we'll fold in Regira.TreeList to turn the web of oaths into an actual tree. What the dates on every oath buy you — the realm at any moment as a single query — is part 2.

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, IHasStartEndDate
{
    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(2048)] public string? Description { get; set; }

    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public DateTime Created { get; set; }
    public DateTime? LastModified { 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; }
    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", …
    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: a person stores GivenName and FamilyName, a house stores Name, and the display name is derived from whichever columns the subtype actually has rather than duplicated into one.
  • Contact data and addresses hang off the base, so a raven address book works identically for people and houses.

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, r.StartDate }).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 EndDate is for). The unique index makes a duplicate oath a constraint violation instead of silent data drift. It includes StartDate, which the blueprint's does not: House Stark rebelled in 282 and swore to the throne again in 283, and a renewed oath is a second row with its own dates, not an edit that erases the first.

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(293) },                   // 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 (the build, its navigation API and what happens on a cycle are in From a Flat List to a Tree in One Line of C#):

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

(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.

Every any-depth question — the northern alliance below Winterfell, the chain of fealty above it, the leaf vassals nobody is sworn to — is then navigation instead of SQL, with the navigation methods From a Flat List to a Tree in One Line of C# lists. Returning that tree over HTTP — flattened parents-first with ToTreeView(), rebuilt in the browser — is hierarchies at any depth in EF Core and the same tree in a Vue SPA, and when the alliance graph outgrows memory the same edge table feeds that recursive SQL without the model changing.

Drawing the realm

The same data renders as a map with surprisingly little work, because the model already is a graph: organizations become boxes, MEMBER_OF becomes containment, and every other typed relationship becomes a labeled edge — dashed, with a †year, when it has an EndDate. The emitter behind those rules, the small SVG renderer behind the polished figure below, and the commands to regenerate every image are in the companion, Drawing the Westeros Alliance Maps.

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. EndDate, not DELETE — the diagram is the argument for it.

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. 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.

The realm is bloody; the model is clean.

Runnable: the model above — the TPH table, the typed dated edges, the two Restrict FKs and the unique index — is a console project that seeds the realm and folds it into a tree with one dotnet run.

Docs: the full Stakeholders blueprint ships with the Regira Entities documentation; Regira.TreeList is an Apache-2.0 NuGet package.

Built with

Regira.Entities

Dated relationships, roles and the queries over them sit on Regira Entities and EF Core.

What Regira Entities covers Entities documentation Free tier & licensing