Working With Design Patterns: State
The next story I want to tackle is to allow patrons to place a hold on a book. If checked in, the librarian places a held book behind the desk. Attempts to check it out by a different patron are rejected. If checked out, the system marks the book on hold for when it's returned. Holds are only valid for three days; a daily update routine hits all holdings, giving each an opportunity to clear any expired holds. The three-day timer starts only once a book is returned, if it's checked out. A hold cannot be placed on a book already on hold.
Based on these requirements, the code to manage patron holds (see Listing 3) really isn't that bad, but I'm starting to wonder where it's headed. I must ensure I have if statements in the right place, for example, when doing an update: If I add code to check to see whether any hold needs to be cleared, but neglect to guard against the case where the holding isn't already on hold, my code throws a NullPointerException. As I consider similar features, such as transfers and placing books on reserve, I'm thinking that the code easily could start getting unwieldy and confusing.
Tracking State
A holding can be in a number of states: checked out, checked in, on hold or not, in transit between branches, and so on. For the existing code, a holding can be in one of four possible states:
- checked out, on hold
- checked out, not on hold
- checked in, on hold
- checked out, not on hold
Defining the states allows me to draw a state diagram (see Figure 1). The state diagram captures each separate state as a separate box. A holding can transition between states when specific events occur, such as the holding being checked in. Events usually trigger actions as part of transitioning to another state.
Click here for a larger image.
Figure 1: State transitions.
The state pattern says that I can take each of these states and represent them as a separate class. The current state is tracked within Holding as a reference to a state object. I define the possible events as methods on all states. Each state handles the set of events however appropriately by invoking an action on the Holding object. The event handler also is responsible for updating the state reference on the Holding object if a state transition is required.
Listing 3: Holding, revised.
// HoldingTest.java import static org.junit.Assert.*; import java.util.*; import org.junit.*; public class HoldingTest { private Holding holding; private static final Date NOW = new Date(); private static final Date LATER = new Date(NOW.getTime() + 1); private static final String PATRON_ID1 = "12345"; private static final String PATRON_ID2 = "22345"; @Before public void initialize() { Book book = BookTest.CATCH22; int copyNumber = 1; holding = new Holding(book, copyNumber); } @Test public void create() { assertSame(BookTest.CATCH22, holding.getBook()); assertEquals(1, holding.getCopyNumber()); assertFalse(holding.isOnLoan()); } @Test public void checkout() { holding.checkout(NOW, PATRON_ID1); assertTrue(holding.isOnLoan()); assertEquals(NOW, holding.getLoanDate()); } @Test public void checkin() { holding.checkout(NOW, PATRON_ID1); holding.checkin(LATER); assertFalse(holding.isOnLoan()); } @Test public void placeHoldOnCheckedInHolding() { assertFalse(holding.isOnHold()); holding.placeHold(NOW, PATRON_ID1); assertTrue(holding.isOnHold()); } @Test public void rejectsDuplicateHolds() { holding.placeHold(NOW, PATRON_ID1); try { holding.placeHold(NOW, PATRON_ID2); fail(); } catch (HoldException expected) { assertTrue(holding.isOnHold()); } } @Test(expected = HoldException.class) public void holdOnCheckedOutBookRetainedOnCheckin() { holding.checkout(NOW, PATRON_ID2); holding.placeHold(NOW, PATRON_ID1); holding.checkin(LATER); assertTrue(holding.isOnHold()); holding.checkout(NOW, PATRON_ID2); } @Test public void releaseHold() { holding.placeHold(NOW, PATRON_ID1); holding.releaseAnyHold(); assertFalse(holding.isOnHold()); } @Test public void holdReleasedAfter3Days() { holding.placeHold(NOW, PATRON_ID1); holding.update(DateUtil.addDays(NOW, 1)); assertTrue(holding.isOnHold()); holding.update(DateUtil.addDays(NOW, 2)); assertTrue(holding.isOnHold()); holding.update(DateUtil.addDays(NOW, 3)); assertFalse(holding.isOnHold()); } @Test public void holdReleasedAfter3DaysAfterCheckin() { holding.checkout(NOW, PATRON_ID1); holding.placeHold(LATER, PATRON_ID1); Date checkinDate = DateUtil.addDays(NOW, 3); holding.update(checkinDate); assertTrue(holding.isOnHold()); holding.checkin(checkinDate); assertTrue(holding.isOnHold()); holding.update(DateUtil.addDays(checkinDate, 3)); assertFalse(holding.isOnHold()); } @Test public void releaseHoldHarmlessIfNoHolds() { holding.releaseAnyHold(); assertFalse(holding.isOnHold()); } @Test public void checkoutReleasesHold() { holding.placeHold(NOW, PATRON_ID1); holding.checkout(LATER, PATRON_ID1); assertFalse(holding.isOnHold()); } @Test public void rejectCheckoutForHoldByDifferentPatron() { holding.placeHold(NOW, PATRON_ID1); try { holding.checkout(NOW, PATRON_ID2); fail(); } catch (HoldException expected) { } } } // Holding.java import java.util.*; public class Holding { private final Book book; private final int copyNumber; private Date checkoutDate; private String holdPatron; private Date holdDate; public Holding(Book book, int copyNumber) { this.book = book; this.copyNumber = copyNumber; } public Book getBook() { return book; } public int getCopyNumber() { return copyNumber; } public boolean isOnLoan() { return checkoutDate != null; } public Date getLoanDate() { return checkoutDate; } public void checkout(Date date, String patronId) { if (isOnHold() && patronId != holdPatron) throw new HoldException(); releaseAnyHold(); checkoutDate = date; } public void checkin(Date date) { checkoutDate = null; } public void placeHold(Date date, String patronId) { if (isOnHold()) throw new HoldException(); this.holdPatron = patronId; holdDate = new Date(); } public boolean isOnHold() { return holdPatron != null; } public void releaseAnyHold() { holdPatron = null; } public void update(Date date) { if (isOnHold() && !isOnLoan() && DateUtil.daysBetween(holdDate, date) >= 3) releaseAnyHold(); } }
Page 2 of 4
This article was originally published on June 19, 2008