Party Identifier Pattern (Wzorzec identyfikatora podmiotu)
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Party posiadają wiele identyfikatorów z różnych kontekstów:
- Person ma: numer paszportu, numer PESEL, prawo jazdy, numer ubezpieczenia społecznego
- Organization ma: NIP, numer KRS, numer DUNS, numer VAT UE
- Twój system przydziela: numer klienta, identyfikator pracownika, numer konta
Te identyfikatory:
- Pochodzą od różnych organów wydających (rząd, twój system, organizacje międzynarodowe)
- Mają różne okresy ważności (paszport wygasa, numer klienta jest stały)
- Muszą być wyszukiwalne („znajdź Customer po PESEL")
- Mogą mieć status weryfikacji (potwierdzony vs niezweryfikowany)
Koncepcja
Party Identifier oddziela zarządzanie tożsamością od samego Party. Każdy identyfikator jest osobną encją z własnym typem, wartością, organem wydającym i cyklem życia.
Pozwala to Party mieć dowolną liczbę identyfikatorów, dodawanych lub usuwanych w czasie, bez zmiany struktury Party.
Struktura
Typy identyfikatorów
Identyfikatory Person
| Kod | Nazwa | Przykład formatu | Wydawca |
|---|---|---|---|
| PESEL | Polski nr PESEL | 11 cyfr | Rząd RP |
| PASSPORT | Numer paszportu | Zależny od kraju | Rząd |
| SSN | Numer ubezpieczenia społecznego | XXX-XX-XXXX | Rząd USA |
| DRIVING | Prawo jazdy | Różne | Urząd wydający |
| TAX_ID | Osobisty nr podatkowy | Różne | Urząd skarbowy |
Identyfikatory Organization
| Kod | Nazwa | Przykład formatu | Wydawca |
|---|---|---|---|
| NIP | Polski NIP | 10 cyfr | Urząd skarbowy |
| KRS | Polski KRS | 10 cyfr | Sąd rejestrowy |
| REGON | Polski REGON | 9 lub 14 cyfr | GUS |
| VAT_EU | Numer VAT UE | Kod kraju + numer | Państwo członkowskie UE |
| DUNS | Numer D-U-N-S | 9 cyfr | Dun & Bradstreet |
| LEI | Legal Entity Identifier | 20 znaków | GLEIF |
Identyfikatory systemowe
| Kod | Nazwa | Generowany przez |
|---|---|---|
| CUSTOMER_NO | Numer klienta | Twój system |
| EMPLOYEE_ID | Identyfikator pracownika | System HR |
| ACCOUNT_NO | Numer konta | System finansowy |
| MEMBER_ID | Identyfikator członka | System członkostwa |
Atrybuty
PartyIdentifier
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| id | UUID | Tak | Unikalny identyfikator |
| party | Party | Tak | Właściciel tego identyfikatora |
| identifierType | IdentifierType | Tak | Typ (PESEL, NIP, itd.) |
| value | String | Tak | Wartość identyfikatora |
| issuingAuthority | String | Nie | Kto go wydał |
| issuedDate | Date | Nie | Kiedy został wydany |
| validFrom | Date | Nie | Od kiedy jest ważny |
| validTo | Date | Nie | Data wygaśnięcia |
| verified | Boolean | Tak | Czy został zweryfikowany? |
| verifiedAt | Timestamp | Nie | Kiedy nastąpiła weryfikacja |
| verifiedBy | String | Nie | Kto/co zweryfikowało |
IdentifierType
| Atrybut | Typ | Wymagane | Opis |
|---|---|---|---|
| code | String | Tak | Unikalny kod (PESEL, NIP) |
| name | String | Tak | Nazwa czytelna dla człowieka |
| description | String | Nie | Wyjaśnienie |
| pattern | String | Nie | Wzorzec walidacji (regex) |
| appliesToPerson | Boolean | Tak | Czy dotyczy Person? |
| appliesToOrganization | Boolean | Tak | Czy dotyczy Organization? |
| requiresVerification | Boolean | Tak | Czy wymaga weryfikacji przed użyciem? |
Zachowania
Entity PartyIdentifier:
function isActive(): Boolean
today = currentDate()
fromOk = validFrom is None or validFrom <= today
toOk = validTo is None or validTo >= today
return fromOk and toOk
function isExpired(): Boolean
return validTo is not None and validTo < currentDate()
function isExpiringSoon(days: Integer): Boolean
if validTo is None:
return false
return validTo <= currentDate() + days
function verify(verifier: String):
require not verified // Don't re-verify
require identifierType.validate(value) // Format is valid
verified = true
verifiedAt = now()
verifiedBy = verifier
emit IdentifierVerified(this, verifier)
function revoke(reason: String):
validTo = currentDate()
revokedReason = reason
emit IdentifierRevoked(this, reason)
Entity IdentifierType:
function validate(value: String): Boolean
if pattern is None:
return true
return value matches pattern
function canApplyTo(party: Party): Boolean
if party is Person:
return appliesToPerson
if party is Organization:
return appliesToOrganization
return false
// Query helpers on Party
Entity Party:
function addIdentifier(type: IdentifierType, value: String): PartyIdentifier
require type.canApplyTo(this)
require type.validate(value)
require not hasActiveIdentifier(type) // No duplicates
identifier = new PartyIdentifier(
party: this,
identifierType: type,
value: value,
verified: false
)
identifiers.add(identifier)
return identifier
function getIdentifier(type: IdentifierType): PartyIdentifier or None
return identifiers
.filter(i => i.identifierType == type and i.isActive())
.first()
function hasActiveIdentifier(type: IdentifierType): Boolean
return getIdentifier(type) is not None
function getVerifiedIdentifiers(): PartyIdentifier[]
return identifiers.filter(i => i.verified and i.isActive())
function getExpiringIdentifiers(days: Integer): PartyIdentifier[]
return identifiers.filter(i => i.isExpiringSoon(days))
// Lookup function (repository/service level)
function findPartyByIdentifier(type: IdentifierType, value: String): Party or None
identifier = findOne PartyIdentifier
where identifierType == type
and value == value
and isActive()
if identifier is None:
return None
return identifier.partyPrzykłady
Dodawanie identyfikatorów do Person
jan = findParty("Jan Kowalski")
peselType = findIdentifierType("PESEL")
passportType = findIdentifierType("PASSPORT")
// Add PESEL
pesel = jan.addIdentifier(peselType, "85031512345")
pesel.issuingAuthority = "Urzad Stanu Cywilnego Warszawa"
pesel.verified = false
// Verify it
pesel.verify("System:PESEL-API")
// emits IdentifierVerified(pesel, "System:PESEL-API")
// Add passport with expiry
passport = jan.addIdentifier(passportType, "EA1234567")
passport.issuingAuthority = "Wojewoda Mazowiecki"
passport.validFrom = 2020-06-15
passport.validTo = 2030-06-15
passport.isExpiringSoon(365) // false (expires 2030)
jan.getVerifiedIdentifiers() // [pesel]Wyszukiwanie Party po identyfikatorze
// Find party by NIP (tax ID)
nipType = findIdentifierType("NIP")
acme = findPartyByIdentifier(nipType, "5261234567")
// Returns: Acme Sp. z o.o.
// Find expiring documents
expiringIds = jan.getExpiringIdentifiers(90)
// Returns identifiers expiring within 90 daysReguły walidacji
PESEL (polski numer ewidencyjny)
function validatePESEL(value: String): Boolean
if length(value) != 11:
return false
if not isAllDigits(value):
return false
// Checksum validation
weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
sum = 0
for i in 0..9:
sum += digit(value, i) * weights[i]
checkDigit = (10 - (sum mod 10)) mod 10
return checkDigit == digit(value, 10)NIP (polski numer identyfikacji podatkowej)
function validateNIP(value: String): Boolean
// Remove dashes
normalized = value.replace("-", "")
if length(normalized) != 10:
return false
if not isAllDigits(normalized):
return false
weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]
sum = 0
for i in 0..8:
sum += digit(normalized, i) * weights[i]
checkDigit = sum mod 11
return checkDigit == digit(normalized, 9)REGON (polski numer statystyczny — 9-cyfrowy)
function validateREGON9(value: String): Boolean
if length(value) != 9:
return false
if not isAllDigits(value):
return false
weights = [8, 9, 2, 3, 4, 5, 6, 7]
sum = 0
for i in 0..7:
sum += digit(value, i) * weights[i]
checkDigit = sum mod 11
if checkDigit == 10:
checkDigit = 0
return checkDigit == digit(value, 8)REGON (14-cyfrowy — dla jednostek lokalnych)
function validateREGON14(value: String): Boolean
if length(value) != 14:
return false
if not isAllDigits(value):
return false
// First 9 digits must be valid REGON-9
if not validateREGON9(value.substring(0, 9)):
return false
weights = [2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8]
sum = 0
for i in 0..12:
sum += digit(value, i) * weights[i]
checkDigit = sum mod 11
if checkDigit == 10:
checkDigit = 0
return checkDigit == digit(value, 13)KRS (Krajowy Rejestr Sądowy — 10 cyfr)
function validateKRS(value: String): Boolean
if length(value) != 10:
return false
if not isAllDigits(value):
return false
// KRS has no checksum — just format validation
return trueNumer VAT UE
function validateVATEU(value: String): Boolean
if length(value) < 4:
return false
countryCode = value.substring(0, 2)
number = value.substring(2)
// Country code must be valid EU member
validCountries = ["AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE",
"FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV",
"LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK",
"SI", "ES", "SE"]
if countryCode not in validCountries:
return false
// Country-specific length and format
match countryCode:
case "PL":
return length(number) == 10 and isAllDigits(number)
case "DE":
return length(number) == 9 and isAllDigits(number)
case "FR":
return length(number) == 11
// ... other countries have their own rules
default:
return isAlphanumeric(number) and length(number) >= 2Algorytm Luhna (karty kredytowe, IMEI, itd.)
function validateLuhn(value: String): Boolean
if not isAllDigits(value):
return false
sum = 0
doubleNext = false
// Process from right to left
for i in (length(value) - 1) downTo 0:
d = digit(value, i)
if doubleNext:
d = d * 2
if d > 9:
d = d - 9
sum += d
doubleNext = not doubleNext
return sum mod 10 == 0Niezmienniki
- Zgodność z typem Party — PESEL tylko dla Person, NIP tylko dla Organization
- Unikalny aktywny identyfikator — Brak dwóch aktywnych identyfikatorów tego samego typu dla tego samego Party
- Walidacja formatu — Wartość musi pasować do wzorca typu
- Weryfikacja jeśli wymagana — Jeśli typ wymaga weryfikacji, musi być zweryfikowany przed użyciem
- Prawidłowy zakres dat — validFrom <= validTo
Obsługa błędów
| Operacja | Naruszony warunek wstępny | Błąd |
|---|---|---|
addIdentifier(type, value) |
Typ nie dotyczy tego Party | PreconditionError: PESEL cannot be assigned to Organization |
addIdentifier(type, value) |
Wartość nie przechodzi walidacji formatu | PreconditionError: value does not match pattern for NIP |
addIdentifier(type, value) |
Istnieje aktywny identyfikator tego samego typu | PreconditionError: party already has active PESEL |
verify(verifier) |
Już zweryfikowany | PreconditionError: identifier already verified |
verify(verifier) |
Wartość nie przechodzi walidacji formatu | PreconditionError: cannot verify — invalid format |
findPartyByIdentifier(type, value) |
Nie znaleziono | Zwraca None (bez wyjątku) |
getIdentifier(type) |
Nie znaleziono | Zwraca None (bez wyjątku) |
Schemat SQL
sql-- Identifier types (reference data)
CREATE TABLE identifier_type (
code VARCHAR(30) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
pattern VARCHAR(255), -- Regex pattern
applies_to_person BOOLEAN NOT NULL DEFAULT FALSE,
applies_to_organization BOOLEAN NOT NULL DEFAULT FALSE,
requires_verification BOOLEAN NOT NULL DEFAULT FALSE
);
-- Party identifiers
CREATE TABLE party_identifier (
id UUID PRIMARY KEY,
party_id UUID NOT NULL REFERENCES party(id) ON DELETE CASCADE,
identifier_type VARCHAR(30) NOT NULL REFERENCES identifier_type(code),
value VARCHAR(100) NOT NULL,
issuing_authority VARCHAR(100),
issued_date DATE,
valid_from DATE,
valid_to DATE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMP,
verified_by VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_valid_dates CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from)
);
-- Unique constraint: one active identifier per type per party
CREATE UNIQUE INDEX idx_party_identifier_unique
ON party_identifier(party_id, identifier_type)
WHERE valid_to IS NULL OR valid_to >= CURRENT_DATE;
-- Fast lookup by identifier value
CREATE INDEX idx_party_identifier_lookup
ON party_identifier(identifier_type, value);
-- Find parties with expiring identifiers
CREATE INDEX idx_party_identifier_expiry
ON party_identifier(valid_to)
WHERE valid_to IS NOT NULL;
-- Insert standard identifier types
INSERT INTO identifier_type (code, name, applies_to_person, applies_to_organization, pattern) VALUES
('PESEL', 'Polish National ID', TRUE, FALSE, '^\d{11}$'),
('NIP', 'Polish Tax ID', FALSE, TRUE, '^\d{10}$'),
('KRS', 'Polish Company Registry', FALSE, TRUE, '^\d{10}$'),
('PASSPORT', 'Passport Number', TRUE, FALSE, NULL),
('VAT_EU', 'EU VAT Number', FALSE, TRUE, '^[A-Z]{2}[A-Z0-9]+$'),
('CUSTOMER_NO', 'Customer Number', TRUE, TRUE, '^CUST-\d{8}$'),
('EMPLOYEE_ID', 'Employee ID', TRUE, FALSE, '^EMP-\d{6}$');Typowe zapytania
sql-- Find party by identifier
SELECT p.*
FROM party p
JOIN party_identifier pi ON pi.party_id = p.id
WHERE pi.identifier_type = 'PESEL'
AND pi.value = '12345678901'
AND (pi.valid_to IS NULL OR pi.valid_to >= CURRENT_DATE);
-- Get all identifiers for a party
SELECT pi.*, it.name as type_name
FROM party_identifier pi
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE pi.party_id = :partyId
ORDER BY pi.identifier_type;
-- Find identifiers expiring in next 30 days
SELECT pi.*, p.name as party_name, it.name as type_name
FROM party_identifier pi
JOIN party p ON p.id = pi.party_id
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE pi.valid_to BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
ORDER BY pi.valid_to;
-- Find unverified identifiers requiring verification
SELECT pi.*, p.name as party_name
FROM party_identifier pi
JOIN party p ON p.id = pi.party_id
JOIN identifier_type it ON it.code = pi.identifier_type
WHERE it.requires_verification = TRUE
AND pi.verified = FALSE
AND (pi.valid_to IS NULL OR pi.valid_to >= CURRENT_DATE);Zagadnienia projektowe
Szyfrowanie
Wrażliwe identyfikatory (SSN, PESEL) powinny być szyfrowane w spoczynku:
sql-- Option 1: Application-level encryption
value_encrypted BYTEA NOT NULL,
-- Option 2: Database-level (PostgreSQL)
value VARCHAR(100) NOT NULL, -- Use pgcrypto extension
-- Keep searchable hash for lookup
value_hash VARCHAR(64) NOT NULL, -- SHA-256 hash for searchingIntegracja weryfikacji
Weryfikacja może być:
- Ręczna — Employee potwierdza dokument
- Automatyczna — Wywołanie API do rejestru rządowego
- Zewnętrzna — Usługa weryfikacji tożsamości (Onfido, itd.)
Wartości historyczne
Gdy identyfikator się zmienia (nowy numer paszportu), można:
- Zastąpić — Zaktualizować wartość, utracić historię
- Wygasić i dodać nowy — Ustawić validTo na starym, dodać nowy z validFrom
- Wersjonować — Dodać numer wersji do śledzenia zmian
Opcja 2 jest zalecana ze względu na ścieżki audytu.
Related Patterns
- Party — Encja będąca identyfikowana
- Party Role — Identyfikatory specyficzne dla roli (identyfikator Employee należy do roli Employee)
- Dokument — Fizyczny dokument potwierdzający tożsamość