The Party Relationship Pattern
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Parties don't exist in isolation — they have relationships with each other:
- A person works for an organization (employment)
- An organization owns another organization (subsidiary)
- A person is married to another person
- An organization supplies to another organization
These relationships:
- Have their own attributes (job title in employment, ownership percentage)
- Are time-bounded (employed from 2020 to 2023)
- Can be navigated both directions ("who works for X?" and "where does Y work?")
Concept
Party Relationship models connections between two parties. Each relationship has a type that defines the nature of the connection and the roles each party plays (e.g., "employer" and "employee" in an Employment relationship).
The relationship itself is an entity that can have attributes, lifecycle, and behaviors.
Structure
Relationship Types
| Type | From Party | To Party | Example |
|---|---|---|---|
| Employment | Person | Organization | "Jan works for Acme Corp" |
| Ownership | Party | Organization | "Holding owns 60% of Subsidiary" |
| Marriage | Person | Person | "Anna is married to Piotr" |
| CustomerRelationship | Organization | Organization | "Acme is customer of Supplier Co" |
| ParentChild | Person | Person | "Maria is parent of Kasia" |
| Partnership | Organization | Organization | "Firm A partners with Firm B" |
Attributes
PartyRelationship (Abstract)
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| fromParty | Party | Yes | First party in relationship |
| toParty | Party | Yes | Second party in relationship |
| validFrom | Date | Yes | When relationship started |
| validTo | Date | No | When relationship ended |
| notes | String | No | Additional information |
Employment
| Attribute | Type | Required | Description |
|---|---|---|---|
| jobTitle | String | Yes | Position/job title |
| department | String | No | Department name |
| employmentType | EmploymentType | Yes | FullTime, PartTime, Contract |
| workLocation | String | No | Office location |
| reportsTo | Employment | No | Manager's employment record |
Ownership
| Attribute | Type | Required | Description |
|---|---|---|---|
| ownershipPercentage | Decimal | Yes | Percentage owned (0-100) |
| votingRights | Boolean | Yes | Has voting rights? |
| shareClass | String | No | Class of shares (A, B, etc.) |
CustomerRelationship
| Attribute | Type | Required | Description |
|---|---|---|---|
| accountManager | Employee | No | Assigned account manager |
| contractValue | Money | No | Annual contract value |
| tier | Enum | No | Strategic, Key, Standard |
| nda | Boolean | No | NDA signed? |
Behaviors
Entity PartyRelationship (abstract):
function isActive(): Boolean
today = currentDate()
return validFrom <= today and (validTo is None or validTo >= today)
function terminate(endDate: Date, reason: String):
require endDate >= validFrom
require validTo is None // Not already terminated
validTo = endDate
terminationReason = reason
emit RelationshipTerminated(this, endDate, reason)
function overlaps(other: PartyRelationship): Boolean
// Check if two relationships overlap in time
if validTo is None and other.validTo is None:
return true // Both ongoing
if validTo is None:
return other.validTo >= validFrom
if other.validTo is None:
return validTo >= other.validFrom
return not (validTo < other.validFrom or other.validTo < validFrom)
function getOtherParty(party: Party): Party
if fromParty == party:
return toParty
if toParty == party:
return fromParty
raise Error("Party not in relationship")
Entity Employment extends PartyRelationship:
// fromParty = Employee (Person)
// toParty = Employer (Organization)
function getEmployee(): Person
return fromParty as Person
function getEmployer(): Organization
return toParty as Organization
function promote(newTitle: String, newDepartment: String):
require isActive()
previousTitle = jobTitle
jobTitle = newTitle
department = newDepartment
emit EmployeePromoted(this, previousTitle, newTitle)
function transfer(newDepartment: String, newLocation: String):
require isActive()
department = newDepartment
workLocation = newLocation
emit EmployeeTransferred(this, newDepartment, newLocation)
Entity Ownership extends PartyRelationship:
// fromParty = Owner
// toParty = Owned Organization
function isMajorityOwner(): Boolean
return ownershipPercentage > 50.0
function adjustOwnership(newPercentage: Decimal):
require newPercentage >= 0 and newPercentage <= 100
require isActive()
previousPercentage = ownershipPercentage
ownershipPercentage = newPercentage
emit OwnershipChanged(this, previousPercentage, newPercentage)
// Query helpers on Party
Entity Party:
function getRelationshipsFrom(): PartyRelationship[]
return findAll PartyRelationship where fromParty == this
function getRelationshipsTo(): PartyRelationship[]
return findAll PartyRelationship where toParty == this
function getAllRelationships(): PartyRelationship[]
return getRelationshipsFrom() + getRelationshipsTo()
function getActiveEmployments(): Employment[]
return getRelationshipsFrom()
.filter(r => r is Employment and r.isActive())
function getEmployees(): Person[] // For Organization
return findAll Employment
where toParty == this and isActive()
.map(e => e.fromParty)Examples
Employment: Jan works for Acme
jan = findParty("Jan Kowalski") // Person
acme = findParty("Acme Sp. z o.o.") // Organization
employment = new Employment(
fromParty: jan,
toParty: acme,
validFrom: 2022-06-01,
jobTitle: "Senior Developer",
department: "Engineering",
employmentType: EmploymentType.FullTime
)
employment.getEmployee() // jan
employment.getEmployer() // acme
employment.isActive() // true
// Jan gets promoted
employment.promote("Tech Lead", "Engineering")
// emits EmployeePromoted(employment, "Senior Developer", "Tech Lead")Ownership: Holding owns Subsidiary
holding = findParty("Holding S.A.")
subsidiary = findParty("Acme Sp. z o.o.")
ownership = new Ownership(
fromParty: holding,
toParty: subsidiary,
validFrom: 2018-01-01,
ownershipPercentage: 75.0,
votingRights: true,
shareClass: "A"
)
ownership.isMajorityOwner() // true (75% > 50%)
// Ownership diluted after new investment round
ownership.adjustOwnership(51.0)
// emits OwnershipChanged(ownership, 75.0, 51.0)Invariants
- Different parties — fromParty and toParty must be different (no self-relationships)
- Valid date range — validFrom must be before or equal to validTo
- Party type constraints — Employment requires Person → Organization
- Ownership total — Total ownership of an organization cannot exceed 100%
- No duplicate active relationships — Same type between same parties shouldn't overlap
Error Handling
| Operation | Precondition Violated | Error |
|---|---|---|
terminate(endDate, reason) |
endDate < validFrom |
PreconditionError: end date before start date |
terminate(endDate, reason) |
Already terminated | PreconditionError: relationship already terminated |
getOtherParty(party) |
Party not in relationship | Error: Party not in relationship |
adjustOwnership(pct) |
pct < 0 or pct > 100 |
PreconditionError: percentage must be 0-100 |
adjustOwnership(pct) |
Relationship not active | PreconditionError: cannot adjust terminated ownership |
promote(title, dept) |
Employment not active | PreconditionError: cannot promote in terminated employment |
| Create with same parties | fromParty == toParty |
InvariantError: self-relationships not allowed |
Directionality
Relationships can be:
| Directionality | Example | Navigation |
|---|---|---|
| Directed | Employment | Person → Organization (asymmetric) |
| Undirected | Marriage | Person ↔ Person (symmetric) |
| Hierarchical | ParentChild | Parent → Child (asymmetric) |
For undirected relationships, querying from either party should return the relationship.
SQL Schema
sql-- Base relationship table
CREATE TABLE party_relationship (
id UUID PRIMARY KEY,
relationship_type VARCHAR(30) NOT NULL,
from_party_id UUID NOT NULL REFERENCES party(id),
to_party_id UUID NOT NULL REFERENCES party(id),
valid_from DATE NOT NULL,
valid_to DATE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_different_parties CHECK (from_party_id != to_party_id),
CONSTRAINT chk_valid_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
);
-- Employment-specific
CREATE TABLE employment (
id UUID PRIMARY KEY REFERENCES party_relationship(id) ON DELETE CASCADE,
job_title VARCHAR(100) NOT NULL,
department VARCHAR(100),
employment_type VARCHAR(20) NOT NULL, -- 'FullTime', 'PartTime', 'Contract'
work_location VARCHAR(100),
reports_to_id UUID REFERENCES employment(id)
);
-- Ownership-specific
CREATE TABLE ownership (
id UUID PRIMARY KEY REFERENCES party_relationship(id) ON DELETE CASCADE,
ownership_percentage DECIMAL(5,2) NOT NULL,
voting_rights BOOLEAN NOT NULL DEFAULT TRUE,
share_class VARCHAR(10),
CONSTRAINT chk_percentage CHECK (ownership_percentage >= 0 AND ownership_percentage <= 100)
);
-- Customer relationship-specific
CREATE TABLE customer_relationship (
id UUID PRIMARY KEY REFERENCES party_relationship(id) ON DELETE CASCADE,
account_manager_id UUID REFERENCES party_role(id), -- Employee role
contract_value_cents INTEGER,
contract_currency CHAR(3),
tier VARCHAR(20), -- 'Strategic', 'Key', 'Standard'
nda_signed BOOLEAN DEFAULT FALSE
);
-- Indexes for navigation
CREATE INDEX idx_relationship_from ON party_relationship(from_party_id);
CREATE INDEX idx_relationship_to ON party_relationship(to_party_id);
CREATE INDEX idx_relationship_type ON party_relationship(relationship_type);
CREATE INDEX idx_relationship_active ON party_relationship(from_party_id, to_party_id)
WHERE valid_to IS NULL;Common Queries
sql-- Find all employees of an organization
SELECT p.*, e.*
FROM party p
JOIN party_relationship pr ON pr.from_party_id = p.id
JOIN employment e ON e.id = pr.id
WHERE pr.to_party_id = :organizationId
AND pr.relationship_type = 'Employment'
AND pr.valid_to IS NULL;
-- Find organization hierarchy (ownership tree)
WITH RECURSIVE ownership_tree AS (
-- Base: direct ownerships
SELECT o.id, pr.from_party_id as owner_id, pr.to_party_id as owned_id,
o.ownership_percentage, 1 as level
FROM ownership o
JOIN party_relationship pr ON pr.id = o.id
WHERE pr.from_party_id = :rootOwnerId
AND pr.valid_to IS NULL
UNION ALL
-- Recursive: owned companies' ownerships
SELECT o.id, pr.from_party_id, pr.to_party_id,
o.ownership_percentage, ot.level + 1
FROM ownership o
JOIN party_relationship pr ON pr.id = o.id
JOIN ownership_tree ot ON pr.from_party_id = ot.owned_id
WHERE pr.valid_to IS NULL
AND ot.level < 10 -- Prevent infinite recursion
)
SELECT * FROM ownership_tree;
-- Find relationship history between two parties
SELECT pr.*, pr.relationship_type, pr.valid_from, pr.valid_to
FROM party_relationship pr
WHERE (pr.from_party_id = :party1Id AND pr.to_party_id = :party2Id)
OR (pr.from_party_id = :party2Id AND pr.to_party_id = :party1Id)
ORDER BY pr.valid_from DESC;Design Considerations
Relationship vs Role
| Concept | Use Case | Example |
|---|---|---|
| Role | Party's capability/function in system | Customer can place orders |
| Relationship | Connection between two parties | Company employs Person |
Sometimes both are needed: "Person has Employee role" AND "Employment relationship between Person and Organization"
Bidirectional Navigation
Consider whether you need to navigate both directions efficiently:
// From Person: "Where do I work?"
person.getEmployments()
// From Organization: "Who works here?"
organization.getEmployees()Index both from_party_id and to_party_id if bidirectional queries are common.
Historical Relationships
Keeping terminated relationships (with validTo set) enables:
- Employment history
- Audit trails
- "Who owned this company in 2019?"
Related Patterns
- Party — The entities being related
- Party Role — Alternative for party capabilities (Customer, Supplier)
- Accountability — More complex relationship patterns with rules