Software Archetypes

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

fromParty

toParty

1

1

0..*

0..*

Party

+id: UUID

+name: String

«abstract»

PartyRelationship

+id: UUID

+fromParty: Party

+toParty: Party

+validFrom: Date

+validTo: Date

+isActive() : : Boolean

Employment

+jobTitle: String

+department: String

+employmentType: EmploymentType

Ownership

+ownershipPercentage: Decimal

+votingRights: Boolean

Marriage

+marriageDate: Date

+divorceDate: Date

CustomerRelationship

+accountManager: Employee

+contractValue: Money

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

  1. Different parties — fromParty and toParty must be different (no self-relationships)
  2. Valid date range — validFrom must be before or equal to validTo
  3. Party type constraints — Employment requires Person → Organization
  4. Ownership total — Total ownership of an organization cannot exceed 100%
  5. 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?"
  • Party — The entities being related
  • Party Role — Alternative for party capabilities (Customer, Supplier)
  • Accountability — More complex relationship patterns with rules