Model Objects

The term Model Object is an informal term, with no widely accepted definition. Here, Model Objects (MOs) refer to data-centric classes which encapsulate closely related items.

Model Objects:

Should Model Objects follow the JavaBeans conventions?

Not necessarily. In fact, some argue that the JavaBeans style is to be avoided as a general model for Model Objects.

Should Model Objects be immutable?

Given the deep simplicity of immutable objects, some prefer to design their Model Objects as immutable. However, when the underlying data changes, a new object must be created, instead of simply calling a setXXX method on an existing object. Some argue that this penalty is too high, while others argue that it is a micro-optimization - especially in cases where the data is "read-mostly", and the state of corresponding Model Objects changes only rarely.

Implementing Model Objects as immutable seems particularly natural in web applications. There, Model Objects are most commonly placed in request scope, not session scope. In this case, there is no long-lived object for the user to directly alter, so the Model Object can be immutable.

Example

This model object represents a telescope. Note that this implementation:

It's important to know that some frameworks impose a specific policy on how data-validation is done. Unfortunately, there's a common and unfortunate tradition of frameworks pushing you towards separating data and data-validation into separate classes. If this bothers you, you'll have to decide how to handle that.

import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsLast;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

/**
 An example of a fairly robust implementation of a Model object.
 
 @since Java 8  
*/
public final class Telescope implements Comparable<Telescope>{
  
  /** Design of a telescope's optics */
  enum Design {
    NEWTONIAN, REFRACTOR, SCHMIDT_CASSEGRAIN
  }
  
  /**
   Constructor that validates all data.
   
   If one or more problems are found, then a checked Exception is thrown. 
   Furthermore, the details of each problem found will be available 
   via Throwable.getSuppressed(). This lets the caller show all 
   details to the end user. 
   
   <P>WARNING: some frameworks force you into putting such 
   validation-code outside the Model Object. That's distasteful. 
   The fundamental idea of object programming is to UNITE data 
   and the code that acts closely upon it, not to separate them. 

   @param id database identifier. Can be empty, but never null. 
    A new item that has not yet been saved to the database will 
    have an empty id.
   @param name brand name of the scope; must have visible content 
   @param cost in dollars, greater than or equal to 0; nullable
   @param datePurchased, in range 1900-01-01..2099-12-31
   @param aperture in millimeters, in range 0..10000
   @param design optical design of the scope
  */
  public Telescope(
    String id, String name, BigDecimal cost, LocalDate datePurchased, 
    Double aperture, Design design
  ) throws Exception {
    this.id = id; 
    this.name = name;
    this.cost = cost; 
    this.datePurchased = datePurchased;
    this.aperture = aperture;
    this.design = design;
    validate();
  }
  
  /** 
   In the case of new objects which don't yet have an id, this method 
   will return an empty String. 
  */
  public String getId() {
    return id;
  }
  
  public String getName() { 
    return name; 
  }
  
  /** Returns an Optional because the field may be null. */
  public Optional<BigDecimal> getCost() { 
    return Optional.ofNullable(cost); 
  }
  
  public LocalDate getDatePurchased() { 
    return datePurchased; 
  }
  
  public Double getAperture() { 
    return aperture; 
  }
  
  public Design getDesign() { 
    return design; 
  }

  @Override public boolean equals(Object aThat) {
    //unusual: multiple return statements
    if (this == aThat) return true;
    if (!(aThat instanceof Telescope)) return false;
    Telescope that = (Telescope)aThat;
    for(int i = 0; i < this.getSigFields().length; ++i){
      if (!Objects.equals(this.getSigFields()[i], that.getSigFields()[i])){
        return false; 
      }
    }
    return true;
  }  
  
  @Override public int hashCode() {
    return Objects.hash(getSigFields());
  }

  /** Intended for debugging only. */
  @Override public String toString() {
    return toStringUtil(this, getSigFields());
  }
  
  /** 
   Implementing compareTo is only needed if you need to sort collections
   of these objects. 
  */
  @Override public int compareTo(Telescope that) {
    int result = COMPARATOR.compare(this, that);
    //optional: you may want to include this assertion (at least during development)
    //note that assertions are disabled by default
    if (result == 0) {
      assert this.equals(that) : 
        this.getClass().getSimpleName() + ": compareTo inconsistent with equals."
      ;
    }
    return result;
  }
  
  // PRIVATE 

  /** 
   Database identifier (not a business identifier).
   New items, that don't yet have an id assigned, have 
   this field as an empty String (not null).
  */
  private final String id;
  
  private final String name;
  
  /**
   This is the only nullable field in this class. 
   Nullable data is very common!
   Not a floating point type, since it's money. 
  */
  private final BigDecimal cost; 
  
  /** java.util.Date is obsolete and of low quality; avoid it in new code. */
  private final LocalDate datePurchased; 
  
  private final Double aperture;
  
  private final Design design;
  
  /** 
   The id is left out! The id is analogous to an object's identity, and is 
   treated here as NOT forming part of the object's core state (its data).
   IMPORTANT: equals and hashCode both call this method; they need to 
   talk to the same fields. In addition, compareTo uses the same fields
   too (without calling this method, however).
  */
  private Object[] getSigFields() {
    //optimize: start with items that are most likely to differ
    return new Object[] {
      name, cost, datePurchased, aperture, design
    };
  }

  /** Static: avoid creating this object every time a comparison is made.*/
  private static Comparator<Telescope> COMPARATOR = getComparator();
  
  /**
   Note the 'thenComparing' chain: when comparing, the implementation goes 
   to the next level of comparison ONLY IF the previous level has 
   returned '0' (equal).
   
   <P>Note that Telescope::getCost can't be used below, since it returns 
   an Optional, and the Comparator methods don't play well with Optional.
   Choice: either use a lambda expression to reference the data directly, 
   or define a private method (getPlainCost()) to return the nullable object.
  */
  private static Comparator<Telescope> getComparator(){
    //use the same fields used by the equals method, if at all possible!
    Comparator<Telescope> result = 
      comparing(Telescope::getName)
      .thenComparing(Telescope::getPlainCost, nullsLast(naturalOrder()))
      /*.thenComparing(t -> t.cost, nullsLast(naturalOrder()))*/ //alternative form
      .thenComparing(Telescope::getDatePurchased)
      .thenComparing(Telescope::getAperture)
      .thenComparing(Telescope::getDesign)
    ;
    return result;
  }
  
  /** 
   Created only so that a method reference expression (instead a lambda expression) 
   can be used in the getComparator() method above. 
   Not needed if you want to use a lambda expression!
  */
  private BigDecimal getPlainCost() { 
    return cost; 
  }
 
  /**
   Validate all of the data passed to the constructor.
   Throws an Exception if one or more problems are found.
   (You may want to define a new Exception type  
   to represent this very specific kind of error.)
   
   All of the constraints are defined by the constructor's javadoc (above).
   You shouldn't repeat those constraints here.
  */
  private void validate() throws Exception {
    List<String> errors = new ArrayList<>();
    ensureNotNull(id, "Id is null", errors);
    
    if (!hasContent(name)) {
      errors.add("Name has no content.");
    }
    
    if (cost != null) { 
      if (cost.compareTo(BigDecimal.ZERO) < 0) {
        errors.add("Cost cannot be negative.");
      }
    }
    
    boolean passes = ensureNotNull(datePurchased, "Date purchased is null.", errors);
    if (passes) {
      LocalDate START = LocalDate.of(1900, 1, 1);
      LocalDate END = LocalDate.of(2099, 12, 31);
      if (datePurchased.isBefore(START) || datePurchased.isAfter(END)) {
        errors.add("Date purchased is outside the normal range: " + START + ".." + END);
      }
    }
    
    passes = ensureNotNull(aperture, "Aperture is null", errors);
    if (passes) {
      Double START = 0.0;
      Double END = 10000.0;
      if (aperture.compareTo(START) < 0 || aperture.compareTo(END) > 0) {
        errors.add("Aperture is outside the normal range: " + START + ".." + END) ;
      }
    }
    
    ensureNotNull(design, "Design is null", errors);
    
    if (!errors.isEmpty()) {
      Exception ex = new Exception();
      for(String error: errors) {
        ex.addSuppressed(new Exception(error));
      }
      throw ex;
    }
  }
  
  /** Returns true only if the field passes the test, and is NOT null. */
  private boolean ensureNotNull(Object field, String errorMsg, List<String> errors) {
    boolean result = true;
    if (field == null) {
      errors.add(errorMsg);
      result = false;
    }
    return result;
  }
  
  /** 
   WARNING: This method is extremely common, and should be in a utility class.
   (It really should be in the JDK, as a static method of the String class.)
  */
  private boolean hasContent(String string) {
    return (string != null && string.trim().length() > 0);
  }
 
  /** 
   There's a lot of variation in how you might want to implement toString().
   Since the output is almost always intended only for debugging, it's 
   usually a matter of taste.
   
   <P>This implementation has the defect that the names of fields aren't included 
   in the output.
   
   <P>Example output for this class:<br>
   <pre>Telescope: [Meade 100.00 2018-09-01 200.0 REFRACTOR]</pre> 
   
   @param fields the fields to be included in the output 
   @param thing the object itself (which provides the class name)
   @return text describing the object, suitable only for logging and debugging
  */
  private String toStringUtil(Object thing, Object[] fields) {
    String result = "";
    for(Object field : fields) {
      result = result + " " + Objects.toString(field); //null-friendly
    }
    return thing.getClass().getSimpleName() + ": ["+ result.trim() + "]";
  }
} 

Example

Comment represents a comment posted to a message board. Its implementation follows the Immutable Object pattern. Comment provides the usual getXXX methods. Note that, in this case, no defensive copy is needed for the date-time field, since LocalDateTime is immutable. It also implements the toString, equals, and hashCode methods.

The constructor is responsible for establishing the class invariant, and performs Model Object validation.

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/** 
 Comment posted by a user.
 This class is immutable.
*/
public final class Comment { 

  /**
    Constructor.
    
    Note that a checked exception is thrown if a problem is found.
    Each problem is added as a <em>suppressed</em> exception to the 
    returned exception. (Suppressed exceptions are used here simply 
    as way to return a list of problems to the caller.)
      
    @param userName identifies the logged-in user posting the comment,
    must have content. 
    @param body the comment, must have content.
    @param dateTime date and time when the message was posted.
   */
   public Comment(
     String userName, String body, LocalDateTime dateTime
   ) throws Exception {
     this.userName = userName;
     this.body = body;
     this.dateTime = dateTime;
     validateState();
   }
  
   /** Return the logged-in user name passed to the constructor. */
   public String getUserName() {
     return userName;
   }
  
   /** Return the body of the message passed to the constructor. */
   public String getBody() {
     return body;
   }
  
   /** Return the creation date-time passed to the constructor.  */
   public LocalDateTime getDateTime() {
     return dateTime;
   }
  
   /** Intended for debugging only. */
   @Override public String toString() {
     return "Comment date:" + dateTime + " name:" + userName + " body:" + body;
   }
  
   @Override public boolean equals(Object aThat) {
     if (this == aThat) return true;
     if (!(aThat instanceof Comment)) return false;
     Comment that = (Comment)aThat;
     for(int i = 0; i < this.getSigFields().length; ++i){
       if (!Objects.equals(this.getSigFields()[i], that.getSigFields()[i])){
         return false;
       }
     }
     return true;
   }   
  
   @Override public int hashCode() {
     return Objects.hash(getSigFields());
   }
  
   // PRIVATE // 
   private final String userName;
   private final String body;
   private final LocalDateTime dateTime;

   /** The 'significant' fields attached to this object. */
   private Object[] getSigFields(){
     //small optimization: in the array, the things that 
     //are most likely to differ are placed first
     return new Object[] {body, dateTime, userName};
   }

   /** 
    This kind of common validation, if defined in your code,
    should be defined only once in your app, and not embedded 
    in every Model Object (don't-repeat-yourself rule). 
   */
   private boolean isBlank(String text) {
     return text == null || text.trim().length() == 0;
   }
   
   private void validateState() throws Exception {
     List<String> errors = new ArrayList<>(); 
     if (dateTime == null){
       errors.add("DateTime cannot be null.");
     }
     if (isBlank(userName)) {
       errors.add("UserName must have content.");
     }
     if (isBlank(body)) {
       errors.add("Comment body must have content.");
     }
     
     if  (!errors.isEmpty()) {
       Exception ex = new Exception("Errors found in constructing a Comment.");
       for (String error : errors) {
         ex.addSuppressed(new Exception(error));
       }
       throw ex;
     }
   }
}
 

See Also :
Validate state with class invariants
Immutable objects
Use a testing framework (JUnit)
Data access objects
Avoid JavaBeans style of construction
Use Model View Controller framework
Validation belongs in a Model Object
Consider wrapper classes for optional data
Return Optional not null
Implementing toString
Implementing equals
Implementing hashCode
Representing money