Mario
Posted on November 29, 2023
Preface
You are a software developer who has joined a new project about a library where you have been given a set of requirements that the project must comply. This project is a Java application that you need to code using the latest super Java technologies.
Diving in
We start by reading the requirements and trying to understand the concepts, functions, data...etc.
Well, we are no strangers to concepts like book cover, ISBN or book name, but when you code, these concepts are not present from scratch. So, like we would do when we start a project, we create a new project, we create packages, we create classes... but this time we would like to focus on domain driven design.
While coding in Java, we will create a record called ´Book´ where all the attributes will be placed, so that we can instantiate books easily.
public record Book(String ISBN, String name, String description, String authorName, String bookCover) {
}
class BookTest {
@Test
void createBook() {
Book elQuixote = new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
}
}
This approach may seem simple and good. However, when we start thinking... Can we create a Book with incorrect parameters? It is definitely possible using only the new Book(null, null, null, null, null)
constructor. Oh, the million dollar mistake. Easy solution, but not pretty (java stuff):
import java.util.Objects;
public record Book(String ISBN, String name, String description, String authorName, String bookCover) {
public Book {
Objects.requireNotNull(ISBN);
Objects.requireNotNull(name);
Objects.requireNotNull(description);
Objects.requireNotNull(authorName);
Objects.requireNotNull(bookCover);
}
}
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
}
@Test
void cannotCreateBookWithNullParameters() {
assertThrows(NullPointerException.class, () -> new Book(null, null, null, null, null));
}
}
With those requireNotNull
we are safe because no null parameter can be injected into the constructor... Wait, are we completely safe?
import java.util.Objects;
public record Book(String ISBN, String name, String description, String authorName, String bookCover) {
public Book {
Objects.requireNotNull(ISBN);
Objects.requireNotNull(name);
Objects.requireNotNull(description);
Objects.requireNotNull(authorName);
Objects.requireNotNull(bookCover);
}
}
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
// WATCH OUT
final Book otherQuixoteBook = new Book("coolISBN", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(otherQuixoteBook);
}
@Test
void cannotCreateBookWithNullParameters() {
assertThrows(NullPointerException.class, () -> new Book(null, null, null, null, null));
}
}
Hold on... This coolISBN
in otherQuixoteBook
does not look like a valid ISBN for a book. We may need to add some sort of validation
mecahism for the ISBN parameter. We might be safe from null parameters, but not from non-valid ISBN parameters.
According to the domain of this problem, an ISBN is an International Standard Book Number. Now, in our requirements there is a description about an ISBN:
An ISBN consists of 13 digits (until December 2006 it had 10 digits), the first 3 digits are an EAN prefix and it is always 978 or 979. 979, then there are 2 digits identifying the country (in the case of Spain 84), then we have 3 digits identifying the publisher (or the self-publisher), the next 3 digits identify the publisher (or the self-publisher), the next 3 digits identify the book, and the last digit (the letter X can also appear, which is equivalent to 10) serves as a control 10) serves as a check to confirm that the string is correct.
Okay, we obviously need a regex for this ISBN thing.
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Objects;
import java.util.regex.Pattern;
public record Book(String ISBN, String name, String description, String authorName, String bookCover) {
private static final String ISBN_PATTERN = "^(ISBN(-13)?:\\s?)?(\\d{3}-?\\d{1,5}-?\\d{1,7}-?\\d{1,7}-?\\d{1,7}|\\d{10}|\\d{13})$";
public Book {
Objects.requireNotNull(ISBN);
if (!isValidISBN(ISBN)) {
throw new IllegalArgumentException("ISBN is not valid");
}
Objects.requireNotNull(name);
Objects.requireNotNull(description);
Objects.requireNotNull(authorName);
Objects.requireNotNull(bookCover);
}
private static boolean isValidISBN(String isbn) {
return Pattern.matches(ISBN_PATTERN, isbn);
}
}
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
}
@Test
void cannotCreateBookWithNullParameters() {
assertThrows(NullPointerException.class, () -> new Book(null, null, null, null, null));
}
@Test
void cannotCreateBookWithWrongISBN() {
assertThrows(IllegalArgumentException.class, () -> new Book("coolISBN", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
}
Well, NOW we are safe... but not quite. What about the cover of the book? Or the name, description or author's name? Or any other new field we would like to add? These are strings that have a specific format. According to the requirements we have the following information about these fields:
- The name of the book must not be empty and must have a maximum length of 80 characters.
- The book description must not be empty and must have a maximum length of 200 characters.
- The author's name must not be empty.
- The book cover is a URL of an image.
The first option that comes to my mind is adding these validations in the record construction.
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Objects;
import java.util.regex.Pattern;
public record Book(String ISBN, String name, String description, String authorName, String bookCover) {
private static final String ISBN_PATTERN = "^(ISBN(-13)?:\\s?)?(\\d{3}-?\\d{1,5}-?\\d{1,7}-?\\d{1,7}-?\\d{1,7}|\\d{10}|\\d{13})$";
private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile("\\.(jpg|png)$", Pattern.CASE_INSENSITIVE);
public Book {
Objects.requireNotNull(ISBN);
if (!isValidISBN(ISBN)) {
throw new IllegalArgumentException("ISBN is not valid");
}
Objects.requireNotNull(name);
if (!nameIsValid()) {
throw new IllegalArgumentException("name is not valid");
}
Objects.requireNotNull(description);
if (!descriptionIsValid(description)) {
throw new IllegalArgumentException("description is not valid");
}
Objects.requireNotNull(authorName);
if (!authorNameIsValid(authorName)) {
throw new IllegalArgumentException("authorName is not valid");
}
Objects.requireNotNull(bookCover);
if (!bookCoverIsValid(bookCover)) {
throw new IllegalArgumentException("bookCover is not valid");
}
}
private static boolean isValidISBN(String isbn) {
return Pattern.matches(ISBN_PATTERN, isbn);
}
private static boolean nameIsValid(String name) {
return name.length > 0 && name.length <= 80 && !name.trim().equals(" ");
}
private static boolean descriptionIsValid(String description) {
return description.length > 0 && description.length <= 200 && !description.trim().equals(" ");
}
private static boolean authorNameIsValid(String authorName) {
return authorName.length > 0 && !authorName.trim().equals(" ");
}
private static boolean bookCoverIsValid(String imageUrl) {
try {
URL url = new URL(imageUrl);
String path = url.getPath();
return IMAGE_EXTENSION_PATTERN.matcher(path).find();
} catch (MalformedURLException e) {
return false;
}
}
}
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
}
@Test
void cannotCreateBookWithNullParameters() {
assertThrows(NullPointerException.class, () -> new Book(null, null, null, null, null));
}
@Test
void cannotCreateBookWithWrongISBN() {
assertThrows(IllegalArgumentException.class, () -> new Book("coolISBN", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
@Test
void cannotCreateBookWithWrongName() {
assertThrows(IllegalArgumentException.class, () -> new Book("978-84-08-06105-2", "",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
@Test
void cannotCreateBookWithWrongDescrpition() {
assertThrows(IllegalArgumentException.class, () -> new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
@Test
void cannotCreateBookWithWrongAuthorName() {
assertThrows(IllegalArgumentException.class, () -> new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
@Test
void cannotCreateBookWithWrongBookCover() {
assertThrows(IllegalArgumentException.class, () -> new Book("978-84-08-06105-2", "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"picture"));
}
}
This looks like a large class, but we are safe for now. However, the more I read the Book
constructor the more I think we could add more information because we are always using String
all over the place for the fields. If we had to interact with Book
fields in a hypothetical BookService
we would always interact with strings.
class MyFantasticBookService {
private final List<Book> collectionOfBooks = populateCollectionOfBooksFromPersistence();
// Remember, 84 is the country code for Spain
public List<Book> getAllBooksWithCountryCodeFromSpain() {
return collectionOfBooks.stream().filter(book -> {
final String countryCode = book.isbn().replaceAll("-", "").replace("ISBN-13:", "").replace("ISBN:", "").trim().substring(3, 5);
return countryCode.equals("84");
}).toList();
}
}
Wow, this looks tricky, doesn't it? We are using plain String
to extract a specific piece of information about a field, in this case the ISBN. We could start refactoring and extracting this chain of alterations of the string to methods in this service or even in a separated class with static or methods in the Book
class... but all these options make us separate the concept of domain in the code from its restrictions or characteristics according to the requirements. ISBN as a plain String
has no context around it and that is something we want to avoid.
Value objects to the rescue
Using value objects for this matter is an interesting choice. A value objects is an immutable type that is distinguishable only by the state of its properties. What if the ISBN was a value object? We would benefit from:
- Once it is created, it will never change its value. However, with String we have the same benefit.
- The validation methods are exactly where they are supposed to be. Close to the concept definition, as a part of it, actually.
- It is semantic. Whenever we see
ISBN
we automatically know what are we talking about. - We can provide semantic methods to interact with the value (remember country code?).
Let's get on it. First, we create the record ISBN
but moving the validation stuff from Book
to the ISBN
construction.
public record ISBN(String value) {
private static final String ISBN_PATTERN = "^(ISBN(-13)?:\\s?)?(\\d{3}-?\\d{1,5}-?\\d{1,7}-?\\d{1,7}-?\\d{1,7}|\\d{10}|\\d{13})$";
public ISBN {
Objects.requireNotNull(value);
if (!isValidISBN(value)) {
throw new IllegalArgumentException("ISBN is not valid");
}
}
private static boolean isValidISBN(String isbn) {
return Pattern.matches(ISBN_PATTERN, isbn);
}
}
Then, we change the ISBN
parameter type from the Book
record.
public record Book(ISBN isbn, String name, String description, String authorName, String bookCover) {
private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile("\\.(jpg|png)$", Pattern.CASE_INSENSITIVE);
Book {
Objects.requireNotNull(isbn);
Objects.requireNotNull(name);
if (!nameIsValid()) {
throw new IllegalArgumentException("name is not valid");
}
Objects.requireNotNull(description);
if (!descriptionIsValid(description)) {
throw new IllegalArgumentException("description is not valid");
}
Objects.requireNotNull(authorName);
if (!authorNameIsValid(authorName)) {
throw new IllegalArgumentException("authorName is not valid");
}
Objects.requireNotNull(bookCover);
if (!bookCoverIsValid(bookCover)) {
throw new IllegalArgumentException("bookCover is not valid");
}
}
private static boolean nameIsValid(String name) {
return name.length > 0 && name.length <= 80 && !name.trim().equals(" ");
}
private static boolean descriptionIsValid(String description) {
return description.length > 0 && description.length <= 200 && !description.trim().equals(" ");
}
private static boolean authorNameIsValid(String authorName) {
return authorName.length > 0 && !authorName.trim().equals(" ");
}
private static boolean bookCoverIsValid(String imageUrl) {
try {
URL url = new URL(imageUrl);
String path = url.getPath();
return IMAGE_EXTENSION_PATTERN.matcher(path).find();
} catch (MalformedURLException e) {
return false;
}
}
}
Do you see the difference? We have reduced the cognitive complexity from the Book
class and improved maintainability by delegating the responsibility of representing the ISBN to the corresponding record IBSN
, forgetting completely about ensuring it, in fact, is a valid ISBN.
It comes with a cheap price to pay when are creating a book though, the ISBN needs to be created before the Book:
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book(new ISBN("978-84-08-06105-2"), "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg");
assertNotNull(elQuixote);
}
@Test
void cannotCreateBookWithWrongISBN() {
assertThrows(IllegalArgumentException.class, () -> new Book(new ISBN("coolISBN"), "Don Quixote de la Mancha",
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts, in 1605 and 1615.",
"Miguel de Cervantes",
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote.jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
}
}
Now, let's remember the method from the previous hypothetical service MyFantasticBookService
. The getAllBooksWithCountryCodeFromSpain
method took the plain
()String
value and transformed it until the country code from the ISBN was extracted. What if we moved the extraction to a class method of ISBN
?. It seems reasonable because only the ISBN
class has the value and knows "how to deal with it".
public record ISBN(String value) {
private static final String ISBN_PATTERN = "^(ISBN(-13)?:\\s?)?(\\d{3}-?\\d{1,5}-?\\d{1,7}-?\\d{1,7}-?\\d{1,7}|\\d{10}|\\d{13})$";
public ISBN {
Objects.requireNotNull(value);
if (!isValidISBN(value)) {
throw new IllegalArgumentException("ISBN is not valid");
}
}
private static boolean isValidISBN(String isbn) {
return Pattern.matches(ISBN_PATTERN, isbn);
}
public String countryCode() {
return value.replaceAll("-", "").replace("ISBN-13:", "").replace("ISBN:", "").trim().substring(3, 5);
}
}
Wow, that's an improvement! We have transferred the knowledge from Book
to ISBN
improving readability and allowing Book
to access to the same piece of information by using the countryCode
method.
How would MyFantasticBookService
look like?
class MyFantasticBookService {
private final List<Book> collectionOfBooks = populateCollectionOfBooksFromPersistence();
public List<Book> getAllBooksWithCountryCodeFromSpain() {
// That 84 looks like a code smell indeed
return collectionOfBooks.stream().filter(book -> book.isbn().countryCode().equals("84")).toList();
}
}
We have transformed a plain String
with no context to a semantic type ISBN
where we could add any method related to the ISBN
, for example getting the EAN prefix, the publisher code or the control digit. It will depend on the domain needs.
We won't stop with this type only, will we? We want to do the same thing with the other fields so Book
ends up looking like this:
import java.util.Objects;
public record Book(ISBN isbn, BookName name, BookDescription description, AuthorName authorName, BookCover bookCover) {
public Book {
Objects.requireNonNull(isbn);
Objects.requireNonNull(name);
Objects.requireNonNull(description);
Objects.requireNonNull(authorName);
Objects.requireNonNull(bookCover);
}
}
For that we would follow the same approach as we did with ISBN
.
public record BookName(String value) {
public BookName {
Objects.requireNonNull(value);
if (!nameIsValid(value)) {
throw new IllegalArgumentException("Name is not valid");
}
}
private static boolean nameIsValid(String name) {
return name.length() > 0 && name.length() <= 80 && !name.trim().equals("");
}
}
public record Description(String value) {
public Description {
Objects.requireNonNull(value);
if (!descriptionIsValid(value)) {
throw new IllegalArgumentException("Description is not valid");
}
}
private static boolean descriptionIsValid(String description) {
return description.length() > 0 && description.length() <= 200 && !description.trim().equals("");
}
}
public record AuthorName(String value) {
public AuthorName {
Objects.requireNonNull(value);
if (!authorNameIsValid(value)) {
throw new IllegalArgumentException("Author name is not valid");
}
}
private static boolean authorNameIsValid(String authorName) {
return authorName.length() > 0 && !authorName.trim().equals("");
}
}
public record BookCover(String value) {
private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile("\\.(jpg|png)$", Pattern.CASE_INSENSITIVE);
public BookCover {
Objects.requireNonNull(value);
if (!bookCoverIsValid(value)) {
throw new IllegalArgumentException("Book cover is not valid");
}
}
private static boolean bookCoverIsValid(String imageUrl) {
try {
URL url = new URL(imageUrl);
String path = url.getPath();
return IMAGE_EXTENSION_PATTERN.matcher(path).find();
} catch (MalformedURLException e) {
return false;
}
}
}
And then, finally, we have a proper way to create a Book
with requirements close to the code.
class BookTest {
@Test
void createBook() {
final Book elQuixote = new Book(new ISBN("978-84-08-06105-2"),
new BookName("Don Quixote de la Mancha"),
new BookDescription(
"Don Quixote is a Spanish epic novel by Miguel de Cervantes. It was originally published in two parts,in 1605 and 1615."),
new AuthorName("Miguel de Cervantes"),
new BookCover(
"https://upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Title_page_first_edition_Don_Quijote. jpg/800px-Title_page_first_edition_Don_Quijote.jpg"));
assertNotNull(elQuixote);
}
}
Conclusion
We started by using plain strings as values in Book
with no context at all, with validation in the Book
constructor and not providing any kind of method to interact with values. We transitioned from that into value objects with responsibility for each concrete value, improving readability and making the Book
life easier. Also, we provide better developer experience by ensuring once the Book is created, it is meeting all requirements.
Posted on November 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.