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
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.8Terminating 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 daysState Machine
Invariants
- Party required — Every role must belong to exactly one Party
- Valid date range — validFrom must be before or equal to validTo
- No overlapping roles of same type — A Party cannot have two active Customer roles simultaneously
- Unique identifiers — customerNumber, supplierCode, employeeId must be unique within their type
- 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
└── SupplierRelated Patterns
- Party — The entity playing the role
- Party Relationship — Relationships between parties (customer-of, employed-by)
- Order — Customer places orders