Archetypy Programowania

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

plays

1

0..*

Party

+id: UUID

+name: String

+roles: PartyRole[]

«abstract»

PartyRole

+id: UUID

+party: Party

+validFrom: Date

+validTo: Date

+isActive() : : Boolean

Customer

+customerNumber: String

+loyaltyTier: LoyaltyTier

+creditLimit: Money

+getOrders() : : Order[]

Supplier

+supplierCode: String

+paymentTermsDays: Integer

+rating: Rating

Employee

+employeeId: String

+department: String

+position: String

+salary: Money

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

Zakoń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 days

Maszyna stanów

create(validFrom)

update attributes

terminate(validTo)

Active

Terminated

validTo is NULL
isActive() returns true

validTo is set
isActive() returns false

Niezmienniki

  1. Party wymagany — Każda rola musi należeć do dokładnie jednego Party
  2. Poprawny zakres dat — validFrom musi być wcześniejsze lub równe validTo
  3. Brak nakładających się ról tego samego typu — Party nie może mieć dwóch aktywnych ról Customer jednocześnie
  4. Unikalne identyfikatory — customerNumber, supplierCode, employeeId muszą być unikalne w obrębie swojego typu
  5. 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
        └── Supplier
  • Party — Encja pełniąca rolę
  • Party Relationship — Relacje między Party (Customer-od, zatrudniony-przez)
  • Order — Customer składa Order