The Party Identifier Pattern
Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt
Problem
Parties have multiple identifiers from different contexts:
- A person has: passport number, national ID (PESEL), driver's license, social security number
- An organization has: tax ID (NIP), registration number (KRS), DUNS number, EU VAT number
- Your system assigns: customer number, employee ID, account number
These identifiers:
- Come from different issuing authorities (government, your system, international bodies)
- Have different validity periods (passport expires, customer number is permanent)
- Need to be looked up ("find customer by PESEL")
- May have verification status (confirmed vs unverified)
Concept
Party Identifier separates identity management from the Party itself. Each identifier is a separate entity with its own type, value, issuing authority, and lifecycle.
This allows a Party to have any number of identifiers, added or removed over time, without changing the Party structure.
Structure
Identifier Types
Person Identifiers
| Code | Name | Pattern Example | Issuer |
|---|---|---|---|
| PESEL | Polish National ID | 11 digits | Polish Government |
| PASSPORT | Passport Number | Country-specific | Government |
| SSN | Social Security | XXX-XX-XXXX | US Government |
| DRIVING | Driver's License | Varies | DMV/Authority |
| TAX_ID | Personal Tax ID | Varies | Tax Authority |
Organization Identifiers
| Code | Name | Pattern Example | Issuer |
|---|---|---|---|
| NIP | Polish Tax ID | 10 digits | Polish Tax Office |
| KRS | Polish Registry | 10 digits | Court Registry |
| REGON | Polish Stats ID | 9 or 14 digits | Statistics Office |
| VAT_EU | EU VAT Number | CC + number | EU Member State |
| DUNS | D-U-N-S Number | 9 digits | Dun & Bradstreet |
| LEI | Legal Entity ID | 20 chars | GLEIF |
System Identifiers
| Code | Name | Generated By |
|---|---|---|
| CUSTOMER_NO | Customer Number | Your system |
| EMPLOYEE_ID | Employee ID | HR system |
| ACCOUNT_NO | Account Number | Finance system |
| MEMBER_ID | Membership ID | Membership system |
Attributes
PartyIdentifier
| Attribute | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Unique identifier |
| party | Party | Yes | Owner of this identifier |
| identifierType | IdentifierType | Yes | Type (PESEL, NIP, etc.) |
| value | String | Yes | The identifier value |
| issuingAuthority | String | No | Who issued it |
| issuedDate | Date | No | When it was issued |
| validFrom | Date | No | When it became valid |
| validTo | Date | No | Expiration date |
| verified | Boolean | Yes | Has it been verified? |
| verifiedAt | Timestamp | No | When verification occurred |
| verifiedBy | String | No | Who/what verified it |
IdentifierType
| Attribute | Type | Required | Description |
|---|---|---|---|
| code | String | Yes | Unique code (PESEL, NIP) |
| name | String | Yes | Human-readable name |
| description | String | No | Explanation |
| pattern | String | No | Regex validation pattern |
| appliesToPerson | Boolean | Yes | Valid for Person? |
| appliesToOrganization | Boolean | Yes | Valid for Organization? |
| requiresVerification | Boolean | Yes | Must be verified before use? |
Behaviors
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.partyExamples
Adding identifiers to a 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]Looking up a Party by identifier
// 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 daysValidation Rules
PESEL (Polish National ID)
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 (Polish Tax ID)
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 (Polish Statistical ID — 9-digit)
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-digit — for local units)
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 (Polish Court Registry — 10 digits)
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 trueEU VAT Number
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) >= 2Luhn Algorithm (Credit cards, IMEI, etc.)
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 == 0Invariants
- Valid for party type — PESEL only for Person, NIP only for Organization
- Unique active identifier — No two active identifiers of same type for same party
- Format validation — Value must match type's pattern
- Verified if required — If type requires verification, must be verified before use
- Valid date range — validFrom <= validTo
Error Handling
| Operation | Precondition Violated | Error |
|---|---|---|
addIdentifier(type, value) |
Type not applicable to party | PreconditionError: PESEL cannot be assigned to Organization |
addIdentifier(type, value) |
Value fails format validation | PreconditionError: value does not match pattern for NIP |
addIdentifier(type, value) |
Active identifier of same type exists | PreconditionError: party already has active PESEL |
verify(verifier) |
Already verified | PreconditionError: identifier already verified |
verify(verifier) |
Value fails format check | PreconditionError: cannot verify — invalid format |
findPartyByIdentifier(type, value) |
Not found | Returns None (no exception) |
getIdentifier(type) |
Not found | Returns None (no exception) |
SQL Schema
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}$');Common Queries
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);Design Considerations
Encryption
Sensitive identifiers (SSN, PESEL) should be encrypted at rest:
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 searchingVerification Integration
Verification can be:
- Manual — Staff member confirms document
- Automated — API call to government registry
- Third-party — Identity verification service (Onfido, etc.)
Historical Values
When an identifier changes (new passport number), you can either:
- Replace — Update value, lose history
- Expire and add — Set validTo on old, add new with validFrom
- Version — Add version number to track changes
Option 2 is recommended for audit trails.
Related Patterns
- Party — The entity being identified
- Party Role — Role-specific identifiers (Employee ID belongs to Employee role)
- Document — Physical document proving identity