Keep requirements close to your code

mariocalin

Mario

Posted on November 29, 2023

Keep requirements close to your code

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }

}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
mariocalin
Mario

Posted on November 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related