Party Relationship Pattern (Wzorzec relacji między podmiotami)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Party nie istnieją w izolacji — łączą je relacje:
- Person pracuje dla Organization (Employment)
- Organization posiada inną Organization (spółka zależna)
- Person jest w związku małżeńskim z inną Person
- Organization dostarcza do innej Organization
Te relacje:
- Mają własne atrybuty (stanowisko w Employment, procent Ownership)
- Są ograniczone czasowo (zatrudniony od 2020 do 2023)
- Można je nawigować w obu kierunkach („kto pracuje dla X?" i „gdzie pracuje Y?")
Koncepcja
Party Relationship modeluje połączenia między dwoma Party. Każda relacja ma typ, który definiuje naturę połączenia i role, jakie pełni każdy Party (np. „pracodawca" i „Employee" w relacji Employment).
Sama relacja jest encją, która może mieć atrybuty, cykl życia i zachowania.
Struktura
Typy relacji
| Typ | Party źródłowy | Party docelowy | Przykład |
|---|---|---|---|
| Employment | Person | Organization | „Jan pracuje dla Acme Corp" |
| Ownership | Party | Organization | „Holding posiada 60% Spółki Zależnej" |
| Marriage | Person | Person | „Anna jest w związku małżeńskim z Piotrem" |
| CustomerRelationship | Organization | Organization | „Acme jest Customer Supplier Co" |
| ParentChild | Person | Person | „Maria jest rodzicem Kasi" |
| Partnership | Organization | Organization | „Firma A jest partnerem Firmy B" |
Atrybuty
PartyRelationship (klasa abstrakcyjna)
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| fromParty | Party | Tak | Pierwszy Party w relacji |
| toParty | Party | Tak | Drugi Party w relacji |
| validFrom | Date | Tak | Kiedy relacja się rozpoczęła |
| validTo | Date | Nie | Kiedy relacja się zakończyła |
| notes | String | Nie | Dodatkowe informacje |
Employment
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| jobTitle | String | Tak | Stanowisko |
| department | String | Nie | Nazwa działu |
| employmentType | EmploymentType | Tak | FullTime, PartTime, Contract |
| workLocation | String | Nie | Location biura |
| reportsTo | Employment | Nie | Rekord zatrudnienia przełożonego |
Ownership
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| ownershipPercentage | Decimal | Tak | Procent Ownership (0-100) |
| votingRights | Boolean | Tak | Czy posiada prawo głosu? |
| shareClass | String | Nie | Klasa akcji (A, B, itd.) |
CustomerRelationship
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| accountManager | Employee | Nie | Przypisany opiekun klienta |
| contractValue | Money | Nie | Roczna wartość kontraktu |
| tier | Enum | Nie | Strategic, Key, Standard |
| nda | Boolean | Nie | Czy podpisano NDA? |
Zachowania
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)Przykłady
Employment: Jan pracuje dla 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 posiada Spółkę Zależną
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)Niezmienniki
- Różne Party — fromParty i toParty muszą być różne (brak relacji z samym sobą)
- Prawidłowy zakres dat — validFrom musi być wcześniejsze lub równe validTo
- Ograniczenia typu Party — Employment wymaga relacji Person → Organization
- Suma Ownership — Łączna Ownership Organization nie może przekraczać 100%
- Brak duplikatów aktywnych relacji — Ten sam typ między tymi samymi Party nie powinien się nakładać
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
terminate(endDate, reason) |
endDate < validFrom |
PreconditionError: end date before start date |
terminate(endDate, reason) |
Już zakończona | PreconditionError: relationship already terminated |
getOtherParty(party) |
Party nie jest w relacji | Error: Party not in relationship |
adjustOwnership(pct) |
pct < 0 or pct > 100 |
PreconditionError: percentage must be 0-100 |
adjustOwnership(pct) |
Relacja nieaktywna | PreconditionError: cannot adjust terminated ownership |
promote(title, dept) |
Employment nieaktywne | PreconditionError: cannot promote in terminated employment |
| Utworzenie z tymi samymi Party | fromParty == toParty |
InvariantError: self-relationships not allowed |
Kierunkowość
Relacje mogą być:
| Kierunkowość | Przykład | Nawigacja |
|---|---|---|
| Skierowana | Employment | Person → Organization (asymetryczna) |
| Nieskierowana | Marriage | Person ↔ Person (symetryczna) |
| Hierarchiczna | ParentChild | Rodzic → Dziecko (asymetryczna) |
W przypadku relacji nieskierowanych zapytanie od dowolnego Party powinno zwrócić relację.
Schemat SQL
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;Typowe zapytania
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;Zagadnienia projektowe
Relacja a rola
| Koncepcja | Zastosowanie | Przykład |
|---|---|---|
| Rola | Zdolność/funkcja Party w systemie | Customer może składać Order |
| Relacja | Połączenie między dwoma Party | Firma zatrudnia Person |
Czasami potrzebne jest jedno i drugie: „Person ma rolę Employee" ORAZ „Party Relationship Employment między Person a Organization"
Nawigacja dwukierunkowa
Rozważ, czy potrzebujesz efektywnej nawigacji w obu kierunkach:
// From Person: "Where do I work?"
person.getEmployments()
// From Organization: "Who works here?"
organization.getEmployees()Zaindeksuj zarówno from_party_id, jak i to_party_id, jeśli zapytania dwukierunkowe są częste.
Relacje historyczne
Przechowywanie zakończonych relacji (z ustawionym validTo) umożliwia:
- Historię Employment
- Ścieżki audytu
- „Kto był właścicielem tej firmy w 2019 roku?"
Related Patterns
- Party — Encje będące w relacji
- Party Role — Alternatywa dla zdolności Party (Customer, Supplier)
- Accountability — Bardziej złożone wzorce relacji z regułami