StackPractices
intermediate By Mathias Paulenko

Role Pattern

Assign dynamic roles to objects at runtime instead of hard-coding behavior in class hierarchies, enabling flexible identity changes without inheritance bloat.

Overview

The Role Pattern attaches behavior to objects at runtime instead of baking it into the class hierarchy. The same entity often plays different parts depending on context: one person can be a customer, an employee, and a vendor — sometimes at once. Model that with inheritance and you end up subclassing every combination (CustomerEmployeePerson, VendorCustomerPerson…).

The fix is to split the core entity (who the object is) from its roles (what it does). Roles attach, detach, and combine at runtime, so the entity keeps one identity while its capabilities change.

You’ll find this pattern behind authorization systems (RBAC), workflow engines, and multi-tenant apps — anywhere identity and capability need to move independently.

When to Use

Use the Role Pattern when:

  • One entity plays several roles depending on context
  • Inheritance can’t cover the role combinations (customer + employee + vendor multiply subclasses)
  • Roles get assigned, revoked, or changed at runtime
  • Role assignments must be auditable (who had what role, when)

When to Avoid

  • Roles are static and known at compile time (a simple type hierarchy suffices)
  • The system has only one or two roles with no overlap
  • Introducing role objects adds indirection without clear benefit
  • Performance is critical and role lookup overhead is unacceptable
  • For alternatives like shared-context coordination, see the Blackboard Pattern

Solution

Python

from typing import Dict, List, Optional, Protocol
from dataclasses import dataclass, field
from datetime import datetime

class Role(Protocol):
    """Protocol for roles that can be attached to entities"""
    role_name: str

    def can_perform(self, action: str) -> bool:
        ...


@dataclass
class CustomerRole:
    role_name: str = "customer"
    loyalty_points: int = 0

    def can_perform(self, action: str) -> bool:
        return action in ["browse", "purchase", "review"]

    def earn_points(self, amount: int):
        self.loyalty_points += amount


@dataclass
class EmployeeRole:
    role_name: str = "employee"
    department: str = ""
    salary: float = 0.0

    def can_perform(self, action: str) -> bool:
        return action in ["browse", "inventory", "support", "refund"]

    def process_refund(self, order_id: str) -> str:
        return f"Refund processed for {order_id}"


@dataclass
class VendorRole:
    role_name: str = "vendor"
    company_name: str = ""
    commission_rate: float = 0.05

    def can_perform(self, action: str) -> bool:
        return action in ["list_products", "manage_inventory", "view_sales"]


class Person:
    """Core entity that can hold multiple roles"""
    def __init__(self, person_id: str, name: str):
        self.person_id = person_id
        self.name = name
        self._roles: Dict[str, Role] = {}
        self._role_history: List[dict] = []

    def assign_role(self, role: Role):
        self._roles[role.role_name] = role
        self._role_history.append({
            "action": "assigned",
            "role": role.role_name,
            "timestamp": datetime.now().isoformat()
        })

    def revoke_role(self, role_name: str):
        if role_name in self._roles:
            del self._roles[role_name]
            self._role_history.append({
                "action": "revoked",
                "role": role_name,
                "timestamp": datetime.now().isoformat()
            })

    def has_role(self, role_name: str) -> bool:
        return role_name in self._roles

    def get_role(self, role_name: str) -> Optional[Role]:
        return self._roles.get(role_name)

    def can_perform(self, action: str) -> bool:
        return any(role.can_perform(action) for role in self._roles.values())

    @property
    def roles(self) -> List[str]:
        return list(self._roles.keys())


# Usage
person = Person("P-001", "Alice")
person.assign_role(CustomerRole(loyalty_points=100))
person.assign_role(EmployeeRole(department="Sales", salary=75000))

print(f"Alice's roles: {person.roles}")
print(f"Can browse: {person.can_perform('browse')}")
print(f"Can refund: {person.can_perform('refund')}")
print(f"Can list products: {person.can_perform('list_products')}")

# Access role-specific behavior
emp_role = person.get_role("employee")
if emp_role:
    print(emp_role.process_refund("ORD-123"))

# Revoke a role
person.revoke_role("customer")
print(f"Roles after revoke: {person.roles}")

Java

import java.util.*;

interface Role {
    String getRoleName();
    boolean canPerform(String action);
}

class CustomerRole implements Role {
    private int loyaltyPoints = 0;

    public String getRoleName() { return "customer"; }
    public boolean canPerform(String action) {
        return List.of("browse", "purchase", "review").contains(action);
    }
    public void earnPoints(int amount) { loyaltyPoints += amount; }
}

class EmployeeRole implements Role {
    private String department;
    private double salary;

    public EmployeeRole(String department, double salary) {
        this.department = department; this.salary = salary;
    }

    public String getRoleName() { return "employee"; }
    public boolean canPerform(String action) {
        return List.of("browse", "inventory", "support", "refund").contains(action);
    }
    public String processRefund(String orderId) { return "Refund processed for " + orderId; }
}

class Person {
    private final String personId;
    private final String name;
    private final Map<String, Role> roles = new HashMap<>();
    private final List<Map<String, String>> history = new ArrayList<>();

    public Person(String personId, String name) {
        this.personId = personId; this.name = name;
    }

    public void assignRole(Role role) {
        roles.put(role.getRoleName(), role);
        history.add(Map.of("action", "assigned", "role", role.getRoleName()));
    }

    public void revokeRole(String roleName) {
        if (roles.remove(roleName) != null) {
            history.add(Map.of("action", "revoked", "role", roleName));
        }
    }

    public boolean hasRole(String roleName) { return roles.containsKey(roleName); }
    public Role getRole(String roleName) { return roles.get(roleName); }

    public boolean canPerform(String action) {
        return roles.values().stream().anyMatch(r -> r.canPerform(action));
    }

    public List<String> getRoles() { return new ArrayList<>(roles.keySet()); }
}

// Usage
Person person = new Person("P-001", "Alice");
person.assignRole(new CustomerRole());
person.assignRole(new EmployeeRole("Sales", 75000));

System.out.println("Roles: " + person.getRoles());
System.out.println("Can browse: " + person.canPerform("browse"));
System.out.println("Can refund: " + person.canPerform("refund"));

JavaScript

class CustomerRole {
  constructor(loyaltyPoints = 0) {
    this.roleName = 'customer';
    this.loyaltyPoints = loyaltyPoints;
  }

  canPerform(action) {
    return ['browse', 'purchase', 'review'].includes(action);
  }

  earnPoints(amount) {
    this.loyaltyPoints += amount;
  }
}

class EmployeeRole {
  constructor(department, salary) {
    this.roleName = 'employee';
    this.department = department;
    this.salary = salary;
  }

  canPerform(action) {
    return ['browse', 'inventory', 'support', 'refund'].includes(action);
  }

  processRefund(orderId) {
    return `Refund processed for ${orderId}`;
  }
}

class VendorRole {
  constructor(companyName, commissionRate = 0.05) {
    this.roleName = 'vendor';
    this.companyName = companyName;
    this.commissionRate = commissionRate;
  }

  canPerform(action) {
    return ['list_products', 'manage_inventory', 'view_sales'].includes(action);
  }
}

class Person {
  constructor(personId, name) {
    this.personId = personId;
    this.name = name;
    this.roles = new Map();
    this.history = [];
  }

  assignRole(role) {
    this.roles.set(role.roleName, role);
    this.history.push({ action: 'assigned', role: role.roleName, timestamp: new Date().toISOString() });
  }

  revokeRole(roleName) {
    if (this.roles.has(roleName)) {
      this.roles.delete(roleName);
      this.history.push({ action: 'revoked', role: roleName, timestamp: new Date().toISOString() });
    }
  }

  hasRole(roleName) {
    return this.roles.has(roleName);
  }

  getRole(roleName) {
    return this.roles.get(roleName);
  }

  canPerform(action) {
    for (const role of this.roles.values()) {
      if (role.canPerform(action)) return true;
    }
    return false;
  }

  getRoleNames() {
    return Array.from(this.roles.keys());
  }
}

// Usage
const person = new Person('P-001', 'Alice');
person.assignRole(new CustomerRole(100));
person.assignRole(new EmployeeRole('Sales', 75000));

console.log('Roles:', person.getRoleNames());
console.log('Can browse:', person.canPerform('browse'));
console.log('Can refund:', person.canPerform('refund'));

const empRole = person.getRole('employee');
if (empRole) console.log(empRole.processRefund('ORD-123'));

person.revokeRole('customer');
console.log('Roles after revoke:', person.getRoleNames());

Explanation

The pattern separates identity from behavior:

  • Entity (Person): the stable identity — ID, name, nothing role-specific
  • Role (CustomerRole, EmployeeRole): behavior attached and detached at runtime
  • Role history: a log of assign/revoke events for auditing
Mermaid flowchart TD diagram

Callers ask the entity; the entity asks its attached roles. Revoking a role removes its capabilities without touching the entity’s identity — no CustomerEmployeePerson extends Person chains anywhere.

Variants

VariantMechanismUse Case
Role ObjectSeparate role instances attached to entityDDD, authorization
Trait/MixinLanguage-level compositionScala traits, Ruby modules
RBACRoles with permissions matrixEnterprise authorization
State MachineRoles as states with transitionsWorkflow engines

What Works

  • Keep the entity thin. The entity should only hold identity and role management.
  • Make roles stateful when needed. A CustomerRole can track loyalty points.
  • Audit role changes. Security and compliance often require role transition logs.
  • Avoid role overlap confusion. If two roles grant the same permission, document the resolution strategy.
  • Use value objects for role data. Immutable role data shouldn’t be mutated directly.

Common Mistakes

  • Using inheritance for roles. Employee extends Person doesn’t allow a person to also be a customer.
  • God entity. The entity class accumulates fields for every possible role.
  • Role permission conflicts. Two roles grant conflicting permissions with no resolution rule.
  • Not cleaning up on role revocation. Resources held by a role should be released when revoked.
  • Storing role-specific data on the entity. Customer loyalty points belong in CustomerRole, not Person.

Real-World Examples

RBAC (Role-Based Access Control)

Enterprise systems assign users to roles (Admin, Editor, Viewer) rather than granting permissions directly — NIST standardized RBAC on exactly this model. The Role Pattern is the implementation basis underneath it.

DDD Aggregates

In Domain-Driven Design, a Person entity may hold Customer and Employee value objects as roles within a bounded context.

Game Characters

Game entities often have role components: WarriorRole, MerchantRole, QuestGiverRole that are attached to a base Character entity.

Companion code: the role-pattern companion repo packages all three implementations as runnable projects.

Frequently Asked Questions

What's the difference between Role and Strategy?

Strategy swaps how something is done (the algorithm). Role swaps what the object is allowed to be (identity plus capability). A person can hold several roles at once; a strategy is usually a single active choice.

Can an entity hold multiple instances of the same role?

Usually no — one employee role per person. Some domains do allow it (a person under two separate vendor contracts); if yours does, store roles in a list keyed by a qualifier instead of a plain map.

How does this relate to RBAC?

RBAC is the authorization application of this pattern: roles carry permissions, and the entity gains capabilities through its assigned roles. The pattern adds assign/revoke and history; RBAC adds the permission matrix on top.

What happens when two roles grant conflicting permissions?

The simple rule is union — any role granting the action allows it. Stricter domains need a resolution policy (deny-overrides, precedence order, or explicit conflict checks at assignment time). Pick one and write it down; silent conflicts become security bugs.

How do I persist roles and their history?

Store assignments as events (assigned, revoked, timestamp, actor) alongside the entity. Roles themselves can live in a join table or as serialized value objects on the entity row — keep them separate so revoking doesn't touch entity data.

Is this overkill for a small project?

Often, yes. Two roles with no overlap? Use plain composition or a flag. The pattern pays off when role combinations multiply or roles change at runtime — introduce it when the combinatorial pain shows up, not before.

Can I attach a role conditionally at runtime?

That's the point. Gate assignRole with whatever rule you need — purchase history, manual approval, a workflow step — and keep the check outside the role itself so the role stays a pure capability bundle.