Python & Data Science

Crc Cards Designing Classes By Actually Role Playi

You’ve drawn the perfect UML diagram. The boxes are neatly aligned. The arrows point in the right directions. You feel ready to write code.

Then you start coding. And everything falls apart.

That Order class you designed? It turns out it needs to know about payment processing, inventory, AND customer notifications. The Customer class? It’s doing nothing except holding a name — you created a glorified dictionary. Your beautiful diagram was a lie, and now you’re staring at a messy refactor.

Sound familiar?

The problem isn’t you. It’s the tool. UML diagrams are static — they show structure, not behavior. They can’t capture the dynamic conversation between objects. You can’t see that Order is doing too much until you actually try to write the code that makes it work.

What if you could test your design before you wrote a single line of code?

That’s exactly what CRC cards do. Invented in 1989 by Kent Beck and Ward Cunningham (the same people behind Extreme Programming and the wiki), CRC cards are a low-tech, high-insight technique that’s still one of the best ways to design classes. The original paper is a classic: Beck & Cunningham, OOPSLA ‘89.

In this tutorial, you’ll learn what CRC cards are, how to role-play with them, and how to translate the results into clean Python code.

What Actually Is a CRC Card? (Intuition First)

Grab a 3x5 index card. On it, you write three things:

  • Class Name: The name of the class (e.g., Order).
  • Responsibilities: What the class knows (attributes) and what it can do (methods).
  • Collaborators: Which other classes it talks to.

Here’s the magic: the card is small. If you can’t fit all the responsibilities, the class is doing too much. This enforces the single-responsibility principle by physical space, not by abstract rules.

Let’s see what a CRC card looks like for a ShoppingCart class:

+------------------------------------------+
|            ShoppingCart                   |
|------------------------------------------|
| Responsibilities           | Collaborators|
|----------------------------|--------------|
| Knows list of items        | Item         |
| Can add an item            |              |
| Can remove an item         |              |
| Can calculate total price  | Item (price) |
| Can apply discount code    | Discount     |
+------------------------------------------+

The real power comes when you role-play. Each developer holds one card and speaks as that object. When a method call happens, you pass the card to the collaborator. This surfaces dependencies that static analysis misses — because you’re literally acting out the conversation.

In plain English: CRC cards are a way to ‘walk through’ your code’s story before you write it.

Setting Up Your First Role-Play: The Coffee Shop Scenario

Let’s make this concrete. Imagine you’re building a coffee shop ordering system. You know the domain: a customer walks in, orders a latte, pays, and receives the drink.

First, identify candidate classes from the nouns in the scenario:

  • Customer — the person ordering
  • Order — the drink they want
  • Barista — the person making the drink
  • Cashier — the person handling payment
  • Menu — the list of available drinks and prices

Now write a CRC card for each. Keep it to one card per class — no cheating with tiny handwriting.

Here’s the card for Order:

+------------------------------------------+
|                Order                      |
|------------------------------------------|
| Responsibilities           | Collaborators|
|----------------------------|--------------|
| Knows drink name           |              |
| Knows drink size           |              |
| Can calculate price        | Menu         |
| Knows who placed it        | Customer     |
+------------------------------------------+

And here’s the card for Menu:

+------------------------------------------+
|                Menu                       |
|------------------------------------------|
| Responsibilities           | Collaborators|
|----------------------------|--------------|
| Knows all drink names      |              |
| Knows prices for each size |              |
| Can look up price          |              |
+------------------------------------------+

Notice something? The Order card says it collaborates with Menu to calculate the price. That’s a dependency we discovered just by thinking about responsibilities.

Now let’s translate this into a Python class skeleton. Each responsibility becomes a method or attribute:

class Order:
    """From CRC card: Order"""
    
    def __init__(self, drink_name: str, size: str, customer: 'Customer'):
        # From responsibility: 'Knows drink name'
        self.drink_name = drink_name
        # From responsibility: 'Knows drink size'
        self.size = size
        # From responsibility: 'Knows who placed it'
        self.customer = customer
        # Collaborator: Menu (will be used in calculate_price)
        self._menu = None  # Will be set later
    
    def calculate_price(self, menu: 'Menu') -> float:
        """From responsibility: 'Can calculate price'
        
        Collaborator: Menu — we need to ask Menu for the price.
        """
        return menu.get_price(self.drink_name, self.size)

This is the easiest part of the process — the hard work was already done in the role-play.

The Role-Play: Walking Through the ‘Place Order’ Scenario

Now comes the core of the technique. You and your team physically act out the scenario.

Here’s how it works:

  1. You hold the Customer card.
  2. A colleague holds Order.
  3. Another holds Barista.
  4. Another holds Cashier.
  5. Another holds Menu.

You speak the dialogue:

You (as Customer): “I, Customer, create a new Order with drink=‘latte’ and size=‘medium’. I pass the card to Order.”

You physically hand the Order card to your colleague.

Colleague (as Order): “I, Order, need to calculate the price. I ask Menu for the price of a medium latte. I pass the card to Menu.”

The Menu card holder then says:

Colleague (as Menu): “I, Menu, know that a medium latte costs $$4.50. I return the price to Order.”

And so on. Every time a card is passed, you’ve discovered a collaborator relationship.

Let’s see what happens when the order is complete:

You (as Customer): “I, Customer, tell Order that I’m ready to pay.”

Colleague (as Order): “I, Order, tell Cashier to process payment of $$4.50 from Customer.”

Colleague (as Cashier): “I, Cashier, process the payment. I tell Barista to make the drink.”

Colleague (as Barista): “I, Barista, make the latte. I tell Customer the drink is ready.”

This feels silly at first. But watch what happens: you’re simulating method calls and data flow without writing a single line of code. If a card gets passed to a class that doesn’t exist yet, you need to create it — or realize you were wrong about the design.

Now here’s the interesting part. Let’s translate that dialogue into Python code:

class Order:
    """From CRC card: Order"""
    
    def __init__(self, drink_name: str, size: str, customer: 'Customer'):
        self.drink_name = drink_name
        self.size = size
        self.customer = customer
        self._is_paid = False
    
    def calculate_price(self, menu: 'Menu') -> float:
        """From responsibility: 'Can calculate price'
        
        This came from the role-play: Order asks Menu for the price.
        """
        return menu.get_price(self.drink_name, self.size)
    
    def request_payment(self, cashier: 'Cashier'):
        """From role-play: Order tells Cashier to process payment."""
        amount = self.calculate_price(self._menu)
        cashier.process_payment(self.customer, amount)
        self._is_paid = True

class Cashier:
    """From CRC card: Cashier"""
    
    def process_payment(self, customer: 'Customer', amount: float):
        """From role-play: Cashier processes payment and tells Barista."""
        print(f"Processing ${amount:.2f} payment from {customer.name}")
        # In real code, you'd charge the customer's card
        self._notify_barista(customer.order)
    
    def _notify_barista(self, order: 'Order'):
        """From role-play: Cashier tells Barista to make the drink."""
        barista = Barista()
        barista.make_drink(order)

See how the code mirrors the dialogue? The request_payment method calls cashier.process_payment, which then calls barista.make_drink. This loop came directly from the card-passing we just acted out.

What the Role-Play Reveals: Spotting Design Smells

After the role-play, analyze what you learned. The role-play acts like a stress test for your design. It finds the cracks before you build.

Here are the most common design smells the role-play reveals:

God object smell: A card that’s constantly being passed to. It has too many responsibilities. Split it.

In our coffee shop, what if Order was also responsible for calculating tax, applying discounts, and notifying the kitchen? Every scenario would pass the card to Order. That’s a god object.

Orphan class smell: A card that never gets passed to. It might be dead code — or you missed a scenario.

If LoyaltyCard never appears in any role-play, maybe it’s not needed yet. Or maybe you forgot to include the loyalty points redemption scenario.

Duplicated responsibility smell: Two cards both claim to ‘know the price’. That should be in one place.

If both Order and Menu claim to ‘know the price’, which one is right? The role-play reveals the confusion: Order asks Menu for the price, so the responsibility belongs to Menu.

Missing collaborator smell: A card needs information it doesn’t have. You discover a missing class or method.

What if Order needs to check if the customer has enough loyalty points? It doesn’t have a LoyaltyCard collaborator. The role-play reveals this gap.

Let’s see a refactoring example. Suppose the role-play showed that Order was doing too much — it was calculating price, applying discounts, AND managing loyalty points. We split it:

# Before: Order was a god object
class Order:
    def calculate_price(self, menu):
        return menu.get_price(self.drink_name, self.size)
    
    def apply_discount(self, code):
        # Discount logic mixed into Order
        pass
    
    def redeem_loyalty_points(self, points):
        # Loyalty logic mixed into Order
        pass

# After: Split into separate classes
class Order:
    """From CRC card: Order — now focused on order details only."""
    def __init__(self, drink_name, size, customer):
        self.drink_name = drink_name
        self.size = size
        self.customer = customer

class OrderCalculator:
    """From CRC card: OrderCalculator — handles price calculation."""
    def calculate_total(self, order, menu, discount_code=None):
        base_price = menu.get_price(order.drink_name, order.size)
        if discount_code:
            base_price *= 0.9  # 10% discount
        return base_price

class LoyaltyManager:
    """From CRC card: LoyaltyManager — handles points."""
    def redeem_points(self, customer, points):
        if customer.loyalty_points >= points:
            customer.loyalty_points -= points
            return True
        return False

The role-play showed that Order was doing too much — passing the card to itself too often. We split it.

From Card to Code: Translating the CRC Design into Python

Now that the design is validated, let’s write the actual Python classes. Each CRC card maps directly to a class definition:

  • CLASS NAME → class definition (e.g., class Order:)
  • RESPONSIBILITIES → methods and attributes (e.g., self.drink_name, def calculate_total())
  • COLLABORATORS → type hints or imports (e.g., from menu import Menu)

Here’s the full coffee shop system:

from typing import List, Optional
from dataclasses import dataclass

# From CRC card: Customer
@dataclass
class Customer:
    """From CRC card: Customer"""
    name: str
    loyalty_points: int = 0
    order: Optional['Order'] = None

# From CRC card: Menu
class Menu:
    """From CRC card: Menu"""
    def __init__(self):
        # From responsibility: 'Knows all drink names and prices'
        self._prices = {
            ('latte', 'small'): 3.50,
            ('latte', 'medium'): 4.50,
            ('latte', 'large'): 5.50,
            ('cappuccino', 'small'): 3.00,
            ('cappuccino', 'medium'): 4.00,
            ('cappuccino', 'large'): 5.00,
        }
    
    def get_price(self, drink_name: str, size: str) -> float:
        """From responsibility: 'Can look up price'"""
        return self._prices.get((drink_name, size), 0.0)

# From CRC card: Order
class Order:
    """From CRC card: Order"""
    def __init__(self, drink_name: str, size: str, customer: Customer):
        # From responsibility: 'Knows drink name'
        self.drink_name = drink_name
        # From responsibility: 'Knows drink size'
        self.size = size
        # From responsibility: 'Knows who placed it'
        self.customer = customer
        self._is_paid = False
        self._is_ready = False
    
    def calculate_price(self, menu: Menu) -> float:
        """From responsibility: 'Can calculate price'
        
        Collaborator: Menu — we ask Menu for the price.
        """
        return menu.get_price(self.drink_name, self.size)
    
    def mark_paid(self):
        """From responsibility: 'Can be marked as paid'"""
        self._is_paid = True
    
    def mark_ready(self):
        """From responsibility: 'Can be marked as ready'"""
        self._is_ready = True

# From CRC card: Cashier
class Cashier:
    """From CRC card: Cashier"""
    def process_payment(self, customer: Customer, amount: float) -> bool:
        """From responsibility: 'Can process payment'
        
        Collaborator: Customer — we need to charge the customer.
        """
        print(f"{self.__class__.__name__}: Processing ${amount:.2f} from {customer.name}")
        # In real code, this would call a payment gateway
        customer.order.mark_paid()
        return True

# From CRC card: Barista
class Barista:
    """From CRC card: Barista"""
    def make_drink(self, order: Order) -> str:
        """From responsibility: 'Can make a drink'
        
        Collaborator: Order — we need to know what drink to make.
        """
        print(f"{self.__class__.__name__}: Making a {order.size} {order.drink_name}")
        order.mark_ready()
        return f"{order.size} {order.drink_name}"

# The main scenario: walk through the role-play in code
def run_coffee_shop_scenario():
    """Run the 'place order' scenario from the role-play."""
    # Setup
    menu = Menu()
    cashier = Cashier()
    barista = Barista()
    
    # Customer walks in
    customer = Customer(name="Alice")
    print(f"\n{'='*50}")
    print(f"Customer {customer.name} walks into the coffee shop.")
    print(f"{'='*50}\n")
    
    # Customer places an order
    order = Order(drink_name="latte", size="medium", customer=customer)
    customer.order = order
    print(f"Customer: 'I'd like a {order.size} {order.drink_name}, please.'")
    print()
    
    # Calculate price
    price = order.calculate_price(menu)
    print(f"Order: 'That will be ${price:.2f}.'")
    print()
    
    # Process payment
    cashier.process_payment(customer, price)
    print()
    
    # Make the drink
    drink = barista.make_drink(order)
    print()
    
    # Serve
    print(f"Barista: 'Your {drink} is ready, {customer.name}!")
    print(f"\n{'='*50}")
    print(f"Scenario complete!")
    print(f"Order paid: {order._is_paid}")
    print(f"Order ready: {order._is_ready}")
    print(f"{'='*50}")

if __name__ == "__main__":
    run_coffee_shop_scenario()

When you run this, you’ll see:

==================================================
Customer Alice walks into the coffee shop.
==================================================

Customer: 'I'd like a medium latte, please.'

Order: 'That will be $4.50.'

Cashier: Processing $4.50 from Alice

Barista: Making a medium latte

Barista: 'Your medium latte is ready, Alice!'

==================================================
Scenario complete!
Order paid: True
Order ready: True
==================================================

Every class, every method, every collaborator came directly from the CRC cards we wrote during the role-play. The hard work was done before we wrote a single line of code.

When CRC Cards Work (and When They Don’t)

CRC cards are a powerful tool, but they’re not a silver bullet. Let’s be honest about their strengths and limitations.

Best for: Teams that can physically sit together and role-play. The social interaction is the feature, not a bug. The original Beck & Cunningham paper was written for teaching procedural programmers object-oriented thinking — exactly our audience.

Worst for: Remote teams without good video call support, or for problems that are naturally functional (e.g., data pipelines). If you’re doing pure data transformations, CRC cards add overhead without much benefit.

Limitation: CRC cards assume an object-oriented design. If you’re doing functional programming, this technique won’t fit. But for this series — learning to decompose problems into objects — CRC cards are perfect.

Modern alternatives: Some teams use digital tools like Miro boards or specialized CRC card apps. But the physical act of passing cards is hard to replicate digitally. The Reddit thread on modern CRC card usage has good discussions on this.

What You Learned (and What Comes Next)

Let’s recap what you learned:

  • How to write a CRC card: Class name at the top, responsibilities on the left, collaborators on the right. The small-card rule enforces single responsibility.
  • How to role-play a scenario: Each developer holds a card and speaks as that object. Passing cards reveals dependencies.
  • How to spot design smells: God objects, orphan classes, duplicated responsibilities, missing collaborators.
  • How to translate CRC cards into Python classes: Each responsibility becomes a method or attribute. Each collaborator becomes a type hint or import.

The key insight: CRC cards let you test your design before you write code. The role-play acts like a stress test, finding cracks in your design before they become expensive refactors.

Next up in this series: we’ll take the CRC designs we’ve discovered and turn them into testable code with unit tests. Because a design that’s been validated by role-play is a design that’s ready to be tested.

Check Your Understanding

Remember: What are the three things written on a CRC card?

Understand: Why does the small-card rule enforce the single-responsibility principle?

Apply: Take a class you’ve written recently (or a class from a tutorial). Write a CRC card for it. Does it fit on one card? If not, what responsibilities can you split off?

Analyze: In the coffee shop role-play, what would happen if Order also had the responsibility of ‘knows how to make the drink’? What design smell would that create?

Evaluate: Compare CRC cards to UML diagrams for designing a new feature. When would you choose one over the other?

Create: Design a CRC card for a Playlist class in a music streaming app. What are its responsibilities? Who are its collaborators? Role-play the scenario of adding a song to the playlist.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.