Software Archetypes

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

1

0..*

0..*

1

Party

+id: UUID

+name: String

+identifiers: PartyIdentifier[]

+getIdentifier(type) : : PartyIdentifier

PartyIdentifier

+id: UUID

+party: Party

+identifierType: IdentifierType

+value: String

+issuingAuthority: String

+validFrom: Date

+validTo: Date

+verified: Boolean

+isActive() : : Boolean

IdentifierType

+code: String

+name: String

+pattern: String

+appliesToPerson: Boolean

+appliesToOrganization: Boolean

+validate(value) : : Boolean

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

Examples

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 days

Validation 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 true

EU 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) >= 2

Luhn 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 == 0

Invariants

  1. Valid for party type — PESEL only for Person, NIP only for Organization
  2. Unique active identifier — No two active identifiers of same type for same party
  3. Format validation — Value must match type's pattern
  4. Verified if required — If type requires verification, must be verified before use
  5. 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 searching

Verification 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:

  1. Replace — Update value, lose history
  2. Expire and add — Set validTo on old, add new with validFrom
  3. Version — Add version number to track changes

Option 2 is recommended for audit trails.

  • Party — The entity being identified
  • Party Role — Role-specific identifiers (Employee ID belongs to Employee role)
  • Document — Physical document proving identity