I CAME across this question on Stack Overflow asking for the best way to implement bi-directional associations while enforcing cardinality rules at the same time. The example given is a warehouse management application with the following business rules.
A single item can only be stored in one warehouse. A warehouse can store multiple items.
Seeing how the three approaches being considered by the poster were boilerplate, I decided to design an alternative solution.
This blog post shows that an apparently simple problem can yield an inadequate solution if addressed lightly and that thorough analysis yields the most robust design.
Before proceeding, let’s augment the information at hand with some assumptions, namely that the application should allow the user to:
- store items at a warehouse.
- remove items from a warehouse.
- list items stored at a warehouse.
- check where an item is stored.
- set a limit on the number of items that can be stored at a warehouse.
For the sake of simplicity, we consider the storage capacity to be based solely on quantity and not volume, and that it is not required to move items between warehouses.
Class model
Not much is said about what information should be kept on items, but we know that a warehouse needs to have a storage capacity. These are very straightforward requirements that are reflected in this simple model.
Warehouse
[Warehouse]
+ id
+ capacity
Item
[Item]
+ id
Both Warehouse and Item have an attribute id because each object needs to be uniquely identifiable. Warehouse has an attribute capacity to satisfy the business requirement of setting a limit on the number of stored items.
Notice that operations are not listed in the class model as we are still dealing with the static rules at this stage.
(NOTE: Object-oriented analysis and design is iterative. Here, we start with a static model and add to it as we proceed. By the time the state model is produced, behaviours will have become obvious, making it possible to add operations to our class model.)
Relationship model
The following diagram describes the bi-directional relationship between a warehouse and items. At the end of each relationship line, the cardinality is shown together with a short phrase that describes it.
1/is stored in
[Warehouse] ---------------------------- [Item]
stores/0..n
Despite its apparent simplicity, this diagram represents many of the business rules for the application, that is:
- A warehouse may be empty.
- A warehouse can store up to n items.
- An item can be stored in one and only one warehouse.
- An item is not tracked if it is not stored in a warehouse.
State model
Like the class and relationship models, the state model captures business rules, but instead of encoding static rules, it describes the dynamic rules that govern the application as its overall state changes during execution.
In state transition diagrams for Warehouse and Item below, states are denoted by [Sn XYZ], and events by (En XYZ/condition) next to lines that represent transitions between states and the conditions under which these are possible.
Warehouse
(E2 Store item)/(E3 Remove item)
+-------------+
| |
(E1 Store item) V |
o--------->[S1 Empty]---------------->[S2 Partially Full]----+
| A A | | A
| | | (E4 Remove item/ | | |
| | | last item) | | |
| | +--------------------------+ | |
| | | |
(E7 Store item/ | | (E8 Remove item/ | |
limit reached) | | last item) | |
V | | |
[S3 Full]<-----------------------------+ |
| (E5 Store item/limit reached) |
| |
+-------------------------------------+
(E6 Remove item)
The rules represented here are:
- A warehouse is empty by default (state S1).
- A warehouse becomes and remains partially full when an item is stored, but the capacity is not reached yet (state S2 triggered by event E2).
- A warehouse remains partially full when an item is removed as long as the last item is not removed (state S2 triggered by event E3).
- A warehouse becomes empty when the last item is removed (state S1 triggered by event E4).
- A warehouse becomes full when an item is stored and the capacity is reached (state S3 triggered by event E5).
- A warehouse becomes partially full when an item is removed as long as the last item is not removed (state S2 triggered by event E6).
- A warehouse becomes full from being empty when an item is added and the capacity is one (state S3 triggered by event E7).
- A warehouse becomes empty from being full when the only item is removed (state S1 triggered by event E8).
Item
o-------->[S1 Stored]
Usually, an object with a single state does not require a state transition diagram since there is no change in state, but we show one here to illustrate that the only rule applying to items is that they only exist in the stored state. (What this means in practice is, an Item is always instantiated with an association to a Warehouse as we will see shortly.)
Design
Now that the analysis is sufficiently detailed, we would normally proceed to the design. This is when you finalise the classes, attributes, and operations; map attributes to associations; and define the actions that make up your operations. But, for the purpose of this exercise, the design will be rolled into the next section about implementation.
Implementation
We’re now ready to implement our design in C#.
Notice that in the code, the state of objects is not evident as attributes or properties. The state we refer to in the context of object-oriented analysis and design is a conceptual state that does not always have one-to-one mapping with the physical counterpart. For example, in our case, a “full warehouse” does not translate into an attribute Full in Warehouse.
Item
The implementation of class Item is very simple.
public class Item
{
public Item(int id, Warehouse warehouse)
{
Id = id;
Warehouse = warehouse;
}
public int Id { get; private set; }
public Warehouse { get; private set; }
}
The constructor takes a Warehouse parameter because an association to a warehouse needs to be established from the moment the item is created so as to satisfy the business rule that an item only exists in the stored state.
Both properties Id and Warehouse are read-only because they cannot be changed during the lifetime of the instance, that is, the identifier cannot change and an item cannot be moved to another warehouse.
Warehouse
From the state model, it is clear that class Warehouse is more complex. We’ll develop it progressively, implementing the rules one at a time and explaining the reasoning along the way. (This is would normally be done in the design phase, but doing this way makes for a more interesting learning experience.)
The first one that we code is the constructor that creates an instance in the default state S1. (Let’s ignore the fact that we return a mutable IList to keep things simple.)
public class Warehouse
{
public Warehouse(int id, int capacity)
{
Id = id;
Capacity = capacity;
Items = new List<Item>();
}
public int Id { get; private set; }
public int Capacity { get; private set; }
public IList<Item> Items { get; private set; }
}
The trigger to state S2 is (E1 Store item), which, in implementation, translates to sending the message “store item” to an instance of Warehouse, so we add method StoreItem(Item) next.
public class Warehouse
{
public Warehouse(int id, int capacity)
{
Id = id;
Capacity = capacity;
Items = new List<Item>();
}
public int Id { get; private set; }
public int Capacity { get; private set; }
public IList<Item> Items { get; private set; }
public void StoreItem(Item item)
{
Items.Add(item);
}
}
The state model also tells us that an item cannot be stored in a full warehouse (that is, there is no change from state S3 with an event of type "store item"), which means that the code has to be changed to raise an exception if a message “store item” is received when in this state.
public class Warehouse
{
public Warehouse(int id, int capacity)
{
Id = id;
Capacity = capacity;
Items = new List<Item>();
}
public int Id { get; private set; }
public int Capacity { get; private set; }
public IList<Item> Items { get; private set; }
public void StoreItem(Item item)
{
if (Items.Count == Capacity)
{
throw new InvalidOperationException("Warehouse full");
}
Items.Add(item);
}
}
From either state S3 or state S2, an event of type “remove item” will cause the warehouse to go in state S2 if it is not the last item that is removed. At the same time, the model does not show a transition from state S1 for an event of type “remove item”, which means that such a message will result in an invalid state (that is, an exception will be raised). The code is changed again to reflect this.
public class Warehouse
{
public Warehouse(int id, int capacity)
{
Id = id;
Capacity = capacity;
Items = new List<Item>();
}
public int Id { get; private set; }
public int Capacity { get; private set; }
public IList<Item> Items { get; private set; }
public void StoreItem(Item item)
{
if (Items.Count == Capacity)
{
throw new InvalidOperationException("Warehouse full");
}
Items.Add(item);
}
public void RemoveItem(Item item)
{
if (Items.Count == 0)
{
throw new InvalidOperationException("Warehouse empty");
}
Items.Remove(item);
}
}
At this point, we’re done with Warehouse, so let’s review the code to make sure all the requirements have been addressed.
- We can store up to n items in a warehouse but cannot remove any item from an empty warehouse.
- We can specify in which warehouse an item is stored.
- We can list the items in a warehouse.
- We can tell in which warehouse an item is stored.
Usage
Although our two classes Warehouse and Item are now complete, it’s worth showing an example usage just so a final aspect of this design can be emphasised.
using System;
public class Example
{
public static void Main(string[] args)
{
Warehouse warehouse = new Warehouse(101, 5);
try
{
// Store an item in the warehouse
Item item = new Item(1001, warehouse);
warehouse.StoreItem(item);
// Remove the item from the warehouse
warehouse.RemoveItem(item);
item = null;
}
catch (InvalidOperationException ex)
{
System.out.println(ex.Message);
}
}
}
In the example, a Warehouse is instantiated with a capacity of five, and an Item is stored in then removed from it. This is simple enough and does not deserve elaboration. The interesting bit is how the concern posed in the Stack Overflow is seamlessly addressed here.
It turns out that we do not even need to do the clever assignments that were proposed as a possible approach by the poster and shown below.
class Warehouse {
private List- items;
public void RegisterItem(Item obj) {
if(obj.Warehouse != null)
throw new ArgumentException("Can only register un-owned item")
items.Add(obj);
obj.Warehouse = this;
}
}
Neither did we require using Warehouse and Item copy-instances to prevent circular references or using a third-party monitor.
Why does it work?
As our implementation stands, establishing the relationships while preserving the cardinality is inherent to the design. Although Warehouse and Item are linked by virtue of the association relationship, they are only loosely coupled in the sense that the behaviour and properties of one do not affect the other.
And, as to how we establish the relationship, this is the responsibility of neither class. If it was, that would make them coupled because each would need to know how the other works (as in the approach put forward in the Stack Overflow post). In this example, the instantiation of the Warehouse-Item relationship is taken care of by method Main(string[]), but in a real application, that would be encapsulated in a facade or a factory class.
Conclusion
As shown, thorough analysis of the requirements of an application and a structured design process yield the most concise solution. They protect us from boilerplate solutions and unwarranted concerns, such as that troubling the original poster on Stack Overflow. The process we followed during this exercise shows that ultimately the questions that need to be answered first and foremost are those related to the business rules, and that static and dynamic models are very effective at revealing the answers.