Validate state with class invariants

Class invariants are methods which check the validity of an object's state (its data). The idea is to define validation methods for fields, and to perform these validations whenever the fields change. As usual, this should be done without repeating any code.

An object's state may become invalid for various reasons:

An invalid argument is passed by the caller.
To ensure that the caller fulfills the requirements of a method or constructor, all arguments to non-private methods should be explicitly checked. An exception should be thrown if a problem is detected. A special case is deserialization, which should treat readObject like a constructor. (Assertions should not be used for these types of checks.)

The implementation of the class is defective.
As a defensive measure, a method which changes the state of an object can include an assertion at its end, to check that the object has indeed remained in a valid state. Here, such assertions verify correctness of internal implementation details - they do not check arguments.

Example 1

Resto is an immutable Model Object (MO). Its class invariant is defined by the validateState method. In this particular case, if validation fails a checked exception is thrown, and the end user is presented with their original input, along with associated error messages.

Such immutable classes represent the simplest case, since validation is performed only once, during construction (and deserialization, if necessary). By definition, an immutable object cannot change state after construction, so performing validations at other times in the object's life is never necessary.

package hirondelle.fish.main.resto;

import hirondelle.web4j.model.ModelCtorException;
import hirondelle.web4j.model.ModelUtil;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.security.SafeText;
import hirondelle.web4j.model.Decimal;
import static hirondelle.web4j.model.Decimal.ZERO;
import hirondelle.web4j.model.Check;
import hirondelle.web4j.model.Validator;
import static hirondelle.web4j.util.Consts.FAILS;

/** Model Object for a Restaurant. */
public final class Resto {

  /**
   Full constructor.
    
   @param aId underlying database internal identifier (optional) 1..50 characters
   @param aName of the restaurant (required), 2..50 characters
   @param aLocation street address of the restaurant (optional), 2..50 characters
   @param aPrice of the fish and chips meal (optional) $0.00..$100.00
   @param aComment on the restaurant in general (optional) 2..50 characters
  */
  public Resto(
    Id aId, SafeText aName, SafeText aLocation, Decimal aPrice, SafeText aComment
  ) throws ModelCtorException {
    fId = aId;
    fName = aName;
    fLocation = aLocation;
    fPrice = aPrice;
    fComment = aComment;
    validateState();
  }
  
  public Id getId() { return fId; }
  public SafeText getName() {  return fName; }
  public SafeText getLocation() {  return fLocation;  }
  public Decimal getPrice() { return fPrice; }
  public SafeText getComment() {  return fComment; }

  @Override public String toString(){
    return ModelUtil.toStringFor(this);
  }
  
  @Override public  boolean equals(Object aThat){
    Boolean result = ModelUtil.quickEquals(this, aThat);
    if (result ==  null) {
      Resto that = (Resto) aThat;
      result = ModelUtil.equalsFor(
        this.getSignificantFields(), that.getSignificantFields()
      );
    }
    return result;
  }
  
  @Override public int hashCode(){
    if (fHashCode == 0){
      fHashCode = ModelUtil.hashCodeFor(getSignificantFields());
    }
    return fHashCode;
  }
  
  // PRIVATE 
  private final Id fId;
  private final SafeText fName;
  private final SafeText fLocation;
  private final Decimal fPrice;
  private final SafeText fComment;
  private int fHashCode;
  
  private static final Decimal HUNDRED = Decimal.from("100");

  private void validateState() throws ModelCtorException {
    ModelCtorException ex = new ModelCtorException();
    if (FAILS == Check.optional(fId, Check.range(1,50))) {
      ex.add("Id is optional, 1..50 chars.");
    }
    if (FAILS == Check.required(fName, Check.range(2,50))) {
      ex.add("Restaurant Name is required, 2..50 chars.");
    }
    if (FAILS == Check.optional(fLocation, Check.range(2,50))) {
      ex.add("Location is optional, 2..50 chars.");
    }
    Validator[] priceChecks = {Check.range(ZERO, HUNDRED), Check.numDecimalsAlways(2)};
    if (FAILS == Check.optional(fPrice, priceChecks)) {
      ex.add("Price is optional, 0.00 to 100.00.");
    }
    if (FAILS == Check.optional(fComment, Check.range(2,50))) {
      ex.add("Comment is optional, 2..50 chars.");
    }
    if ( ! ex.isEmpty() ) throw ex;
  }
  
  private Object[] getSignificantFields(){
    return new Object[] {fName, fLocation, fPrice, fComment};
  }
}
 

Example 2

Here's an example of a mutable, Serializable class which defines class invariants.

Items to note:


import java.text.StringCharacterIterator;
import java.io.*;

/**
* In this style of implementation, both the entire state of the object
* and its individual fields are validated without duplicating any code.
*
* Argument validation usually has if's and thrown exceptions at the
* start of a method. Here, these are replaced with a simple
* call to validateXXX. Validation is separated cleanly from the
* regular path of execution, improving legibility.
* JDK 7+.
*/
public final class BankAccount implements Serializable {

   /**
   * @param firstName contains only letters, spaces, and apostrophes.
   * @param lastName contains only letters, spaces, and apostrophes.
   * @param accountNumber is non-negative.
   *
   * @throws IllegalArgumentException if any param does not comply.
   */
   public BankAccount(String firstName, String lastName, int accountNumber) {
      //don't call an overridable method in a constructor
      setFirstName(firstName);
      setLastName(lastName);
      setAccountNumber(accountNumber);
   }

   /**
   * All "secondary" constructors call the "primary" constructor, such that
   * validations are always performed.
   */
   public BankAccount() {
      this("FirstName", "LastName", 0);
   }

   public String getFirstName() {
     return firstName;
   }

   public String getLastName(){
    return lastName;
   }

   public int getAccountNumber() {
    return accountNumber;
   }

   /**
   * This method changes state internally, and may use an assert to
   * implement a post-condition on the object's state.
   */
   public void close(){
     //valid:
     accountNumber = 0;

     //this invalid value will fire the assertion:
     //fAccountNumber = -2;

     assert hasValidState(): this;
   }

   /**
   * Names must contain only letters, spaces, and apostrophes.
   *
   * @throws IllegalArgumentException if any param does not comply.
   */
   public void setFirstName(String newFirstName) {
      validateName(newFirstName);
      this.firstName = newFirstName;
   }

   /**
   * Names must contain only letters, spaces, and apostrophes.
   *
   * @throws IllegalArgumentException if any param does not comply.
   */
   public void setLastName (String newLastName) {
      validateName(newLastName);
      this.lastName = newLastName;
   }

   /**
   * AccountNumber must be non-negative.
   *
   * @throws IllegalArgumentException if any param does not comply.
   */
   public void setAccountNumber(int newAccountNumber) {
      validateAccountNumber(newAccountNumber);
      this.accountNumber = newAccountNumber;
   }

   /**
   * Can be used to easily pass object description to an assertion,
   * using a "this" reference.
   */
   public String toString(){
     final StringBuilder result = new StringBuilder();
     final String SPACE = " ";
     result.append(firstName);
     result.append(SPACE);
     result.append(lastName);
     result.append(SPACE);
     result.append(accountNumber);
     return result.toString();
   }

   // PRIVATE

   private String firstName;
   private String lastName;
   private int accountNumber;

   /**
   * Verify that all fields of this object take permissible values; that is,
   * this method defines the class invariant.
   *
   * Call after deserialization.
   * @throws IllegalArgumentException if any field takes an unpermitted value.
   */
   private void validateState() {
      validateAccountNumber(accountNumber);
      validateName(firstName);
      validateName(lastName);
   }

   /**
   * Return true if <code>validateState</code> does not throw
   * an IllegalArgumentException, otherwise return false.
   *
   * Call at the end of any public method which has changed
   * state (any "mutator" method). This is usually done in
   * an assertion, since it corresponds to a post-condition.
   * For example,
   * <pre>
   * assert hasValidState() : this;
   * </pre>
   * This method is provided since <code>validateState</code> cannot be used
   * in an assertion.
   */
   private boolean hasValidState() {
     boolean result = true;
     try {
       validateState();
     }
     catch (IllegalArgumentException ex){
       result = false;
     }
     return result;
   }

   /**
   * Ensure names contain only letters, spaces, and apostrophes.
   *
   * @throws IllegalArgumentException if argument does not comply.
   */
   private void validateName(String name){
     boolean nameHasContent = (name != null) && (!name.equals(""));
     if (!nameHasContent){
       throw new IllegalArgumentException("Names must be non-null and non-empty.");
     }
     StringCharacterIterator iterator = new StringCharacterIterator(name);
     char character =  iterator.current();
     while (character != StringCharacterIterator.DONE ){
       boolean isValidChar = (Character.isLetter(character)
                             || Character.isSpaceChar(character)
                             || character =='\'');
       if (isValidChar) {
         //do nothing
       }
       else {
         String message = "Names can contain only letters, spaces, and apostrophes.";
         throw new IllegalArgumentException(message);
       }
       character = iterator.next();
     }
  }

  /**
  * AccountNumber must be non-negative.
  *
  * @throws IllegalArgumentException if argument does not comply.
  */
   private void validateAccountNumber(int accountNumber){
      if (accountNumber < 0) {
        String message = "Account Number must be greater than or equal to 0.";
        throw new IllegalArgumentException(message);
      }
   }

   private static final long serialVersionUID = 7526472295622776147L;

   /**
   * Always treat de-serialization as a full-blown constructor, by
   * validating the final state of the de-serialized object.
   */
   private void readObject(ObjectInputStream inputStream)
                                throws ClassNotFoundException, IOException {
     //always perform the default de-serialization first
     inputStream.defaultReadObject();

     //ensure that object state has not been corrupted or
     //tampered with maliciously
     validateState();
  }

  /** Test harness.  */
  public static void main (String... args) {
    BankAccount account = new BankAccount("Joe", "Strummer", 532);
    //exercise specific validations.
    account.setFirstName("John");
    account.setAccountNumber(987);
    //exercise the post-condition assertion
    //requires enabled assertions: "java -ea"
    account.close();

    //exercise the serialization
    try (
      OutputStream file = new FileOutputStream("account.ser");
      OutputStream buffer = new BufferedOutputStream(file);
      ObjectOutput output = new ObjectOutputStream(buffer);
    ){
      output.writeObject(account);
    }
    catch(IOException exception){
      System.err.println(exception);
    }

    //exercise the deserialization
    try (
      InputStream file = new FileInputStream("account.ser");
      InputStream buffer = new BufferedInputStream(file);
      ObjectInput input = new ObjectInputStream(buffer);
    ){
      BankAccount recoveredAccount = (BankAccount)input.readObject();
      System.out.println("Recovered account: " + recoveredAccount);
    }
    catch(IOException | ClassNotFoundException exception ){
      System.err.println(exception);
    }
  }
} 

See Also :
Validate method arguments
Immutable objects
Implementing Serializable
Conditional compile
Assert is for private arguments only
Assert use cases
Model Objects