In Progress
Unit 1, Lesson 1
In Progress

Contextual Identity Part 2

Whether modeling cards in a game or shares in a corporation, sometimes you need to create objects which must exist only in a single collection at a time. In this concluding episode of a two-part series, we’ll introduce a new solution to the modeling problem posed in Part 1. You’ll see how the choice of whether to model a domain concept as an immutable value object needs to take into account not just the concept’s intrinsic properties, but also the context that it exists within.

Video transcript & code

In the last episode, we started talking about modeling card games in an object-oriented way. And we talked about how a "Card" object seems like a perfect example of a "value object": an immutable object which is defined solely in terms of its attribute. In this case, those attributes are Rank and Suit. One queen of hearts is interchangeable with another queen of hearts; these two cards have no unique identity.

But then we talked about how when we're working with value objects, it's really easy to accidentally introduce duplicates. For instance, we had this code that deals out 5 cards to hand… but winds up with duplicates of the card in the deck and in the hand.

hand = deck.take(5)
# => [Card(7 of Diamonds), Card(King of Hearts), Card(3 of Diamonds), Card(10 of Spades), Card(7 of Clubs)]

(deck + hand).size
# => 57

In some applications, this sort of thing is perfectly fine and normal. In fact, it's exactly how we expect to use value objects. But in a card game, if we start introducing duplicate cards into a game, we have a serious problem.

This fact introduces an interesting nuance into the question of Value Object versus Entity. Intrinsically, these cards still have just the two attributes: rank, and suit. But their relationship with the game in progress has kind of an identity-like flavor to it.

And this is where we get into one of those subtleties of object-oriented domain modeling which is all too often glossed over in introductions to the topic.

Here's the thing: yes, in the abstract, we can say that a playing card is defined solely in terms of its rank and its suit.

But we're not modeling the platonic concept of a playing card. We're modeling a card game. And in the context of a single game, each card does have an identity. A contextual identity. There are 52 cards, and each one of them can be in just one stack or hand at any given time.

Let's try out an alternative modeling that recognizes this identity.

We'll define a new, improved Card object that has five attributes:

  • suit
  • rank
  • A particular named stack of cards that it belongs to.
  • An ordinal position in that stack.

We also define a convenience method for getting at the card's deck via its stack

…and a customized inspect method to make it display more readably.

Card = Struct.new(:suit, :rank, :stack, :ordinal) do
  RANK_NAMES = [nil, "Ace", *(2..10), "Jack", "Queen", "King"]

  def deck
    stack.deck
  end

  def inspect
    "Card(#{RANK_NAMES[rank]} of #{suit})"
  end
end

We'll set up a Deck class to contain these cards.

For now, we'll just hard-code an initializer that sets up a standard 52-card deck. In the process, it sets each card's initial stack to be the :draw_pile. We'll define this stack method in a moment.

It also shuffles the deck by taking the original indexes of all the cards, randomizing them, and assigning the randomized numbers as ordinals.

We create an accessor method for getting at a named stack of cards in the form of an object. We haven't defined the CardStack class yet; we'll get to that next.

We also define a method to return just the cards corresponding to a given stack.

class Deck
  def initialize
    @cards = %w[Hearts Spades Clubs Diamonds]
               .product((1..13).to_a)
               .map{|(rank,suit)|
      Card[rank,suit,stack(:draw_pile)]
    }
    @cards.zip((0..@cards.size).to_a.shuffle).each do |card, ordinal|
      card.ordinal = ordinal
    end
  end

  def stack(stack_name)
    CardStack[self, stack_name]
  end

  def cards_in_stack(stack)
    @cards.select{|c| c.stack == stack}.sort_by(&:ordinal)
  end
end

We'll use the values gem to quickly define the CardStack class as a Struct-style immutable value object class.

The CardStack class represents… well, a stack of cards. But unlike the arrays we worked in the earlier episode, a CardStack won't "contain" cards in any real sense of the word. Instead, it will behave more like a handle to collections which exist inside the overall deck. As such, it has a reference to the deck, and an attribute for the name of the stack.

Most importantly, it has an each method that can iterate through the stacked cards in order.

…and it includes Enumerable to make full use of that each method.

As a convenience for moving cards into a stack, we provide a next_ordinal method to returns one more than the highest current ordinal in the stack.

Two more little tweaks before we're done: first, we enable card stacks to be created with struct-style subscripting by aliasing new to the square bracket operator.

And we redefine the to_a method to override the default values gem behavior. Instead, our version will return the list of cards.

require "values"

CardStack = Value.new(:deck, :name) do
  include Enumerable

  class <<self
    alias [] new
  end

  def each(&block)
    deck.cards_in_stack(self).each(&block)
  end

  def to_a
    deck.cards_in_stack(self)
  end

  def next_ordinal
    map(&:ordinal).max + 1
  end
end

Now that we've created the Deck and CardStack classes, let's jump back to the Card class and add a method for moving cards.

First it verifies that it and the destination stack are members of the same deck.

Then it updates its stack.

As well as updating its ordinal so that it is the last card in the new stack.

class Card
  # ...
  def move_to(dest_stack)
    deck == dest_stack.deck or
      fail "Can't move card to a different deck"
    self.stack   = dest_stack
    self.ordinal = dest_stack.next_ordinal
  end
end

Now that we know how to properly move a card, let's revisit the CardStack= class and add a method for dealing out a given number of cards to another stack.

class CardStack
  # ...
  def deal(count, to:)
    count.times do
      first.move_to(to)
    end
  end
end

Now, ultimately we're going to want a number of different domain actions for interacting with stacks of cards.

But just to give the flavor of what it's like to code for this new model of a card game, let's write a method for discarding a card.

Inside, move this card to a stack named :discard_pile.

class Card
  # ...
  def discard
    move_to(deck.stack(:discard_pile))
  end
end

Now let's see this method in action.

We'll start by seeding the random number generator, just for the sake of a consistent demonstration.

Then we'll instantiate a new Deck for this game.

We'll deal hands for Mal, Wash, Kaylee, and Zoe.

Let's take a look at the first card in Mal's hand.

Now let's discard it.

Listing Mal's hand, we can see that the card is gone.

Listing the discard pile, we can see the card has taken up residence.

require "./card"
require "./deck"
require "./card_stack"

srand 123

deck = Deck.new

mal  = deck.stack(:mal_hand)
deck.stack(:draw_pile).deal 5, to: mal

wash = deck.stack(:wash_hand)
deck.stack(:draw_pile).deal 5, to: wash

kaylee = deck.stack(:kaylee_hand)
deck.stack(:draw_pile).deal 5, to: kaylee

zoe = deck.stack(:zoe_hand)
deck.stack(:draw_pile).deal 5, to: zoe

mal.to_a
# => [Card(Jack of Hearts), Card(6 of Spades), Card(King of Diamonds), Card(7 of Clubs), Card(8 of Clubs)]

mal.first.discard

mal.to_a
# => [Card(6 of Spades), Card(King of Diamonds), Card(7 of Clubs), Card(8 of Clubs)]

deck.stack(:discard_pile).to_a
# => [Card(Jack of Hearts)]

This is kind of anticlimactic, but it represents a huge change in how we think about the problem. We started with a set of disconnected collections, which could easily become inconsistent with each other. Now, we have a closed system. Our collections are virtual, a card can only belong to one of them at a time, and all of the card movements which take place happen in the context of a single, internally-consistent Deck.

And our new domain model doesn't just make it easier to avoid inconsistencies. Let's say we decide we want to be able to save the state of a game in a file or database.

We've made this job very easy. If we simply dump the entries of the Deck to storage, we have a nearly complete snapshot of the game in progress.

require "yaml"
puts deck.to_yaml


# >> --- &1 !ruby/object:Deck
# >> cards:
# >> - !ruby/struct:Card
# >>   suit: Hearts
# >>   rank: 1
# >>   stack: !ruby/object:CardStack
# >>     deck: *1
# >>     name: :draw_pile
# >>     hash: -300291496390863507
# >>   ordinal: 36
# >> - !ruby/struct:Card
# >>   suit: Hearts
# >>   rank: 2
# >>   stack: !ruby/object:CardStack
# >>     deck: *1
# >>     name: :draw_pile
# >>     hash: -300291496390863507
# >>   ordinal: 26
# ...

The only extra information we need to store is whose turn it is.

Now, I know that unless you work for the online gaming industry, you're not likely to need to model this particular domain for your day job.

But this is a type of modeling problem I run into surprisingly often, in many different domains. There's a set of simple objects which seem like classic Value Objects. Except that, they need to exist inside a closed system, where a given object can only belong to one collection at a time.

The next time you run into a situation that involves this kind of fixed pool of objects, you'll remember this playing cards example. I can't tell you that it's definitely the right solution. But if nothing else, it's an alternative worth considering. Happy hacking!

Responses