Book Read Free

Domain-Driven Design

Page 28

by Eric Evans


  Let’s apply this logic to our container-matching needs. When a SPECIFICATION is being changed, we would like to know if the proposed new SPEC meets all the conditions of the old one.

  New Spec → Old Spec

  That is, if the new spec is true, then the old spec is also true. Proving a logical implication in a general way is very difficult, but special cases can be easy. For example, particular parameterized SPECS can define their own subsumption rule.

  public class MinimumAgeSpecification {

  int threshold;

  public boolean isSatisfiedBy(Person candidate) {

  return candidate.getAge() >= threshold;

  }

  public boolean subsumes(MinimumAgeSpecification other) {

  return threshold >= other.getThreshold();

  }

  }

  A JUnit test might contain this:

  drivingAge = new MinimumAgeSpecification(16);

  votingAge = new MinimumAgeSpecification(18);

  assertTrue(votingAge.subsumes(drivingAge));

  Another practical special case, one suited to address the Container Specification problem, is a SPECIFICATION interface combining subsumption with the single logical operator AND.

  public interface Specification {

  boolean isSatisfiedBy(Object candidate);

  Specification and(Specification other);

  boolean subsumes(Specification other);

  }

  Proving implication with only the AND operator is simple:

  A AND B → A

  or, in a more complicated case:

  A AND B AND C → A AND B

  So if the Composite Specification is able to collect all the leaf SPECIFICATIONS that are “ANDed” together, then all we have to do is check that the subsuming SPECIFICATION has all the leaves that the subsumed one has, and maybe some extra ones as well—its leaves are a superset of the other SPEC’s set of leaves.

  public boolean subsumes(Specification other) {

  if (other instanceof CompositeSpecification) {

  Collection otherLeaves =

  (CompositeSpecification) other.leafSpecifications();

  Iterator it = otherLeaves.iterator();

  while (it.hasNext()) {

  if (!leafSpecifications().contains(it.next()))

  return false;

  }

  } else {

  if (!leafSpecifications().contains(other))

  return false;

  }

  return true;

  }

  This interaction could be enhanced to compare carefully chosen parameterized leaf SPECIFICATIONS and some other complications. Unfortunately, when OR and NOT are included, these proofs become much more involved. In most situations it is best to avoid such complexity by making a choice, either forgoing some of the operators or forgoing subsumption. If both are needed, consider carefully if the benefit is great enough to justify the difficulty.

  Angles of Attack

  This chapter has presented a raft of techniques to clarify the intent of code, to make the consequences of using it transparent, and to decouple model elements. Even so, this kind of design is difficult. You can’t just look at an enormous system and say, “Let’s make this supple.” You have to choose targets. Here are a couple of broad approaches, followed by an extended example showing how the patterns are fit together and used to take on a bigger design.

  Carve Off Subdomains

  You just can’t tackle the whole design at once. Pick away at it. Some aspects of the system will suggest approaches to you, and they can be factored out and worked over. You may see a part of the model that can be viewed as specialized math; separate that. Your application enforces complex rules restricting state changes; pull this out into a separate model or simple framework that lets you declare the rules. With each such step, not only is the new module clean, but also the part left behind is smaller and clearer. Part of what’s left is written in a declarative style, a declaration in terms of the special math or validation framework, or whatever form the subdomain takes.

  It is more useful to make a big impact on one area, making a part of the design really supple, than to spread your efforts thin. Chapter 15 discusses in more depth how to choose and manage subdomains.

  Draw on Established Formalisms, When You Can

  Creating a tight conceptual framework from scratch is something you can’t do every day. Sometimes you discover and refine one of these over the course of the life of a project. But you can often use and adapt conceptual systems that are long established in your domain or others, some of which have been refined and distilled over centuries. Many business applications involve accounting, for example. Accounting defines a well-developed set of ENTITIES and rules that make for an easy adaptation to a deep model and a supple design.

  There are many such formalized conceptual frameworks, but my personal favorite is math. It is surprising how useful it can be to pull out some twist on basic arithmetic. Many domains include math somewhere. Look for it. Dig it out. Specialized math is clean, combinable by clear rules, and people find it easy to understand. One example from my past is “Shares Math,” which will end this chapter.

  Example: Integrating the Patterns: Shares Math

  Chapter 8 told the story of a model breakthrough on a project to build a syndicated loan system. Now this example will go into detail, focusing on just one feature of a design comparable to the one on that project.

  One requirement of that application was that when the borrower makes a principal payment, the money is, by default, prorated according to the lenders’ shares in the loan.

  Initial Design for Payment Distribution

  As we refactor it, this code will get easier to understand, so don’t get stuck on this version.

  Figure 10.16

  public class Loan {

  private Map shares;

  //Accessors, constructors, and very simple methods are excluded

  public Map distributePrincipalPayment(double paymentAmount) {

  Map paymentShares = new HashMap();

  Map loanShares = getShares();

  double total = getAmount();

  Iterator it = loanShares.keySet().iterator();

  while(it.hasNext()) {

  Object owner = it.next();

  double initialLoanShareAmount = getShareAmount(owner);

  double paymentShareAmount =

  initialLoanShareAmount / total * paymentAmount;

  Share paymentShare =

  new Share(owner, paymentShareAmount);

  paymentShares.put(owner, paymentShare);

  double newLoanShareAmount =

  initialLoanShareAmount - paymentShareAmount;

  Share newLoanShare =

  new Share(owner, newLoanShareAmount);

  loanShares.put(owner, newLoanShare);

  }

  return paymentShares;

  }

  public double getAmount() {

  Map loanShares = getShares();

  double total = 0.0;

  Iterator it = loanShares.keySet().iterator();

  while(it.hasNext()) {

  Share loanShare = (Share) loanShares.get(it.next());

  total = total + loanShare.getAmount();

  }

  return total;

  }

  }

  Separating Commands and SIDE-EFFECT-FREE FUNCTIONS

  This design already has INTENTION-REVEALING INTERFACES. But the distributePaymentPrincipal() method does a dangerous thing: It calculates the shares for distribution and also modifies the Loan. Let’s refactor to separate the query from the modifier.

  Figure 10.17

  public void applyPrincipalPaymentShares(Map paymentShares) {

  Map loanShares = getShares();

  Iterator it = paymentShares.keySet().iterator();

  while(it.hasNext()) {

  Object lender = it.next();

  Share paymentShare = (Share) paymentShares.get(lender);

  Share loanShare = (Sh
are) loanShares.get(lender);

  double newLoanShareAmount = loanShare.getAmount() -

  paymentShare.getAmount();

  Share newLoanShare = new Share(lender, newLoanShareAmount);

  loanShares.put(lender, newLoanShare);

  }

  }

  public Map calculatePrincipalPaymentShares(double paymentAmount) {

  Map paymentShares = new HashMap();

  Map loanShares = getShares();

  double total = getAmount();

  Iterator it = loanShares.keySet().iterator();

  while(it.hasNext()) {

  Object lender = it.next();

  Share loanShare = (Share) loanShares.get(lender);

  double paymentShareAmount =

  loanShare.getAmount() / total * paymentAmount;

  Share paymentShare = new Share(lender, paymentShareAmount);

  paymentShares.put(lender, paymentShare);

  }

  return paymentShares;

  }

  Client code now looks like this:

  Map distribution =

  aLoan.calculatePrincipalPaymentShares(paymentAmount);

  aLoan.applyPrincipalPaymentShares(distribution);

  Not too bad. The FUNCTIONS have encapsulated a lot of complexity behind INTENTION-REVEALING INTERFACES. But the code does begin to multiply some when we add applyDrawdown(), calculateFeePaymentShares(), and so on. Each extension complicates the code and weighs it down. This might be a point where the granularity is too coarse. The conventional approach would be to break the calculation methods down into subroutines. That could well be a good step along the way, but we ultimately want to see the underlying conceptual boundaries and deepen the model. The elements of a design with such a CONCEPT-CONTOURING grain could be combined to produce the needed variations.

  Making an Implicit Concept Explicit

  There are enough pointers now to start probing for that new model. The Share objects are passive in this implementation, and they are being manipulated in complex, low-level ways. This is because most of the rules and calculations about shares don’t apply to single shares, but to groups of them. There is a missing concept: shares are related to each other as parts making up a whole. Making this concept explicit will let us express those rules and calculations more succinctly.

  Figure 10.18

  The Share Pie represents the total distribution of a specific Loan. It is an ENTITY whose identity is local within the AGGREGATE of the Loan. The actual distribution calculations can be delegated to the Share Pie.

  Figure 10.19

  public class Loan {

  private SharePie shares;

  //Accessors, constructors, and straightforward methods

  //are omitted

  public Map calculatePrincipalPaymentDistribution(

  double paymentAmount) {

  return getShares().prorated(paymentAmount);

  }

  public void applyPrincipalPayment(Map paymentShares) {

  shares.decrease(paymentShares);

  }

  }

  The Loan is simplified, and the Share calculations are centralized in a VALUE OBJECT focused on that responsibility. Still, the calculations haven’t really become more versatile or easier to use.

  Share Pie Becomes a VALUE OBJECT: Cascade of Insights

  Often, the hands-on experience of implementing a new design will trigger a new insight into the model itself. In this case, the tight coupling of the Loan and Share Pie seems to be obscuring the relationship of the Share Pie and the Shares. What would happen if we made Share Pie a VALUE OBJECT?

  This would mean that increase(Map) and decrease(Map) would not be allowed, because the Share Pie would have to be immutable. To change the Share Pie’s value, the whole Pie would have to be replaced. So you could have operations such as addShares(Map) that would return a whole new, larger Share Pie.

  Let’s go all the way to CLOSURE OF OPERATIONS. Instead of “increasing” a Share Pie or adding Shares to it, just add two Share Pies together: the result is the new, larger Share Pie.

  We can partially close the prorate() operation over Share Pie just by changing the return type. Renaming it to prorated() emphasizes its lack of side effects. “Shares Math” starts to take shape, initially with four operations.

  Figure 10.20

  We can make some well-defined ASSERTIONS about our new VALUE OBJECTS, the Share Pies. Each method means something.

  public class SharePie {

  private Map shares = new HashMap();

  //Accessors and other straightforward methods are omitted

  public double getAmount() {

  double total = 0.0;

  Iterator it = shares.keySet().iterator();

  while(it.hasNext()) { The whole is equal to the

  Share loanShare = getShare(it.next()); sum of its parts.

  total = total + loanShare.getAmount();

  }

  return total;

  }

  public SharePie minus(SharePie otherShares) {

  SharePie result = new SharePie();

  Set owners = new HashSet();

  owners.addAll(getOwners());

  owners.addAll(otherShares.getOwners()); The difference between

  Iterator it = owners.iterator(); two Pies is the difference

  while(it.hasNext()) { between each owner's

  Object owner = it.next(); share.

  double resultShareAmount = getShareAmount(owner) –

  otherShares.getShareAmount(owner);

  result.add(owner, resultShareAmount);

  }

  return result;

  }

  public SharePie plus(SharePie otherShares) { The combination of two

  //Similar to implementation of minus() Pies is the combination of

  } each owner's share.

  public SharePie prorated(double amountToProrate) {

  SharePie proration = new SharePie();

  double basis = getAmount(); An amount can be divided

  Iterator it = shares.keySet().iterator(); proportionately

  while(it.hasNext()) { among all shareholders.

  Object owner = it.next();

  Share share = getShare(owner);

  double proratedShareAmount =

  share.getAmount() / basis * amountToProrate;

  proration.add(owner, proratedShareAmount);

  }

  return proration;

  }

  }

  The Suppleness of the New Design

  At this point, the methods in the all-important Loan class could be as simple as this:

  public class Loan {

  private SharePie shares;

  //Accessors, constructors, and straightforward methods

  //are omitted

  public SharePie calculatePrincipalPaymentDistribution(

  double paymentAmount) {

  return shares.prorated(paymentAmount);

  }

  public void applyPrincipalPayment(SharePie paymentShares) {

  setShares(shares.minus(paymentShares));

  }

  Each of these short methods states its meaning. Applying a principal payment means that you subtract the payment from the loan, share by share. Distributing a principal payment is done by dividing the amount pro rata among the shareholders. The design of the Share Pie has allowed us to use a declarative style in the Loan code, producing code that begins to read like a conceptual definition of the business transaction, rather than a calculation.

  Other transaction types (too complicated to list before) can be declared easily now. For example, loan drawdowns are divided among lenders based on their shares of the Facility. The new draw-down is added to the outstanding Loan. In our new domain language:

  public class Facility {

  private SharePie shares;

  . . .

  public SharePie calculateDrawdownDefaultDistribution(

  double drawdownAmount) {

  return shares.prorated(drawdownAmount);
/>   }

  }

  public class Loan {

  . . .

  public void applyDrawdown(SharePie drawdownShares) {

  setShares(shares.plus(drawdownShares));

  }

  }

  To see the deviation of each lender from its agreed contribution, take the theoretical distribution of the outstanding Loan amount and subtract it from the Loan’s actual shares:

  SharePie originalAgreement =

  aFacility.getShares().prorated(aLoan.getAmount());

  SharePie actual = aLoan.getShares();

  SharePie deviation = actual.minus(originalAgreement);

  Certain characteristics of the Share Pie design make for this easy recombination and communication in the code.

  • Complex logic is encapsulated in specialized VALUE OBJECTS with SIDE-EFFECT-FREE FUNCTIONS. Most complex logic has been encapsulated in these immutable objects. Because Share Pies are VALUE OBJECTS, the math operations can create new instances, which we can use freely to replace outdated instances.

  None of the Share Pie methods causes any change to any existing object. This allows us to use plus(), minus(), and prorated() freely in intermediate calculations, combining them, expecting them to do what their names suggest, and nothing more. It also allows us to build analytical features based on the same methods. (Before, they could be called only when an actual distribution was made, because the data would change after each call.)

  • State-modifying operations are simple and characterized with ASSERTIONS. The high-level abstractions of Shares Math allow invariants of transactions to be written concisely in a declarative style. For example, the deviation is the actual pie minus the Loan amount prorated based on the Facility’s Share Pie.

  • Model concepts are decoupled; operations entangle a minimum of other types. Some methods on Share Pie exhibit CLOSURE OF OPERATIONS (the methods to add or subtract are closed under Share Pies). Others take simple amounts as arguments or return values; they are not closed, but they add little to the conceptual load. The Share Pie interacts closely with only one other class, Share. As a result, the Share Pie is self-contained, easily understood, easily tested, and easily combined to form declarative transactions. These properties were inherited from the math formalism.

 

‹ Prev