Model Objects:
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:
Integer) instead of primitive types (int)
Optional from one of its getters (the lone nullable field)
Comparable, so that callers can sort collections of these model objects
toString,
equals, and
hashCode methods
BigDecimal to model money, not a floating point type
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; } } }