Introduction
In this post, we will look at some refactoring techniques using types. Types can be used to represent the domain in a fine grained, well defined way. Additionally, types can be used to incorporate business rules in a manner that ensures code correctness. This enables writing simple and elegant unit tests to ensure correctness of code.
Refactoring With Types
Recently, while reviewing code, I came across the following class,
public class OrderLine { private int quantity; private Double unitPrice; private Double listPrice; private Double tax; private Double charge; //Rest of the implementation }The code above is a classic example of a code smell called primitive obsession. All the above parameters are represented using numbers. However are they just numbers. Is UnitPrice interchangeable with ListPrice or Tax. In domain driven design these are indeed distinct things and not just numbers. Ideally, we would like to have specific types to represent these concepts. The first level of refactoring is to create simple wrapper types for these classes
public class ListPrice { private ListPrice() { } private @Getter Double listPrice; public ListPrice(Double listPrice) { setListPrice(listPrice); } private void setListPrice(Double listPrice) { Objects.requireNonNull(listPrice, "list price can not be null"); if (listPrice < 0) { throw new IllegalArgumentException("Invalid list price: "+listPrice); } this.listPrice = listPrice; } }
public class UnitPrice { private UnitPrice() { } private @Getter Double unitPrice; public unitPrice(Double unitPrice) { setUnitPrice(unitPrice); } private void setUnitPrice(Double unitPrice) { Objects.requireNonNull(unitPrice, "unit price can not be null"); if (unitPrice < 0) { throw new IllegalArgumentException("Invalid unit price: "+unitPrice); } this.unitPrice = unitPrice; } }This serves as a good starting point. We now have conceptual constructs for these. Any business rules which are required for a construct can now be wired within these constructs rather than being implemented in the container OrderLine class. However, if we observe, there is duplicate code to check that the listPrice and unitPrice should not be null or non-negative. This check would most probably be applied to quantity, tax and charge as well. Hence it makes sense to create a Type which represents a non-negative number concept.
public class NonNegativeDouble { private @Getter Double value; public NonNegativeDouble(Double value){ this.setValue(value); } private void setValue(Double value) { Objects.requireNonNull(value,"Value cannot be null"); if(value < 0){ throw new IllegalArgumentException("Value has to be positive"); } } }Now we can safely refactor the UnitPrice and ListPrice classes to use this new construct of a non-negative double.
public class UnitPrice { private UnitPrice() { } private @Getter NonNegativeDouble unitPrice; public UnitPrice(NonNegativeDouble unitPrice) { setUnitPrice(unitPrice); } private void setUnitPrice(NonNegativeDouble unitPrice) { this.unitPrice = unitPrice; } }A simple test to validate the non-negative constraints for UnitPrice
@Unroll class UnitPriceSpec extends Specification { def "#text creation of Unit Price object with value - (#unitPriceValue)"() { given: def unitPrice when: boolean isExceptionThrown = false try { unitPrice = new UnitPrice(new NonNegativeDouble(unitPriceValue)) } catch (Exception ex) { isExceptionThrown = true } then: assert isExceptionThrown == isExceptionExpected where: text | unitPriceValue | isExceptionExpected 'Valid' | 120 | false 'Valid' | 12.34 | false 'Valid' | 0.8989 | false 'Valid' | 12567652365.67667 | false 'Invalid' | 0 | false 'Invalid' | 0.00000 | false 'Invalid' | -23.5676 | true 'Invalid' | -23478687 | true 'Invalid' | null | true } }Although this showcases a simple use-case for refactoring using types, it applies to a lot of constructs which are modeled as primitive types, like, Email, Names, Currency, Ranges and Date and Time.
Refactoring - Using Types to make Illegal States unrepresentable
Another refactoring which provides a lot of value is to make illegal state unrepresentable in a domain model. As an example, consider the following java class.public class CustomerContact { private @Getter EmailContactInfo emailContactInfo; private @Getter PostalContactInfo postalContactInfo; public CustomerContact(EmailContactInfo emailContactInfo, PostalContactInfo postalContactInfo){ setEmailContactInfo(emailContactInfo); setPostalContactInfo(postalContactInfo); } private void setEmailContactInfo(EmailContactInfo emailContactInfo){ Objects.requireNonNull(emailContactInfo,"Email Contact Info cannot be null"); this.emailContactInfo = emailContactInfo; } private void setPostalContactInfo(PostalContactInfo postalContactInfo){ Objects.requireNonNull(postalContactInfo,"Postal Contact Info cannot be null"); this.postalContactInfo = postalContactInfo; } }
Based on the previous refactoring, we have already extracted the domain level constructs EmailContactInfo and PostalContactInfo. These are true domain level constructs as opposed to beings strings.
Lets assume a simple business rule, which states that
A customer contact must have either email contact information or postal contact information.
This implies that there should be at least one of either the EmailContactInfo or the CustomerContactInfo. Both can also be present. However, our current implementation requires both to be present.
In order to apply the business rule, a first attempt might look like this.
public class CustomerContact { private @Getter Optional emailContactInfo; private @Getter Optional postalContactInfo; public CustomerContact(PersonName name, Optional emailContactInfo, Optional postalContactInfo){ setEmailContactInfo(emailContactInfo); setPostalContactInfo(postalContactInfo); } private void setEmailContactInfo(Optional emailContactInfo){ this.emailContactInfo = emailContactInfo; } private void setPostalContactInfo(Optional postalContactInfo){ this.postalContactInfo = postalContactInfo; } }
Now, we have gone too far the other way. The rule requires that the CustomerContact should have at least one of email or postal contact. However, with the current implementation it is possible for the CustomerContact to not have any of them.
Simplifying the business rule leads to the following
Customer Contact = Email Contact or Postal Contact or Both Email and Postal Contact
In functional language, such conditions can be designed using sum types. However in languages like java, there is no first class support for these constructs. There are libraries like JavaSealedUnions which provide support for Sum and Union types in java.
Using JavaSealedUnions we can implement the business rule as follows
public abstract class CustomerContact implements Union2 { public abstract boolean valid(); public static CustomerContact email(String emailAddress) { return new EmailContact(emailAddress); } public static CustomerContact postal(String postalAddress) { return new PostalContact(postalAddress); } } class EmailContact extends CustomerContact { private final String emailAddress; EmailContact(String emailAddress) { this.emailAddress = emailAddress; } public boolean valid() { return /* some logic here */ } public void continued(Consumer continuationLeft, Consumer continuationRight) { continuationLeft.call(value); } public T join(Function mapLeft, Function mapRight) { return mapLeft.call(value); } } class PostalContact extends CustomerContact { private final String address; PostalContact(String address) { this.address = address; } public boolean valid() { return /* some logic here */ } public void continued(Consumer continuationLeft, Consumer continuationRight) { continuationRight.call(value); } public T join(Function mapLeft, Function mapRight) { return mapRight.call(value); } } // Example CustomerContact customerContact = getCustomerContact(); if (customerContact.valid()) { customerContact.continued(customerContactService::byEmail(), customerContactService::byPostalAddress()) }
This post shows some of the ways where thinking in terms of Types can help in having a cleaner design. It also helps to think in terms of Types to avoid having ambiguity around business rules. The approaches shown above can also be used in other scenarios to either capture allowed states or success and failure cases.
A few great posts for reference are
Top comments (0)