Software Archetypes

The Party Role Pattern

Based on "Enterprise Patterns and MDA" by Jim Arlow & Ila Neustadt

Problem

A Party (person or organization) can act in different capacities:

  • The same person can be a Customer and an Employee
  • An organization can be both a Supplier and a Customer
  • Roles have different attributes: Customer has loyalty tier, Employee has hire date
  • Roles can be time-bounded: someone was an employee from 2020-2023
  • You need to query "all customers" regardless of whether they're people or organizations

Concept

Party Role separates who someone is (Party) from what role they play (Role). A Party can have multiple roles, and each role can have its own attributes, behaviors, and lifecycle.

Think of it as "wearing different hats" — the same person wears a Customer hat when buying and an Employee hat when working.

Structure

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

Attributes

PartyRole (Abstract)

Attribute Type Required Description
id UUID Yes Unique identifier
party Party Yes The party playing this role
validFrom Date Yes When role became active
validTo Date No When role ended (null = still active)
createdAt Timestamp Yes Record creation time

Customer

Attribute Type Required Description
customerNumber String Yes Unique customer identifier
loyaltyTier Enum No Bronze, Silver, Gold, Platinum
creditLimit Money No Maximum credit allowed
preferredPaymentMethod Enum No Card, BankTransfer, Invoice

Supplier

Attribute Type Required Description
supplierCode String Yes Unique supplier identifier
paymentTermsDays Integer Yes Days until payment due (30, 60)
rating Enum No A, B, C, D rating
minimumOrderValue Money No Minimum order amount

Employee

Attribute Type Required Description
employeeId String Yes Unique employee identifier
department String Yes Department name
position String Yes Job title
hireDate Date Yes Employment start date
salary Money No Current salary
managerId UUID No Reference to manager (Employee)

Behaviors

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)

Examples

Jan Kowalski as Customer and 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

Terminating a role

// 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

State Machine

create(validFrom)

update attributes

terminate(validTo)

Active

Terminated

validTo is NULL
isActive() returns true

validTo is set
isActive() returns false

Invariants

  1. Party required — Every role must belong to exactly one Party
  2. Valid date range — validFrom must be before or equal to validTo
  3. No overlapping roles of same type — A Party cannot have two active Customer roles simultaneously
  4. Unique identifiers — customerNumber, supplierCode, employeeId must be unique within their type
  5. Role type immutable — Cannot change a Customer into a Supplier (create new role instead)

Relationships

From To Cardinality Description
PartyRole Party many to 1 Role belongs to a Party
Customer Order 1 to many Customer places orders
Supplier PurchaseOrder 1 to many Supplier receives purchase orders
Employee Employee many to 1 Employee reports to manager
Employee Department many to 1 Employee belongs to department

Error Handling

Operation Precondition Violated Error
terminate(endDate) endDate < validFrom PreconditionError: end date cannot be before start date
terminate(endDate) validTo is not None PreconditionError: role already terminated
canPlaceOrder(total) Role not active Returns false (no exception)
canPlaceOrder(total) Over credit limit Returns false (no exception)
upgradeTier(newTier) newTier <= loyaltyTier PreconditionError: can only upgrade tier, not downgrade
promote(pos, salary) Not active PreconditionError: cannot promote terminated employee
promote(pos, salary) newSalary < salary PreconditionError: salary cannot decrease on promotion

Design choice: Query methods (canPlaceOrder, isActive) return booleans. Command methods (terminate, promote) throw on invalid state. This lets callers check before calling, or handle the exception.

SQL Schema

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);

Common Queries

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;

Design Considerations

Role as Separate Entity vs Attributes on Party

Approach Pros Cons
Separate Entity Multiple roles, time-bounded More complex, more JOINs
Attributes on Party Simple, fast queries One role per party, no history

Recommendation: Use separate entity when:

  • Same party can have multiple roles
  • Roles need their own lifecycle (valid from/to)
  • Role-specific attributes differ significantly

Historical Roles

The pattern supports keeping historical roles (validTo set to past date). This enables:

  • "Who was our customer in 2020?"
  • "Employment history for this person"
  • Audit trails for compliance

Role Hierarchy

For complex systems, consider role hierarchies:

PartyRole
  └── BusinessPartner (abstract)
        ├── Customer
        └── Supplier
  • Party — The entity playing the role
  • Party Relationship — Relationships between parties (customer-of, employed-by)
  • Order — Customer places orders