Party Role Pattern (Wzorzec roli podmiotu)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Party (Person lub Organization) może działać w różnych zdolnościach:
- Ta sama Person może być Customer i Employee
- Organization może być jednocześnie Supplier i Customer
- Role mają różne atrybuty: Customer ma poziom lojalnościowy, Employee ma datę Employment
- Role mogą być ograniczone czasowo: ktoś był Employee od 2020 do 2023
- Potrzebujesz zapytania „wszyscy Customer" niezależnie od tego, czy są Person czy Organization
Koncepcja
Party Role oddziela kim ktoś jest (Party) od jaką rolę pełni (Rola). Party może mieć wiele ról, a każda rola może mieć własne atrybuty, zachowania i cykl życia.
Można to porównać do „noszenia różnych kapeluszy" — ta sama Person nosi kapelusz Customer przy zakupach i kapelusz Employee w pracy.
Struktura
Atrybuty
PartyRole (abstrakcyjny)
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| party | Party | Tak | Party pełniący tę rolę |
| validFrom | Date | Tak | Kiedy rola stała się aktywna |
| validTo | Date | Nie | Kiedy rola się zakończyła (null = nadal aktywna) |
| createdAt | Timestamp | Tak | Czas utworzenia rekordu |
Customer
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| customerNumber | String | Tak | Unikalny identyfikator klienta |
| loyaltyTier | Enum | Nie | Bronze, Silver, Gold, Platinum |
| creditLimit | Money | Nie | Maksymalny dozwolony kredyt |
| preferredPaymentMethod | Enum | Nie | Card, BankTransfer, Invoice |
Supplier
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| supplierCode | String | Tak | Unikalny identyfikator dostawcy |
| paymentTermsDays | Integer | Tak | Dni do terminu płatności (30, 60) |
| rating | Enum | Nie | Ocena A, B, C, D |
| minimumOrderValue | Money | Nie | Minimalna wartość zamówienia |
Employee
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| employeeId | String | Tak | Unikalny identyfikator pracownika |
| department | String | Tak | Nazwa działu |
| position | String | Tak | Stanowisko |
| hireDate | Date | Tak | Data rozpoczęcia zatrudnienia |
| salary | Money | Nie | Aktualne wynagrodzenie |
| managerId | UUID | Nie | Referencja do przełożonego (Employee) |
Zachowania
Entity PartyRole (abstract):
function isActive(): Boolean
today = currentDate()
return validFrom <= today and (validTo is None or validTo >= today)
function isActiveOn(date: Date): Boolean
return validFrom <= date and (validTo is None or validTo >= date)
function terminate(endDate: Date):
require endDate >= validFrom
require validTo is None // Not already terminated
validTo = endDate
function getDuration(): Duration
endDate = validTo or currentDate()
return daysBetween(validFrom, endDate)
Entity Customer extends PartyRole:
function canPlaceOrder(orderTotal: Money): Boolean
if not isActive():
return false
if creditLimit is None:
return true
return getOutstandingBalance() + orderTotal <= creditLimit
function getOutstandingBalance(): Money
// Sum of unpaid invoices
return orders
.filter(o => o.isPaid == false)
.sum(o => o.total)
function upgradeTier(newTier: LoyaltyTier):
require newTier > loyaltyTier // Can only upgrade
previousTier = loyaltyTier
loyaltyTier = newTier
emit CustomerTierUpgraded(this, previousTier, newTier)
Entity Employee extends PartyRole:
function getYearsOfService(): Decimal
return yearsBetween(hireDate, currentDate())
function isManager(): Boolean
// Has direct reports
return exists Employee where managerId == this.id
function getDirectReports(): Employee[]
return findAll Employee where managerId == this.id and isActive()
function promote(newPosition: String, newSalary: Money):
require isActive()
require newSalary >= salary // No pay cut on promotion
previousPosition = position
position = newPosition
salary = newSalary
emit EmployeePromoted(this, previousPosition, newPosition)Przykłady
Jan Kowalski jako Customer i Employee
// Jan is both a customer and an employee of Acme
jan = findParty("Jan Kowalski")
customerRole = new Customer(
party: jan,
validFrom: 2020-01-15,
customerNumber: "CUST-00001234",
loyaltyTier: LoyaltyTier.Gold,
creditLimit: Money(500000, "PLN") // 5000.00 PLN
)
employeeRole = new Employee(
party: jan,
validFrom: 2022-06-01,
employeeId: "EMP-004521",
department: "Engineering",
position: "Senior Developer",
hireDate: 2022-06-01,
salary: Money(1500000, "PLN") // 15000.00 PLN
)
jan.roles // [customerRole, employeeRole]
customerRole.isActive() // true (no validTo)
customerRole.canPlaceOrder(Money(200000, "PLN")) // true (within credit limit)
employeeRole.getYearsOfService() // 3.8Zakończenie roli
// Jan leaves the company
employeeRole.terminate(2025-12-31)
employeeRole.isActive() // false (after 2025-12-31)
employeeRole.isActiveOn(2024-06-01) // true (was active then)
employeeRole.getDuration() // ~1279 daysMaszyna stanów
Niezmienniki
- Party wymagany — Każda rola musi należeć do dokładnie jednego Party
- Poprawny zakres dat — validFrom musi być wcześniejsze lub równe validTo
- Brak nakładających się ról tego samego typu — Party nie może mieć dwóch aktywnych ról Customer jednocześnie
- Unikalne identyfikatory — customerNumber, supplierCode, employeeId muszą być unikalne w obrębie swojego typu
- Typ roli jest niezmienny — Nie można zmienić Customer na Supplier (zamiast tego utwórz nową rolę)
Relacje
| Od | Do | Liczność | Opis |
|---|---|---|---|
| PartyRole | Party | wiele do 1 | Rola należy do Party |
| Customer | Order | 1 do wielu | Customer składa Order |
| Supplier | PurchaseOrder | 1 do wielu | Supplier otrzymuje zamówienia zakupowe |
| Employee | Employee | wiele do 1 | Employee podlega przełożonemu |
| Employee | Department | wiele do 1 | Employee należy do działu |
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
terminate(endDate) |
endDate < validFrom |
PreconditionError: end date cannot be before start date |
terminate(endDate) |
validTo is not None |
PreconditionError: role already terminated |
canPlaceOrder(total) |
Rola nieaktywna | Zwraca false (bez wyjątku) |
canPlaceOrder(total) |
Przekroczony limit kredytowy | Zwraca false (bez wyjątku) |
upgradeTier(newTier) |
newTier <= loyaltyTier |
PreconditionError: can only upgrade tier, not downgrade |
promote(pos, salary) |
Nieaktywna | PreconditionError: cannot promote terminated employee |
promote(pos, salary) |
newSalary < salary |
PreconditionError: salary cannot decrease on promotion |
Decyzja projektowa: Metody zapytaniowe (canPlaceOrder, isActive) zwracają wartości logiczne. Metody komend (terminate, promote) rzucają wyjątek przy nieprawidłowym stanie. Pozwala to wywołującemu sprawdzić warunek przed wywołaniem lub obsłużyć wyjątek.
Schemat SQL
sql-- Base role table with discriminator
CREATE TABLE party_role (
id UUID PRIMARY KEY,
party_id UUID NOT NULL REFERENCES party(id),
role_type VARCHAR(30) NOT NULL, -- 'Customer', 'Supplier', 'Employee'
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_valid_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
);
-- Customer-specific attributes
CREATE TABLE customer (
id UUID PRIMARY KEY REFERENCES party_role(id) ON DELETE CASCADE,
customer_number VARCHAR(30) NOT NULL UNIQUE,
loyalty_tier VARCHAR(20), -- 'Bronze', 'Silver', 'Gold', 'Platinum'
credit_limit_cents INTEGER,
credit_limit_currency CHAR(3),
preferred_payment VARCHAR(20)
);
-- Supplier-specific attributes
CREATE TABLE supplier (
id UUID PRIMARY KEY REFERENCES party_role(id) ON DELETE CASCADE,
supplier_code VARCHAR(30) NOT NULL UNIQUE,
payment_terms_days INTEGER NOT NULL DEFAULT 30,
rating CHAR(1), -- 'A', 'B', 'C', 'D'
min_order_cents INTEGER,
min_order_currency CHAR(3)
);
-- Employee-specific attributes
CREATE TABLE employee (
id UUID PRIMARY KEY REFERENCES party_role(id) ON DELETE CASCADE,
employee_id VARCHAR(30) NOT NULL UNIQUE,
department VARCHAR(100) NOT NULL,
position VARCHAR(100) NOT NULL,
hire_date DATE NOT NULL,
salary_cents INTEGER,
salary_currency CHAR(3),
manager_id UUID REFERENCES employee(id)
);
-- Indexes
CREATE INDEX idx_party_role_party ON party_role(party_id);
CREATE INDEX idx_party_role_type ON party_role(role_type);
CREATE INDEX idx_party_role_active ON party_role(party_id)
WHERE valid_to IS NULL; -- Partial index for active roles
CREATE INDEX idx_employee_manager ON employee(manager_id);Typowe zapytania
sql-- Find all active customers
SELECT p.*, c.*, pr.valid_from
FROM party p
JOIN party_role pr ON pr.party_id = p.id
JOIN customer c ON c.id = pr.id
WHERE pr.role_type = 'Customer'
AND pr.valid_from <= CURRENT_DATE
AND (pr.valid_to IS NULL OR pr.valid_to >= CURRENT_DATE);
-- Find party with all their roles
SELECT p.*, pr.role_type, pr.valid_from, pr.valid_to
FROM party p
LEFT JOIN party_role pr ON pr.party_id = p.id
WHERE p.id = :partyId;
-- Find employees reporting to a manager
SELECT e.*, p.first_name, p.last_name
FROM employee e
JOIN party_role pr ON pr.id = e.id
JOIN party p ON p.id = pr.party_id
WHERE e.manager_id = :managerId
AND pr.valid_to IS NULL;Zagadnienia projektowe
Rola jako osobna encja vs atrybuty na Party
| Podejście | Zalety | Wady |
|---|---|---|
| Osobna encja | Wiele ról, ograniczenie czasowe | Większa złożoność, więcej JOINów |
| Atrybuty na Party | Proste, szybkie zapytania | Jedna rola na Party, brak historii |
Zalecenie: Używaj osobnej encji gdy:
- Ten sam Party może mieć wiele ról
- Role potrzebują własnego cyklu życia (valid from/to)
- Atrybuty specyficzne dla ról znacząco się różnią
Role historyczne
Wzorzec wspiera przechowywanie ról historycznych (validTo ustawione na datę w przeszłości). Umożliwia to:
- „Kto był naszym Customer w 2020?"
- „Historia Employment tej Person"
- Ścieżki audytu na potrzeby zgodności regulacyjnej
Hierarchia ról
Dla złożonych systemów rozważ hierarchie ról:
PartyRole
└── BusinessPartner (abstract)
├── Customer
└── SupplierRelated Patterns
- Party — Encja pełniąca rolę
- Party Relationship — Relacje między Party (Customer-od, zatrudniony-przez)
- Order — Customer składa Order