Party Pattern (Wzorzec podmiotu)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Musisz zamodelować Person i Organization w swoim systemie, ale:
- Person i Organization mają wspólne zachowania (nazwa, dane kontaktowe, adresy)
- Mają też unikalne atrybuty (Person ma datę urodzenia, Organization ma numer rejestrowy)
- Chcesz uniknąć duplikowania kodu dla „Customer będący Person" vs „Customer będący Firmą"
- Zapytania takie jak „znajdź wszystkie Party w Warszawie" powinny działać niezależnie od typu Party
Koncepcja
Party to abstrakcja reprezentująca dowolną encję, która może wchodzić w relacje, posiadać rzeczy lub uczestniczyć w transakcjach. Party jest albo Person (jednostka), albo Organization (firma, instytucja, grupa).
Pozwala to pisać kod operujący na „Party", który działa zarówno dla Person, jak i Organization.
Struktura
Atrybuty
Party (abstrakcyjny)
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| addresses | Address[] | Nie | Kolekcja adresów |
| contacts | Contact[] | Nie | Telefon, email itp. |
| createdAt | Timestamp | Tak | Data utworzenia rekordu |
| updatedAt | Timestamp | Tak | Czas ostatniej modyfikacji |
Person
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| firstName | String | Tak | Imię |
| lastName | String | Tak | Nazwisko |
| middleName | String | Nie | Drugie imię (imiona) |
| birthDate | Date | Nie | Data urodzenia |
| gender | Enum | Nie | Male, Female, Other, Unknown |
Organization
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| legalName | String | Tak | Oficjalna zarejestrowana nazwa |
| tradingName | String | Nie | Nazwa handlowa |
| registrationNumber | String | Nie | Numer rejestrowy firmy |
| taxId | String | Nie | Numer identyfikacji podatkowej |
| foundedDate | Date | Nie | Data założenia organizacji |
Address
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| street | String | Tak | Adres ulicy z numerem |
| city | String | Tak | Nazwa miasta |
| postalCode | String | Tak | Kod pocztowy |
| country | String | Tak | Kod kraju ISO (PL, US itp.) |
| type | AddressType | Tak | Home, Work, Billing, Shipping |
| isPrimary | Boolean | Tak | Czy to adres domyślny? |
Zachowania
Entity Party (abstract):
function getName(): String
// Abstract - implemented by subclasses
function getPrimaryAddress(): Address or None
return addresses.find(a => a.isPrimary == true)
function addAddress(address: Address):
if address.isPrimary:
// Ensure only one primary per type
for existing in addresses:
if existing.type == address.type:
existing.isPrimary = false
addresses.add(address)
function getAddressesByType(type: AddressType): Address[]
return addresses.filter(a => a.type == type)
Entity Person extends Party:
function getName(): String
if middleName is not empty:
return firstName + " " + middleName + " " + lastName
return firstName + " " + lastName
function getAge(): Integer or None
if birthDate is None:
return None
return yearsBetween(birthDate, today())
function isAdult(): Boolean
age = getAge()
return age is not None and age >= 18
Entity Organization extends Party:
function getName(): String
if tradingName is not empty:
return tradingName
return legalName
function getOfficialName(): String
return legalNamePrzykłady
Person: Jan Kowalski
jan = new Person(
firstName: "Jan",
lastName: "Kowalski",
middleName: "Adam",
birthDate: 1985-03-15,
gender: Gender.Male
)
jan.addAddress(new Address(
street: "ul. Marszalkowska 10/5",
city: "Warszawa",
postalCode: "00-001",
country: "PL",
type: AddressType.Home,
isPrimary: true
))
jan.getName() // "Jan Adam Kowalski"
jan.getAge() // 41
jan.isAdult() // trueOrganization: Acme Sp. z o.o.
acme = new Organization(
legalName: "Acme Sp. z o.o.",
tradingName: "Acme",
registrationNumber: "0000123456",
taxId: "5261234567"
)
acme.addAddress(new Address(
street: "ul. Polna 22",
city: "Krakow",
postalCode: "30-001",
country: "PL",
type: AddressType.Work,
isPrimary: true
))
acme.getName() // "Acme" (tradingName)
acme.getOfficialName() // "Acme Sp. z o.o."Niezmienniki
- Party musi mieć nazwę — Person wymaga firstName + lastName; Organization wymaga legalName
- Tylko jeden adres główny na typ — Nie można mieć dwóch głównych adresów rozliczeniowych
- Wiek Person nie może być ujemny — birthDate musi być w przeszłości
- Numer rejestrowy Organization jest unikalny — Żadne dwie Organization nie mogą mieć tego samego registrationNumber (w obrębie jednej jurysdykcji)
Relacje
| Od | Do | Liczność | Opis |
|---|---|---|---|
| Party | Address | 1 do wielu | Party ma wiele adresów |
| Party | ContactMethod | 1 do wielu | Party ma wiele kontaktów |
| Party | PartyRole | 1 do wielu | Party pełni wiele ról |
| Organization | Person | wiele do wielu | Poprzez relację Employment |
Obsługa błędów
| Operacja | Warunek wstępny | Błąd |
|---|---|---|
new Person(...) |
firstName i lastName wymagane | ValidationError: Person requires firstName and lastName |
new Organization(...) |
legalName wymagane | ValidationError: Organization requires legalName |
addAddress(isPrimary=true) |
— | Brak błędu; istniejący główny adres tego samego typu zostaje zdegradowany |
Person.getAge() |
birthDate jest ustawione | Zwraca None jeśli birthDate jest null (brak błędu) |
Schemat SQL
sql-- Using single-table inheritance with discriminator column
CREATE TABLE party (
id UUID PRIMARY KEY,
party_type VARCHAR(20) NOT NULL, -- 'Person' or 'Organization'
-- Person fields
first_name VARCHAR(100),
last_name VARCHAR(100),
middle_name VARCHAR(100),
birth_date DATE,
gender VARCHAR(10),
-- Organization fields
legal_name VARCHAR(255),
trading_name VARCHAR(255),
registration_number VARCHAR(50),
tax_id VARCHAR(50),
founded_date DATE,
-- Common fields
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT chk_party_type CHECK (party_type IN ('Person', 'Organization')),
CONSTRAINT chk_person_name CHECK (
party_type != 'Person' OR (first_name IS NOT NULL AND last_name IS NOT NULL)
),
CONSTRAINT chk_org_name CHECK (
party_type != 'Organization' OR legal_name IS NOT NULL
)
);
CREATE TABLE address (
id UUID PRIMARY KEY,
party_id UUID NOT NULL REFERENCES party(id) ON DELETE CASCADE,
street VARCHAR(255) NOT NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
country CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
address_type VARCHAR(20) NOT NULL, -- 'Home', 'Work', 'Billing', 'Shipping'
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Index for common queries
CREATE INDEX idx_party_type ON party(party_type);
CREATE INDEX idx_address_party ON address(party_id);
CREATE INDEX idx_address_type ON address(party_id, address_type);Zagadnienia projektowe
Dziedziczenie jednej tabeli vs łączenie tabel
| Podejście | Zalety | Wady |
|---|---|---|
| Jedna tabela | Proste zapytania, szybkie | Wiele kolumn NULL, marnowanie miejsca |
| Łączenie tabel | Znormalizowane, indeksy specyficzne dla typu | Złożone zapytania, więcej JOINów |
| Tabela konkretna | Brak NULLi, izolowane typy | Nie można łatwo odpytywać wszystkich Party |
Zalecenie: Jedna tabela dla Party (niewiele kolumn specyficznych dla typu), łączenie tabel dla złożonych hierarchii.
Kiedy stosować wzorzec Party
✅ Stosuj gdy:
- Zarówno Person, jak i Organization mogą być Customer/Supplier/Employee
- Potrzebujesz ujednoliconych zapytań niezależnie od typu Party
- Współdzielone zachowania (adresy, kontakty, role)
❌ Nie stosuj gdy:
- Masz do czynienia tylko z jednym typem (tylko Person LUB tylko Organization)
- Typy mają drastycznie różne atrybuty bez części wspólnej
- Prosty CRUD bez logiki biznesowej
Related Patterns
- Party Role — Jaką rolę pełni Party? (Customer, Supplier, Employee)
- Party Relationship — Jak Party są powiązane? (Employment, Ownership)
- Party Identifier — Zewnętrzne identyfikatory (Paszport, NIP, Numer Customer)