Domain-Driven Design
Page 18
Without going into deep analysis, we can identify three user-level application functions, which we can assign to three application layer classes.
1. A Tracking Query that can access past and present handling of a particular Cargo
2. A Booking Application that allows a new Cargo to be registered and prepares the system for it
3. An Incident Logging Application that can record each handling of the Cargo (providing the information that is found by the Tracking Query)
These application classes are coordinators. They should not work out the answers to the questions they ask. That is the domain layer’s job.
Distinguishing ENTITIES and VALUE OBJECTS
Considering each object in turn, we’ll look for identity that must be tracked or a basic value that is represented. First we’ll go through the clear-cut cases and then consider the more ambiguous ones.
Customer
Let’s start with an easy one. A Customer object represents a person or a company, an entity in the usual sense of the word. The Customer object clearly has identity that matters to the user, so it is an ENTITY in the model. How to track it? Tax ID might be appropriate in some cases, but an international company could not use that. This question calls for consultation with a domain expert. We discuss the problem with a businessperson in the shipping company, and we discover that the company already has a customer database in which each Customer is assigned an ID number at first sales contact. This ID is already used throughout the company; using the number in our software will establish continuity of identity between those systems. It will initially be a manual entry.
Cargo
Two identical crates must be distinguishable, so Cargo objects are ENTITIES. In practice, all shipping companies assign tracking IDs to each piece of cargo. This ID will be automatically generated, visible to the user, and in this case, probably conveyed to the customer at booking time.
Handling Event and Carrier Movement
We care about such individual incidents because they allow us to keep track of what is going on. They reflect real-world events, which are not usually interchangeable, so they are ENTITIES. Each Carrier Movement will be identified by a code obtained from a shipping schedule.
Another discussion with a domain expert reveals that Handling Events can be uniquely identified by the combination of Cargo ID, completion time, and type. For example, the same Cargo cannot be both loaded and unloaded at the same time.
Location
Two places with the same name are not the same. Latitude and longitude could provide a unique key, but probably not a very practical one, since those measurements are not of interest to most purposes of this system, and they would be fairly complicated. More likely, the Location will be part of a geographical model of some kind that will relate places according to shipping lanes and other domain-specific concerns. So an arbitrary, internal, automatically generated identifier will suffice.
Delivery History
This is a tricky one. Delivery Histories are not interchangeable, so they are ENTITIES. But a Delivery History has a one-to-one relationship with its Cargo, so it doesn’t really have an identity of its own. Its identity is borrowed from the Cargo that owns it. This will become clearer when we model the AGGREGATES.
Delivery Specification
Although it represents the goal of a Cargo, this abstraction does not depend on Cargo. It really expresses a hypothetical state of some Delivery History. We hope that the Delivery History attached to our Cargo will eventually satisfy the Delivery Specification attached to our Cargo. If we had two Cargoes going to the same place, they could share the same Delivery Specification, but they could not share the same Delivery History, even though the histories start out the same (empty). Delivery Specifications are VALUE OBJECTS.
Role and Other Attributes
Role says something about the association it qualifies, but it has no history or continuity. It is a VALUE OBJECT, and it could be shared among different Cargo/Customer associations.
Other attributes such as time stamps or names are VALUE OBJECTS.
Designing Associations in the Shipping Domain
None of the associations in the original diagram specified a traversal direction, but bidirectional associations are problematic in a design. Also, traversal direction often captures insight into the domain, deepening the model itself.
If the Customer has a direct reference to every Cargo it has shipped, it will become cumbersome for long-term, repeat Customers. Also, the concept of a Customer is not specific to Cargo. In a large system, the Customer may have roles to play with many objects. Best to keep it free of such specific responsibilities. If we need the ability to find Cargoes by Customer, this can be done through a database query. We’ll return to this issue later in this chapter, in the section on REPOSITORIES.
If our application were tracking the inventory of ships, traversal from Carrier Movement to Handling Event would be important. But our business needs to track only the Cargo. Making the association traversable only from Handling Event to Carrier Movement captures that understanding of our business. This also reduces the implementation to a simple object reference, because the direction with multiplicity was disallowed.
The rationale behind the remaining decisions is explained in Figure 7.2, on the next page.
Figure 7.2. Traversal direction has been constrained on some associations.
There is one circular reference in our model: Cargo knows its Delivery History, which holds a series of Handling Events, which in turn point back to the Cargo. Circular references logically exist in many domains and are sometimes necessary in design as well, but they are tricky to maintain. Implementation choices can help by avoiding holding the same information in two places that must be kept synchronized. In this case, we can make a simple but fragile implementation (in Java) in an initial prototype, by giving Delivery History a List object containing Handling Events. But at some point we’ll probably want to drop the collection in favor of a database lookup with Cargo as the key. This discussion will be taken up again when choosing REPOSITORIES. If the query to see the history is relatively infrequent, this should give good performance, simplify maintenance, and reduce the overhead of adding Handling Events. If this query is very frequent, then it is better to go ahead and maintain the direct pointer. These design trade-offs balance simplicity of implementation against performance. The model is the same; it contains the cycle and the bidirectional association.
AGGREGATE Boundaries
Customer, Location, and Carrier Movement have their own identities and are shared by many Cargoes, so they must be the roots of their own AGGREGATES, which contain their attributes and possibly other objects below the level of detail of this discussion. Cargo is also an obvious AGGREGATE root, but where to draw the boundary takes some thought.
The Cargo AGGREGATE could sweep in everything that would not exist but for the particular Cargo, which would include the Delivery History, the Delivery Specification, and the Handling Events. This fits for Delivery History. No one would look up a Delivery History directly without wanting the Cargo itself. With no need for direct global access, and with an identity that is really just derived from the Cargo, the Delivery History fits nicely inside Cargo’s boundary, and it does not need to be a root. The Delivery Specification is a VALUE OBJECT, so there are no complications from including it in the Cargo AGGREGATE.
The Handling Event is another matter. Previously we have considered two possible database queries that would search for these: one, to find the Handling Events for a Delivery History as a possible alternative to the collection, would be local within the Cargo AGGREGATE; the other would be used to find all the operations to load and prepare for a particular Carrier Movement. In the second case, it seems that the activity of handling the Cargo has some meaning even when considered apart from the Cargo itself. So the Handling Event should be the root of its own AGGREGATE.
Figure 7.3. AGGREGATE boundaries imposed on the model. (Note:
An ENTITY outside a drawn boundary is implied to be the root of its own AGGREGATE.)
Selecting REPOSITORIES
There are five ENTITIES in the design that are roots of AGGREGATES, so we can limit our consideration to these, since none of the other objects is allowed to have REPOSITORIES.
To decide which of these candidates should actually have a REPOSITORY, we must go back to the application requirements. In order to take a booking through the Booking Application, the user needs to select the Customer(s) playing the various roles (shipper, receiver, and so on). So we need a Customer Repository. We also need to find a Location to specify as the destination for the Cargo, so we create a Location Repository.
The Activity Logging Application needs to allow the user to look up the Carrier Movement that a Cargo is being loaded onto, so we need a Carrier Movement Repository. This user must also tell the system which Cargo has been loaded, so we need a Cargo Repository.
Figure 7.4. REPOSITORIES give access to selected AGGREGATE roots.
For now there is no Handling Event Repository, because we decided to implement the association with Delivery History as a collection in the first iteration, and we have no application requirement to find out what has been loaded onto a Carrier Movement. Either of these reasons could change; if they did, then we would add a REPOSITORY.
Walking Through Scenarios
To cross-check all these decisions, we have to constantly step through scenarios to confirm that we can solve application problems effectively.
Sample Application Feature: Changing the Destination of a Cargo
Occasionally a Customer calls up and says, “Oh no! We said to send our cargo to Hackensack, but we really need it in Hoboken.” We are here to serve, so the system is required to provide for this change.
Delivery Specification is a VALUE OBJECT, so it would be simplest to just to throw it away and get a new one, then use a setter method on Cargo to replace the old one with the new one.
Sample Application Feature: Repeat Business
The users say that repeated bookings from the same Customers tend to be similar, so they want to use old Cargoes as prototypes for new ones. The application will allow them to find a Cargo in the REPOSITORY and then select a command to create a new Cargo based on the selected one. We’ll design this using the PROTOTYPE pattern (Gamma et al. 1995).
Cargo is an ENTITY and is the root of an AGGREGATE. Therefore, it must be copied carefully; we need to consider what should happen to each object or attribute enclosed by its AGGREGATE boundary. Let’s go over each one:
• Delivery History: We should create a new, empty one, because the history of the old one doesn’t apply. This is the usual case with ENTITIES inside the AGGREGATE boundary.
• Customer Roles: We should copy the Map (or other collection) that holds the keyed references to Customers, including the keys, because they are likely to play the same roles in the new shipment. But we have to be careful not to copy the Customer objects themselves. We must end up with references to the same Customer objects as the old Cargo object referenced, because they are ENTITIES outside the AGGREGATE boundary.
• Tracking ID: We must provide a new Tracking ID from the same source as we would when creating a new Cargo from scratch.
Notice that we have copied everything inside the Cargo AGGREGATE boundary, we have made some modifications to the copy, but we have affected nothing outside the AGGREGATE boundary at all.
Object Creation
FACTORIES and Constructors for Cargo
Even if we have a fancy FACTORY for Cargo, or use another Cargo as the FACTORY, as in the “Repeat Business” scenario, we still have to have a primitive constructor. We would like the constructor to produce an object that fulfills its invariants or at least, in the case of an ENTITY, has its identity intact.
Given these decisions, we might create a FACTORY method on Cargo such as this:
public Cargo copyPrototype(String newTrackingID)
Or we might make a method on a standalone FACTORY such as this:
public Cargo newCargo(Cargo prototype, String newTrackingID)
A standalone FACTORY could also encapsulate the process of obtaining a new (automatically generated) ID for a new Cargo, in which case it would need only one argument:
public Cargo newCargo(Cargo prototype)
The result returned from any of these FACTORIES would be the same: a Cargo with an empty Delivery History, and a null Delivery Specification.
The two-way association between Cargo and Delivery History means that neither Cargo nor Delivery History is complete without pointing to its counterpart, so they must be created together. Remember that Cargo is the root of the AGGREGATE that includes Delivery History. Therefore, we can allow Cargo’s constructor or FACTORY to create a Delivery History. The Delivery History constructor will take a Cargo as an argument. The result would be something like this:
public Cargo(String id) {
trackingID = id;
deliveryHistory = new DeliveryHistory(this);
customerRoles = new HashMap();
}
The result is a new Cargo with a new Delivery History that points back to the Cargo. The Delivery History constructor is used exclusively by its AGGREGATE root, namely Cargo, so that the composition of Cargo is encapsulated.
Adding a Handling Event
Each time the cargo is handled in the real world, some user will enter a Handling Event using the Incident Logging Application.
Every class must have primitive constructors. Because the Handling Event is an ENTITY, all attributes that define its identity must be passed to the constructor. As discussed previously, the Handling Event is uniquely identified by the combination of the ID of its Cargo, the completion time, and the event type. The only other attribute of Handling Event is the association to a Carrier Movement, which some types of Handling Events don’t even have. A basic constructor that creates a valid Handling Event would be:
public HandlingEvent(Cargo c, String eventType, Date timeStamp) {
handled = c;
type = eventType;
completionTime = timeStamp;
}
Nonidentifying attributes of an ENTITY can usually be added later. In this case, all attributes of the Handling Event are going to be set in the initial transaction and never altered (except possibly for correcting a data-entry error), so it could be convenient, and make client code more expressive, to add a simple FACTORY METHOD to Handling Event for each event type, taking all the necessary arguments. For example, a “loading event” does involve a Carrier Movement:
public static HandlingEvent newLoading(
Cargo c, CarrierMovement loadedOnto, Date timeStamp) {
HandlingEvent result =
new HandlingEvent(c, LOADING_EVENT, timeStamp);
result.setCarrierMovement(loadedOnto);
return result;
}
The Handling Event in the model is an abstraction that might encapsulate a variety of specialized Handling Event classes, ranging from loading and unloading to sealing, storing, and other activities not related to Carriers. They might be implemented as multiple subclasses or have complicated initialization—or both. By adding FACTORY METHODS to the base class (Handling Event) for each type, instance creation is abstracted, freeing the client from knowledge of the implementation. The FACTORY is responsible for knowing what class was to be instantiated and how it should be initialized.
Unfortunately, the story isn’t quite that simple. The cycle of references, from Cargo to Delivery History to History Event and back to Cargo, complicates instance creation. The Delivery History holds a collection of Handling Events relevant to its Cargo, and the new object must be added to this collection as part of the transaction. If this back-pointer were not created, the objects would be inconsistent.
Figure 7.5. Adding a Handling Event requires inserting it into a Delivery History.
Creation of the back-pointer could be encapsulated in the FACTORY
(and kept in the domain layer where it belongs), but now we’ll look at an alternative design that eliminates this awkward interaction altogether.
Pause for Refactoring: An Alternative Design of the Cargo AGGREGATE
Modeling and design is not a constant forward process. It will grind to a halt unless there is frequent refactoring to take advantage of new insights to improve the model and the design.
By now, there are a couple of cumbersome aspects to this design, although it does work and it does reflect the model. Problems that didn’t seem important when starting the design are beginning to be annoying. Let’s go back to one of them and, with the benefit of hindsight, stack the design deck in our favor.
The need to update Delivery History when adding a Handling Event gets the Cargo AGGREGATE involved in the transaction. If some other user was modifying Cargo at the same time, the Handling Event transaction could fail or be delayed. Entering a Handling Event is an operational activity that needs to be quick and simple, so an important application requirement is the ability to enter Handling Events without contention. This pushes us to consider a different design.
Replacing the Delivery History’s collection of Handling Events with a query would allow Handling Events to be added without raising any integrity issues outside its own AGGREGATE. This change would enable such transactions to complete without interference. If there are a lot of Handling Events being entered and relatively few queries, this design is more efficient. In fact, if a relational database is the underlying technology, a query was probably being used under the covers anyway to emulate the collection. Using a query rather than a collection would also reduce the difficulty of maintaining consistency in the cyclical reference between Cargo and Handling Event.