Paul Blasucci's Weblog

Thoughts on software development and sundry other topics

weblog index

Growing a Gilded Rose, Part 2: Next Year's Model

Published:

This is part two-of-four in the series, Growing a Gilded Rose. Over the course of these four blog posts, I hope to demonstrate incrementally improving a legacy code base which has thorny requirements, while also presenting a few different software development tools or concepts. The full series is as follows:

  1. Make it Testable
  2. Next Year's Model (this post)
  3. When Worlds Collide
  4. A New Requirement Appears

Bonus Content

  1. F# All the Things!
  2. Meh... C# Can Do That, Too

Solution Evolution

Overview

So last time, we got acquainted with our problem domain, the Gilded Rose Kata. We made a tiny change to the existing code to enable a very crude sort of approval test. Then, using this test to guide us, we made another small change. This one enabled us to write a few property-based tests. We tested nine properties in total, based on the observed behavior of the program and an informal explanation (which we received as part of the overall requirements). The code, as it stands at the end of the previous post, is available in the companion repository, in a branch called 1_testable. But it's all been very casual, so far.

In this post, we'll build on our previous work to define a proper domain model. That is, we will make explicit, in code, the important business details, which until now have been merely implicit in the behavior of the legacy software. Further, we will extend our suite of tests to exercise the logic of our model. It's also worth noting what we won't do. Specifically, we won't touch the legacy code at all!

Adding a Model

To begin working on our domain model, we will add a new F# library project to our existing solution. We're forced to do this because you cannot mix C# and F# in the same unit of compilation (also referred to as a “project”). The library, though, requires no special configuration or any extra references. Further the project itself only contains two files:

As for the actual model, carefully re-reading the description brings to light a few different requirements:

  1. Each inventory item has one, and only one, “kind”.
  2. Most items have a “quality”, which changes over time.
  3. Quality is a constrained value.
  4. Some item's have a different kind of quality which does not change.
  5. Unchanging qualities do not have the same constraints as those which change.
  6. Most items have an “age”, some do not.
  7. An item's quality is updated using rules specific to its kind.

Further buried in the actual implementation of UpdateQuality are other facts:

  1. An item's kind is determined by its “name”, which never changes.
  2. When an item's quality is updated, so too is its age.
  3. Item's are aged one whole (integral) “day” at a time.

Working through the signature file (from top to bottom) we can see all of these points addressed with five relatively simple constructs. First, we have a unit of measure:

/// A common unit of time (n.b. "business day" -- not necessarily a "solar day").
type [<Measure>] days

We'll use this to distinguish an item's age, which we'll actually call “Sell In”, so as to be consistent with the vocabulary used by domain experts (i.e. other Gilded Rose employees). This will help to address point ten from the previous list.

Then we define a struct to represent the constrained value of an item:

/// The value of a ordinary item
/// (n.b. constrained within: 0 .. 50 "units", inclusive).
[<Struct>]
type Quality =
  private { Value : uint8 }

  /// The smallest possible value of a Quality (0 "units").
  static member MinValue : Quality

  /// The largest possible value of a Quality (50 "units").
  static member MaxValue : Quality

  /// Constructs a Quality from the given value
  /// (n.b. overlarge inputs are truncated to Quality.MaxValue).
  static member Of : value : uint8 -> Quality

  /// Defines an explicit conversion of a Quality to an unsigned 8-bit integer.
  static member op_Explicit : Quality -> uint8

  /// Adds two Quality values
  /// (n.b. result does not overflow, but is truncated to Quality.MaxValue).
  static member ( + ) : left : Quality * right : Quality -> Quality

  /// Subtracts two Quality values
  /// (n.b. result does not underflow, but is truncated to Quality.MinValue).
  static member ( - ) : left : Quality * right : Quality -> Quality

This is very much in the vein of a “Value Object”, as one might find in literature about Domain Driven Design. It actually follows a coding style I've written about a few times in the past. It defines a primitive type which:

The most important detail of this type is that values are truncated on both the high and low ends. That is, rather than having operations like addition and subtract “wrap around” (e.g. 49 + 3 = 2), we simple “cap” at MinValue or MaxValue (e.g. 49 + 3 = 50) We can see how this is achieved in this excerpt from Inventory.fs (n.b. comments added solely for this blog post):

// ... other functionality elided ...

static member Of(value) =
  // Internally we store values in a yte. Since the smallest possible
  // value for a yte (0) is also the smallest possible value for a
  // Quality, on construction we only have to guard against over-large
  // inputs, which we truncate to Quality.MaxValue (50).
  { Value = min value 50uy }

static member ( + ) (left, right) =
  let sum = left.Value + right.Value
  // ⮟⮟⮟ simple check for "wrap around"
  if sum < left.Value then Quality.MaxValue else Quality.Of(sum)

static member ( - ) (left, right) =
  let dif = left.Value - right.Value
  // ⮟⮟⮟ simple check for "wrap around"
  if left.Value < dif then Quality.MinValue else Quality.Of(dif)

Returning to our signature file, we also define a struct for our “constant” value, though it requires significantly less functionality:

/// The value of an extraordinary item (80 "units", always).
[<Struct>]
type MagicQuality =
  /// Defines an explicit conversion of a MagicQuality to an unsigned 8-bit integer.
  static member op_Explicit : MagicQuality -> uint8

Next, we make, perhaps, the most important change from the legacy code. We explicitly codify the various “kinds” of inventory items. To recap, this is how an Item is defined in the original program (and remember, we cannot change this, lest we ire the goblin in the corner):

public class Item
{
    public string Name { get; set; } = "";

    public int SellIn { get; set; }

    public int Quality { get; set; }
}

However, when we re-read the description of the system, we find no less than four different types of inventory. As these are mutually exclusive, we can neatly describe the whole lot with a discriminated union:

/// Tracks the category, name, value, and "shelf life" of any inventory.
type Item =
  /// An item with a constant value and no "shelf life".
  | Legendary of name : string * quality : MagicQuality

  /// An item whose value decreases as its "shelf life" decreases.
  | Depreciating of name : string * quality : Quality * sellIn : int32<days>

  /// An item whose value increases as its "shelf life" decreases.
  | Appreciating  of name : string * quality : Quality * sellIn : int32<days>

  /// An item whose value is subject to complex, "shelf life"-dependent rules.
  | BackstagePass of name : string * quality : Quality * sellIn : int32<days>

Effectively, each kind of inventory item gets its own “variant” (or “case” or “label”), plus any relevant data. It is important to note, conceptually: this is still only one type. But it can exist in exactly one of these four -- and only these four -- states. Further, though we may refer to the field as “age” or “shelf life” elsewhere, here we use the term sellIn, as this reflects usage in both the legacy code and by domain experts (i.e. other Gilded Rose inn employees).

So far, we've address roughly 7 or 8 of the 10 requirements listed above. All of the remaining behavior will be accounted for in a single function, given at the end of the signature file as:

/// Change the quality and "shelf life" for an Item
/// (i.e. apply appropriate rules for the passage of a single "business day").
val updateItem : item : Item -> Item

This seemingly simple fellow is somewhat analogous to the body of the foreach loop in the original code's UpdateQuality method. That is, it operates on a single inventory item. However, rather than modifying the item in place, it takes an item as input and returns a new item as output. Given the immutable nature of discriminated unions, this is hardly surprising. However, this also makes it easier to reason about and test the code. Let's now jump back to the implementation file and see the details. The function, in its entirety, is as follows (we'll break it down immediately after):

let updateItem item =
  // advance the "shelf life" clock by a single day
  let (|Aged|) sellIn = Aged(sellIn - 1<days>)

  // items with negative "shelf life" gain/lose value twice as quickly
  let rateOfChange sellIn = if sellIn < 0<days> then 2uy else 1uy

  match item with
  | Legendary _ -> item

  | Depreciating (name, quality, Aged sellIn') ->
      let quality' = quality - Quality.Of(rateOfChange sellIn')
      Depreciating(name, quality', sellIn')

  | Appreciating (name, quality, Aged sellIn') ->
      let quality' = quality + Quality.Of(rateOfChange sellIn')
      Appreciating(name, quality', sellIn')

  | BackstagePass (name, quality, sellIn & Aged sellIn') ->
      let quality' =
        if sellIn' < 0<days> then
          Quality.MinValue
        else
          //  NOTE
          //  ----
          //  Pass quality has a "hard cliff", based on "shelf life".
          //  However, until then, its value is calculated against
          //  the _current_ expiry (i.e. before advancing the clock).
          quality + Quality.Of(
            match sellIn with
            | days when days <=  5<days> -> 3uy
            | days when days <= 10<days> -> 2uy
            | _                          -> 1uy
          )
      BackstagePass(name, quality', sellIn')

The implementation begins by defining two helpers:

// advance the "shelf life" clock by a single day
let (|Aged|) sellIn = Aged(sellIn - 1<days>)

This single-case total active pattern works more-or-less like an ordinary function (in fact, you could use it that way if you really wanted). It takes a number of days as input, and returns that number reduced by one day. But, by making it an active pattern, we can perform this operation anyplace where one might pattern match! We'll see this put to good use very soon.

// items with negative "shelf life" gain/lose value twice as quickly
let rateOfChange sellIn = if sellIn < 0<days> then 2uy else 1uy

This function helps determine how quickly an item's Quality increases or decreases. It's basically a multiplier, such that when an item's “shelf life” is negative, things change twice as fast. In any other case, value is altered at the normal rate (i.e. changed by one “unit”).

Next, we have a match expression, which takes different actions based on the kind of inventory item passed into the function. Notice the symmetry between the definition of Item and how we pattern match against an instance of it. This effectively replaces the many many if statements in the legacy code. And it no longer requires potentially fragile string comparisons in order to make decisions. Further, it groups together related bits of logic. Let's consider each case in turn.

match item with
| Legendary _ -> item

This one is straight-forward. To quote the initial project description:

... a legendary item, never has to be sold or decreases in Quality.

Basically, being given a Legendary item is a non-operation. So we immediately return the input data exactly as we received it. Then things gets more interesting.

match item with
// ... other code elided ...

| Depreciating (name, quality, Aged sellIn') ->
    let quality' = quality - Quality.Of(rateOfChange sellIn')
    Depreciating(name, quality', sellIn')

Depreciating items are those items whose value decreases every time the "shelf life" decreases. First, we extract the name and quality fields. Technically, we also extract the sellIn field. But that's hidden behind an invocation of the (|Aged|) active pattern, which is giving us a new “shelf life”, bound to sellIn', which has already been reduced by one day. Effectively, we “advance the clock” at the same time as decomposing the item into its constituent parts. Then, with the help of the rateOfChange function we defined earlier, we make a new value (quality'), which has been appropriately reduced. Finally, we package up the name, the reduced quality, and the aged “shelf life” into a new instance of a Depreciating item. And, as this is the last expression in the current code path, it becomes a return value for the overall function.

Next, we have Appreciating items, which are very similar to Depreciating ones. However, in this variant, the relationship between time and value is inverted. When “shelf life” decreases, quality increases.

match item with
// ... other code elided ...

| Appreciating (name, quality, Aged sellIn') ->
    let quality' = quality + Quality.Of(rateOfChange sellIn')
    Appreciating(name, quality', sellIn')

This is a simple matter of using addition, where we'd previously used subtraction, to produce a new value for the item. It is interesting to note: the rateOfChange helper function is still used in exactly the same way.

Finally, we arrive at the branch for the inventory item type with the most complex update logic: backstage passes. A BackstagePass increases in value by several different increments, depending on its “shelf life”. However, it also ceases to be worth anything, after a certain point-in-time. We start by decomposing the item into its constituents.

match item with
// ... other code elided ...

| BackstagePass (name, quality, sellIn & Aged sellIn') ->

However, unlike the previous cases, here we extract two values for the item's “shelf life”. sellIn is the value which was passed into the updateItem function. That is, it's the age before “advancing the clock”. Meanwhile, sellIn' is the newly advanced age, and it comes from the (|Aged|) active pattern (just as we did for depreciating and appreciating items).

Next, we have to determine the updated value of the item's quality. This is a bit non-obvious, as it requires first checking the updated “shelf life” (sellIn').

let quality' =
  if sellIn' < 0<days> then
    Quality.MinValue

If we're passed the day of the show for which the BackstagePass grants access, it's not worth anything. So, the pass's new worth (quality') is just set to Quality.MinValue (which happens to be zero units). However, if the show's not-yet-started, things get more complex. We increase the item's worth. But the amount of increase is determined by the “shelf life” before any aging has taken place (sellIn).

let quality' =
  // ... code elided ...
  else
    //  NOTE
    //  ----
    //  Pass quality has a "hard cliff", based on "shelf life".
    //  However, until then, its value is calculated against
    //  the _current_ expiry (i.e. before advancing the clock).
    quality + Quality.Of(
      match sellIn with
      | days when days <=  5<days> -> 3uy
      | days when days <= 10<days> -> 2uy
      | _                          -> 1uy
    )

Thus, quality is incremented by three units when the show is less than six days away. The increment falls to two units when the show is less then eleven days away. Finally, if we've got more than ten days to wait, the worth of backstage pass will increase by one unit.

BackstagePass(name, quality', sellIn')

We conclude the current branch (and the updateItem function) by building and returning a new BackstagePass instance, comprised of the original item's name, the increased -- or worthless! -- quality, and the aged “shelf life”.

Testing a Model

Now that we've formalized the domain logic, it behooves us to test everything. Again, we will leverage property-based testing. In fact, we will duplicate the existing tests, "re-phrasing" them in terms of our new model. As this winds up being a useful-but-rote conversion, we won't explore it here. Instead, we will highlight one very interesting deviation.

Perhaps the most obvious -- but most significant -- change in the new model is the creation of the Quality type. This value object encodes logic which was previously only manifest in the behavior of the UpdateQuality method. This change also gives rise to an important change in the test suite. Instead of one single test (after +N days, ordinary item has 0 <= quality <= 50), included in UpdateQualitySpecs.fs, we have an entire set of new assertions around the behavior of the Quality type. Specifically, we ensure that creation, addition, and subtraction all uphold our invariants (i.e. never less than 0 and never more than 50). The portions of QualitySpecs.fs covering the classical arithmetic properties of addition are as follows (comments added for this post):

module QualitySpecs =
  // ... other tests elided ...

  [<Property>]
  let `additive identity holds` quality =
    // incrementing by nothing is a non-operation ... A + 0 = A
    quality + Quality.MinValue = quality

  [<Property>]
  let `addition is commutative` (quality1 : Quality) (quality2 : Quality) =
    // ordering of operands does NOT matter ... A + B = B + A
    quality1 + quality2 = quality2 + quality1

  [<Property>]
  let `addition is associative`
    (quality1 : Quality)
    (quality2 : Quality)
    (quality3 : Quality)
    =
    //  grouping of operands does NOT matter ... A + (B + C) = (A + B) + C
    quality1 + (quality2 + quality3) = (quality1 + quality2) + quality3

  // ... other tests elided ...

So, not only have we made explicit some key behavior in the system, but also we have greatly increased our confidence in the logical soundness of that behavior.

Conclusion

Building on previously gained insights, we've now:

And all of the code listed above, plus several other bits and bobs, may be found in the companion repository, in a branch called 2_model-fs.

It might not seem like it, but we've come a very long way in a very short period of time. Having all the pieces in place means we're now ready to start adding new features. Weeeeeell, we're almost ready. But not quite. 😉 Before we add support for new “conjured” items, we need to integrate the F# model into our C# program, which is the subject of the next blog post. Or, if you prefer, visit the discussion forum to ask questions and share feedback for this post (or any of the other in the series).