Module 3: Design with Composition

Module Overview

Understand composition as an alternative to inheritance and learn how to design flexible and maintainable object relationships.

Learning Objectives

  • Implement a class that exposes a subset of its member class's functionality
  • Implement a class that exposes a superset of its member class's functionality
  • Design and implement a class with new functionality that uses the functionality of member class' to achieve its goal
  • Design a system that includes "has-a" relationships
  • Recall that Java classes can depend on other classes
  • Outline composition and how it is used when defining a class
  • Compare and contrast inheritance and composition as ways to achieve code reuse
  • Discuss when to expose only a subset of functionality from a member class by using composition
  • Discuss when to expose a superset of functionality from a member class by using composition
  • Discuss when to create a new class that uses the functionality of a member class by using composition

Inheritance Versus Composition

Overview

In earlier readings, we introduced encapsulation to bundle data with the methods that operate on that data. To better organize and structure our code, we need more techniques to define the relationships between multiple entities.

This reading discusses two techniques for organizing the relationships between classes, inheritance, and composition. We pay attention to the relative benefits of each strategy and discuss why composition is often the better choice for developers.

In the following reading, we develop an API for an instant messaging service. We see how to use composition to reduce, extend, and alter the functionality of previously defined classes.

Introduction

We often ask developers to design and implement software that mimics the behavior of real-world systems. When we push a button on a television remote, we expect the television to respond immediately and correctly to our command. If the on button didn't turn the TV on, we would return it and ask for a new one. Likewise, when we interact with software, we expect the click of a digital button to prompt an immediate and correct response. There's just one problem. The real world is messy and complex! How is one person supposed to deal with all the complexity?

One of the ways we can simplify the complexity of the real world is by organizing it into objects. This is the basis of object-oriented programming languages such as Java. We take a large concept and break it into many smaller, easy-to-understand components. We can relate these components by using techniques called inheritance and composition.

Inheritance

Inheritance is all about deriving one class from another. We call the original class the superclass and the derived class the subclass. This means, as a developer, you can reuse the fields and methods of the existing class without having to write (and debug!) them in your subclass. For example, the following code defines two classes, a superclass called Dog and a subclass called Labrador:


public class Dog {
    private int age;
    private String name;
    public Dog(int age, String name) {
        this.age = age;
        this.name = name;
    }

    public void makeNoise() {
        System.out.println("Bark!");
    }

    public int getAgeInDogYears() {
        return age * 7;
    }

}
public class Labrador extends Dog {
    public Labrador(int age, String name) {
        super(age, name);
    }


    @Override
    public void makeNoise() {
        super.makeNoise();
        System.out.println("Woof!");
    }

    public void retrieve() {
        System.out.println("Must chase after stick!");
    }

}
                

Because Labrador extends Dog, all the methods available to Dog objects are also available to Labrador objects. Both classes have access to the method getAgeInDogYears(). If we need to change or extend the behavior of methods in the superclass, we can override the method in the subclass. The behavior of the superclass is still available to the subclass through the use of the super keyword. In the above example, the Labrador subclass overrides the Dog method makeNoise(), first calling super.makeNoise() and extending the original behavior by adding in some additional noise. Finally, a subclass can define behaviors unique to the subclass. In the above example, the retrieve() method is only accessible to Labrador objects.

Inheritance is valuable when we need to define a class that's a specialized version of some other class. It allows the sharing of methods and instance variables between classes. When classes get more complex, inheritance saves a great deal of time and headache.

We can phrase the inheritance relationship as an is-a statement, as shown below.

  • a labrador is a dog
  • a car is a vehicle
  • a house is a building

In each of these examples, the first entity is the subclass, and the second is the superclass. When considering where to use inheritance, try to see if you can frame the relationship between classes as an is-a statement.

Composition

The second technique we use to simplify the design of the real world is composition. Composition refers to creating a class by putting together many classes. For instance, a car is composed of many parts like wheels and an engine. We can see how these parts fit together in the following code example:


public class Engine {
    public void run() {
        System.out.println("the engine is on!");
    }

}
public class Wheel {
    public void spin() {
        System.out.println("the wheels are turning!");
    }

}
public class Car {
    private Engine engine;
    private Wheel wheel;
    public Car(Engine engine, Wheel wheel) {
        this.engine = engine;
        this.wheel = wheel;
    }

}
                

As with inheritance, we can phrase the relationship of composed classes using a particular statement, called a has-a statement. For example,

  • a car has an engine
  • a house has a door
  • a dog has paws

When considering when to use composition, always determine whether you can organize the class relationships according to one or more has-a statements.

Inheritance versus composition

With both inheritance and composition in our toolbox for designing software systems, the only task left is to figure out when it's appropriate to use one technique over the other. The first test of a system is to apply the is-a and has-a statement test. If you can phrase a relationship in terms of an is-a statement, consider using inheritance. If you can express a relationship in a has-a statement, composition is the way to go.

Choosing between inheritance and composition is more than just clever wording. The choice between the two informs the design and maintenance of an entire software system. We call the relationships between inherited classes tightly coupled. Each subclass inherits all the functionality of its superclass. Inheritance is often abused to inherit a portion of methods and fields of an existing class. You can't pick and choose though, a subclass inherits all the methods and fields from the superclass, even if they are not used or not needed. This creates more complexity than the subclass needs.

Additionally, if a programmer changes a superclass, this can have a significant effect on subclasses. Their behavior has just been changed without choosing to do so. A superclass can also define a new method. Now each subclass also has that method. Even worse, what if a bug is introduced in a superclass? The failure will spread to each downstream class. If you don't use inheritance carefully, code maintenance can become a severe hassle!

Let's consider an example to illustrate the breaking changes we just discussed.

Consider the simplified PhotoAlbum class below:


public class PhotoAlbum {
    private Set<Photo> photos;
    public void addNewPhoto(Photo photo) {
        photos.add(photo);
    }

}
                

We want to implement a photo album that tracks all the people in the album and which photo they are in. We decide to create a TrackingPhotoAlbum which passes the is-a test. A TrackingPhotoAlbum is-a PhotoAlbum. So, we use inheritance and implement the class as below:


public class TrackingPhotoAlbum extends PhotoAlbum {
    private Map<Person, List<Photo>> photosContaining;

    @Override
    public void addNewPhoto(Photo photo) {
        super.addNewPhoto(photo);
        for(Person person: getPeopleInPhoto(photo)) {
            photoTrackingMap.get(person).add(photo);
        }

    }

    public List<Photo> getPhotos(Person person) {
        return photosContaining.get(person);
    }

    private List<Photo> getPeopleInPhoto(Photo photo) {
        // implementation not shown
    }

}
                

The owner of the PhotoAlbum class has received numerous requests to enable removing photos from an album. The PhotoAlbum class gets updated to the following:


public class PhotoAlbum {
    private Set<Photo> photos;
    public void addNewPhoto(Photo photo) {
        photos.add(photo);
    }

    public void removePhoto(Photo photo) {
        photos.remove(photo);
    }

}
                

Uh oh! Now TrackingPhotoAlbum has a method that allows users of the class to delete photos from the album. This could result in a photo being removed from the album but still being tracked in the photos a person appears in. On a call to TrackingPhotoAlbum.getPhotos(), the deleted photo will still be returned.

By comparison, composition creates loosely coupled relationships. This means classes related by composition are easier to change without creating cascades of bugs and failures. The flexibility composition offers has led to a philosophy of programming called composition over inheritance. This says that composition is always the first choice of a programmer when it comes to system design.

Let's reconsider our photo album example but instead utilize composition. We change our TrackingPhotoAlbum to instead have a PhotoAlbum instead of extending PhotoAlbum. We will not inherit the behaviors anymore, so instead of using super to interact with a superclass, we instead will call the methods of PhotoAlbum directly.


public class TrackingPhotoAlbum {
    private PhotoAlbum photoAlbum;
    private Map<Person, List<Photo>> photosContaining;
    public void addNewPhoto(Photo photo) {
        photoAlbum.addNewPhoto(photo);
        for(Person person: getPeopleInPhoto(photo)) {
            photoTrackingMap.get(person).add(photo);
        }

    }

    public List<Photo> getPhotos(Person person) {
        return photosContaining.get(person);
    }

}
                

In the TrackingPhotoAlbum.addNewPhoto() method we have replaced a call to super.addNewPhoto() with a call to our instance of PhotoAlbum, photoAlbum.addNewPhoto(). When using composition, the addition of the remove() method to the PhotoAlbum class does not break the behavior of TrackingPhotoAlbum. There is no remove() method exposed by the TrackingPhotoAlbum class, so callers of the class cannot cause an inconsistent state. As owners of the TrackingPhotoAlbum class, we can decide if and when we want to implement a method to remove photos.

Inheritance does have its uses, but the dangers and headaches often outweigh the benefits. The Java library has many good examples of inheritance, but you'll rarely see it used at Amazon in the code we write. We utilize composition when possible. If inheritance is used, it is used within the same code package so that the code is owned by a single team that can be responsible for ensuring that as a superclass is changed, the subclasses are also updated at the same time if necessary.

Summary

In this reading, we discussed inheritance and composition, as well as when to use each technique. This came with the strong encouragement of preferring composition over inheritance. In the following reading, we'll take a closer look at composition. We'll show you how to use composition to omit or extend the functionality of classes and interfaces.

Expose a Subset of Functionality

Introduction

In the previous reading, you learned what composition is and how it is handy when inheritance is not the right solution for the given scenario. In this document, we will explore different applications of composition to understand how we can use this powerful concept.

First, let's introduce the code used across all examples in this document, the Chime messaging client. We will focus on only a subset of Chime functionality. This is not actual Chime client code, but it is similar to what you could expect to see in the Chime API. To simplify the discussion, the authentication and authorization (giving only approved users the rights to invoke specific actions) aspects are not considered.

Let's check out what the interface for our Chime client looks like:


/** 
 * Chime client interface used to retrieve conversation history between users. 
 * It can also be used to send a message from one user to another 
 * (from sender to receiver). 
**/
public interface ChimeClient {
    /**     
     * Returns a conversation history between two users.           
     * @param firstUser first user in the conversation     
     * @param secondUser second user in the conversation     
     * @return all messages that were exchanged in the conversation between the two users     
    **/    
    List<ChimeMessage> getConversationHistory(ChimeUser firstUser, ChimeUser secondUser);    
    
    /**     
     * This method allows addition of one message to the conversation between two users. 
     * The order of users is important here since we need to know who is the sender 
     * and who is the receiver.          
     * @param sender The user for which we are sending the message     
     * @param receiver The user to receive the message    
     * @param message The message content     
    **/    
    void addMessageToConversation(ChimeUser sender, ChimeUser receiver, String message);
}
                

You can see that the client contains two methods:

  • getConversationHistory() allows us to retrieve all messages that were exchanged in the conversation between two users.
  • addMessageToConversation() allows us to send a message to the receiving user on behalf of the sender.

The actual implementation of the ChimeClient is not relevant for the examples that we're going to use in this read. We can assume that whatever the implementation is, it respects the contract defined in the above interface.

Let's also take a look at the ChimeUser and ChimeMessage interfaces:


/**
 * Contains the information about the specific Chime user.
 */
public interface ChimeUser {

    long getUserId();

    String getName();

    String getSurname();
    
    /**
     * Returns the list of all contacts for 
     * the current user.
     * @return list of user contacts.
     */
    List<ChimeUser> getContacts();
}

/**
 * Contains the information about the specific Chime message. 
 */
public interface ChimeMessage {

    long getMessageId();

    /**
     * Actual content (message) sent from sender to receiver.
     * @return string content of the message.
     */
    String getContent();

    ZonedDateTime getDateCreated();

    ChimeUser getSender();
    
    ChimeUser getReceiver();
}
                

The ChimeUser class contains information about a specific Chime user, like name and surname. One of the interesting aspects is that it includes all of the user contacts as well. ChimeMessage contains information about a specific Chime message that was exchanged between two users and the date on which it was exchanged.

Expose a subset of functionality from a contained class

The Chime client we just introduced allows us to retrieve the conversation history between two users and send a message on behalf of a user. This means we have read (message history) and write (send a new message) functionality available in the client. You could use it to export your chat history (something typically provided by most communication applications) or add automatic messages to a conversation (e.g., Chime hooks, which publish information about specific events where the sender is Chime itself). It is widespread to limit the functionality of such clients to support only read or write functionality. For example, we might restrict the client's functionality to the methods used only for reading the data if that is all our application needs. We don't want to expose the write functionality because we would never introduce a bug where we accidentally send messages.

Having been introduced to inheritance in Java, you might think about a solution in which you would extend the ChimeClient interface and override the addMessageToConversation() method to do nothing. But would that be the right solution? Not really, because that would break the superclass method's promise and could lead to painful bugs for the users of this new class. For example, a user of the class might miss the fact that the class is not sending messages and have their code rely on this missing functionality. This would be a terrible experience for the user of our code.

Instead of using inheritance to solve this problem, we could instead use composition. Here's how that would look:


/**
 * Read only Chime client interface used to retrieve conversation history 
 * between users.
 */
public class ReadOnlyChimeClient {
    private final ChimeClient client;
    
    public ReadOnlyChimeClient(ChimeClient client) {
        this.client = client;
    }
    
    List<ChimeMessage> getConversationHistory(ChimeUser firstUser, ChimeUser secondUser) {
        return client.getConversationHistory(firstUser, secondUser);
    }
}
                

Instead of defining an "is-a" relationship by extending ChimeClient, we can instead create a "has-a" relationship between ReadOnlyChimeClient and the ChimeClient interface. This is done by creating a private instance variable of type ChimeClient in our ReadOnlyChimeClient class. Finally, we define just a single method getConversationHistory() in our new class. This class doesn't suffer from the "fake method" problem we discussed in the inheritance approach. It has only the method it needs to implement the desired functionality.

This reduction of functionality is one of the most frequently used composition techniques. It allows us to expose only a subset of functionality from a contained class. This is extremely useful in cases where we want to limit access to specific resources or methods or if we don't need other methods or data provided by the contained class. For example, you might want to have a read-only data structure such as List or Map that doesn't allow any changes to its content after it has been initialized. In this case, using composition to hide away all the methods that will enable modifications to the data structure would be a way to implement this. In general, the separation between read and write operations is one of the most common use-cases for using composition to limit the functionality of a class.

Expose a Superset of Functionality

Expose a superset of functionality from a contained class

The ChimeClient interface provides us with just two methods which—while being very useful—might not satisfy the needs of all of its consumers. Some additional methods that might be useful could be:

  • Get the last message from the conversation of two users
  • Send the same message to multiple users (e.g., "Happy Holidays!")

Inheritance is built for this situation. Assuming that ParentChimeClient implements ChimeClient, we can create a subclass of ParentChimeClient named something like ChildChimeClient and only implement the new methods!

This works well until ParentChimeClient makes a breaking change. If its developers decided to stop support ChimeClient and return a List<String> instead of a List<ChimeMessage>, our ChildChimeClient would break. But so would all the code that uses ChildChimeClient, and all its subclasses, and all the code that used its subclasses!

If we use composition instead and build an ExtendedChimeClient that "has-a" ParentChimeClient, then our ExtendedChimeClient would still break. But the code that used it wouldn't! We'd only have to make changes in the ExtendedChimeClient to fix everything and get our ChimeClient functionality back.

Here's how this new class would look:


/**
 * This class contains a ChimeClient class and provides additional functionalities
 * that allow getting the last message from the conversation history and sending 
 * the same message to all of the users contacts.
 */
public class ExtendedChimeClient {
    private final ChimeClient client;

    public ExtendedChimeClient(ChimeClient client) {
        this.client = client;
    }

    public List<ChimeMessage> getConversationHistory(ChimeUser firstUser, ChimeUser secondUser) {
        return client.getConversationHistory(firstUser, secondUser);
    }

    public void addMessageToConversation(ChimeUser sender, ChimeUser receiver, String message) {
        client.addMessageToConversation(sender, receiver, message);
    }

    /**
     * Get only the last message exchanged in the conversation between the two users.
     * @param firstUser first user in the conversation
     * @param secondUser second user in the conversation
     * @return last message exchanged in the conversation between the two users
     */
    public ChimeMessage getLastMessageFromConversationHistory(ChimeUser firstUser, ChimeUser secondUser) {
        List<ChimeMessage> conversationHistory = client.getConversationHistory(firstUser, secondUser);
        return conversationHistory.get(conversationHistory.size() - 1);
    }

    /**
     * This method allows addition of one message to all conversations between the given user
     * and other contacts.
     * *
     * @param sender The user for which we are sending the message
     * @param message The message content
     */
    public void addMessageToAllConversations(ChimeUser sender, List<ChimeUser> receivers, String message) {
        for (ChimeUser receiver : receivers) {
            client.addMessageToConversation(sender, receiver, message);
        }
    }
}
                

You can see that we followed the same approach of containing the ChimeClient interface within the new ExtendedChimeClient class instead of using the Java defined extend keyword to implement inheritance. If ClimeClient methods change, we'll have to update our ExtendedChimeClient, but we can continue to present the same interface to our consumers, so they won't need to change.

This is another pattern in which composition finds its use. Adding functionality to an existing class without needing to extend the class makes the newly created class more robust and resilient to changes in the contained class. While fixes still need to be made to a class taking advantage of composition, the changes stop here. If the functionality were implemented through inheritance, we would need to fix the client code as well. But as we know, sometimes we don't have access to that code because it might be owned by one or more away teams.

One other aspect of extending functionality that you will typically see in code is adding validation before returning results to the consumer of the class. E.g., in our example, we might want to check if the messages contain any private information such as phone numbers or social security numbers. We might want to prevent sending messages with such content in the first place as well. Additionally, you will likely find yourself in a situation where you need to use code that you don't own, and it will be missing functionality that you need to make your feature work. In that case, using composition to implement the desired behavior is an excellent way to go.

Expose a Different Contract

Expose a contract that differs from the contract defined in the contained class

The last aspect of composition that we will discuss is changing the contract defined in the contained class. For example, you can imagine that in the case of our ChimeClient you might need just the String representation of the conversation history, with the content of each message separated by a newline character. Similar to our example where we added functionality to the contained class, composition is a good fit for this use case.

Here's how we could use composition to implement this transformation:


/**
 * This class encapsulates ChimeClient class and provides method that transforms the
 * conversation history from a list of message objects to a string representation of the
 * conversation.
 */
public class TransformedChimeClient {
    private final ChimeClient client;

    public TransformedChimeClient(ChimeClient client) {
        this.client = client;
    }

    public void addMessageToConversation(ChimeUser sender, ChimeUser receiver, String message) {
        client.addMessageToConversation(sender, receiver, message);
    }

    /**
     * Returns a string representation of conversation history where each message is separated
     * by a newline.
     * @param firstUser first user in the conversation
     * @param secondUser second user in the conversation
     * @return string representation of all messages that were exchanged in the conversation between the two users
     */
    public String getConversationHistory(ChimeUser firstUser, ChimeUser secondUser){
        List<ChimeMessage> conversationHistory = client.getConversationHistory(firstUser, secondUser);
        StringBuilder builder = new StringBuilder();
        for (ChimeMessage chimeMessage : conversationHistory) {
            builder.append(chimeMessage.toString()).append(System.lineSeparator());
        }
        return builder.toString();
    }
}
                

We followed the same approach as with two previous examples and have contained the ChimeClient as a private attribute. The main thing to notice is that the transformed method has the same signature (method name and parameters) as the one in the ChimeClient, but the return type has been changed.

Why didn't we include the original getConversationHistory method in the TransformedChimeClient as well? As we discussed in our encapsulation lesson, we shouldn't expose functionality that other classes in your codebase won't call. We are then committing to a public method that we will be unable to change later and creating unnecessary code and tests we have to maintain. So, if you take a look at the code that consumes TransformedChimeClient and there is no need for the original method, don't include it; you can always add it later.

So far, we have been using only a single contained class, but it is not uncommon to have multiple contained classes that all work together in the composition. In our example, you might want to translate the conversation into another language. In that case, apart from having the ChimeClient as an attribute, you will also need to have something that will translate the messages (or strings) to another language, like a ChimeTranslator class. You would first use ChimeClient to retrieve the conversation history in the original language, and then use the ChimeTranslator to translate that conversation history to a target language before returning the translation to the consumer of the class.


/** 
 * This interface defines method for translating the message to the language spoken 
 * by the message consumer. 
 */
public interface ChimeTranslator {

    /**     
     * Translate the message to the language spoken by the message consumer    
     * @param messageConsumer the user that will read the message     
     * @param message the actual message     
     * @return message containing translated text     
     */    
     public ChimeMessage translateForUser(ChimeUser messageConsumer, ChimeMessage message);
}
                

/**
 * This client exposes functionality that allows users to view the Chime message
 * history in their native language.
 */
public class TranslatedChimeClient {
    private final ChimeClient client;
    private final ChimeTranslator translator;

    public TransformedChimeClient(ChimeClient client, ChimeTranslator translator) {
        this.client = client;
        this.translator = translator;
    }

    /**
     * Get conversation history translated to the first user's native language.
     * @param firstUser first user
     * @param secondUser second user
     * @return list of translated chime messages
     */
    public List<ChimeMessage> getConversationHistoryFirstUserView(ChimeUser firstUser, ChimeUser secondUser){
        return getTranslatedMessages(firstUser, client.getConversationHistory(firstUser, secondUser));
    }

    /**
     * Get conversation history translated to the second user's native language.
     * @param firstUser first user
     * @param secondUser second user
     * @return list of translated chime messages
     */
    public List<ChimeMessage> getConversationHistorySecondUserView(ChimeUser firstUser, ChimeUser secondUser){
        return getTranslatedMessages(secondUser, client.getConversationHistory(firstUser, secondUser));
    }

    /**
     * Translate a list of messages to the native language of the reader.
     * @param reader The user reading the translated messages.
     * @param messages Original messages before translation.
     * @return List of translated messages.
     */
    private List<ChimeMessage> getTranslatedMessages(ChimeUser reader, List<ChimeMessage> messages) {
        List<ChimeMessage> translatedMessages = new ArrayList<>();
        for(ChimeMessage message : messages) {
            ChimeMessage translatedMessage = translator.translateForUser(reader, message);
            translatedMessages.add(translatedMessage);
        }
        return translatedMessages;
    }
}
                

With this, we have covered the three main patterns of usage of composition: limiting the functionality of the contained class, extending the functionality of the contained class, and transforming the functionality of the contained class.

Code Examples

Here are some examples demonstrating key composition patterns:

Exposing a Subset of Functionality (Delegation)

import java.util.ArrayList;
import java.util.Collection;

// This class implements a read-only list that only exposes
// a subset of ArrayList functionality
public class ReadOnlyList<E> {
    // Composition: has-a relationship with ArrayList
    private final ArrayList<E> list;
    
    public ReadOnlyList(Collection<E> items) {
        this.list = new ArrayList<>(items);
    }
    
    // We only expose get and size methods
    public E get(int index) {
        return list.get(index);
    }
    
    public int size() {
        return list.size();
    }
    
    public boolean contains(E element) {
        return list.contains(element);
    }
    
    // Notice we don't expose add, remove, or other mutating methods
    
    public static void main(String[] args) {
        ArrayList<String> source = new ArrayList<>();
        source.add("Apple");
        source.add("Banana");
        source.add("Cherry");
        
        ReadOnlyList<String> readOnly = new ReadOnlyList<>(source);
        System.out.println("Element at index 1: " + readOnly.get(1));
        System.out.println("Size: " + readOnly.size());
        System.out.println("Contains Banana? " + readOnly.contains("Banana"));
        
        // This would cause a compilation error:
        // readOnly.add("Date");
    }
}

Exposing a Superset of Functionality

import java.util.ArrayList;
import java.util.List;

// This class extends ArrayList functionality with additional features
public class EnhancedList<E> {
    // Composition: has-a relationship with ArrayList
    private final List<E> list;
    
    public EnhancedList() {
        this.list = new ArrayList<>();
    }
    
    // Delegate standard List methods
    public boolean add(E element) {
        return list.add(element);
    }
    
    public E get(int index) {
        return list.get(index);
    }
    
    public int size() {
        return list.size();
    }
    
    // Add enhanced functionality
    public void addIfNotExists(E element) {
        if (!list.contains(element)) {
            list.add(element);
        }
    }
    
    public E getFirst() {
        if (list.isEmpty()) {
            return null;
        }
        return list.get(0);
    }
    
    public E getLast() {
        if (list.isEmpty()) {
            return null;
        }
        return list.get(list.size() - 1);
    }
    
    public void addAll(E... elements) {
        for (E element : elements) {
            list.add(element);
        }
    }
    
    public static void main(String[] args) {
        EnhancedList<String> enhanced = new EnhancedList<>();
        
        // Use standard functionality
        enhanced.add("Apple");
        enhanced.add("Banana");
        
        // Use enhanced functionality
        enhanced.addAll("Cherry", "Date", "Elderberry");
        enhanced.addIfNotExists("Banana"); // Won't add duplicate
        
        System.out.println("First element: " + enhanced.getFirst());
        System.out.println("Last element: " + enhanced.getLast());
        System.out.println("Total size: " + enhanced.size());
    }
}

Creating a New Class with Unique Functionality

import java.util.HashMap;
import java.util.Map;

// A shopping cart that uses a Map internally but provides a completely different
// interface and functionality
public class ShoppingCart {
    // Composition: has-a relationship with HashMap
    private final Map<Product, Integer> items = new HashMap<>();
    
    // Add a product to the cart
    public void addProduct(Product product, int quantity) {
        if (items.containsKey(product)) {
            // Update quantity if product already exists
            items.put(product, items.get(product) + quantity);
        } else {
            // Add new product
            items.put(product, quantity);
        }
    }
    
    // Remove a product from the cart
    public void removeProduct(Product product) {
        items.remove(product);
    }
    
    // Update product quantity
    public void updateQuantity(Product product, int newQuantity) {
        if (newQuantity <= 0) {
            removeProduct(product);
        } else if (items.containsKey(product)) {
            items.put(product, newQuantity);
        }
    }
    
    // Get total price of all items
    public double getTotalPrice() {
        double total = 0;
        for (Map.Entry<Product, Integer> entry : items.entrySet()) {
            total += entry.getKey().getPrice() * entry.getValue();
        }
        return total;
    }
    
    // Get number of unique products
    public int getUniqueProductCount() {
        return items.size();
    }
    
    // Get total quantity of all products
    public int getTotalQuantity() {
        int total = 0;
        for (int quantity : items.values()) {
            total += quantity;
        }
        return total;
    }
    
    // Print cart contents
    public void printCartContents() {
        System.out.println("Shopping Cart Contents:");
        for (Map.Entry<Product, Integer> entry : items.entrySet()) {
            Product product = entry.getKey();
            int quantity = entry.getValue();
            System.out.printf("%s - %d x $%.2f = $%.2f%n", 
                product.getName(), quantity, product.getPrice(), 
                quantity * product.getPrice());
        }
        System.out.printf("Total: $%.2f%n", getTotalPrice());
    }
    
    // Simple Product class for the example
    static class Product {
        private final String name;
        private final double price;
        
        public Product(String name, double price) {
            this.name = name;
            this.price = price;
        }
        
        public String getName() {
            return name;
        }
        
        public double getPrice() {
            return price;
        }
        
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Product product = (Product) o;
            return Double.compare(product.price, price) == 0 && 
                   name.equals(product.name);
        }
        
        @Override
        public int hashCode() {
            return 31 * name.hashCode() + Double.hashCode(price);
        }
    }
    
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();
        
        // Create some products
        Product laptop = new Product("Laptop", 999.99);
        Product mouse = new Product("Mouse", 24.99);
        Product keyboard = new Product("Keyboard", 59.99);
        
        // Add products to cart
        cart.addProduct(laptop, 1);
        cart.addProduct(mouse, 2);
        cart.addProduct(keyboard, 1);
        
        // Update quantity
        cart.updateQuantity(mouse, 3);
        
        // Print cart
        cart.printCartContents();
        
        System.out.println("\nUnique products: " + cart.getUniqueProductCount());
        System.out.println("Total items: " + cart.getTotalQuantity());
    }
}

Practice

Alexa

Mastery Task 5: The Cost of Progress

Mastery Task Guidelines

Mastery Tasks are opportunities to test your knowledge and understanding through code. When a mastery task is shown in a module, it means that we've covered all the concepts that you need to complete that task. You will want to make sure you finish them by the end of the week to stay on track and complete the unit.

Each mastery task must pass 100% of the automated tests and code styling checks to pass each unit. Your code must be your own. If you have any questions, feel free to reach out for support.

Like most teams at Amazon, the Sustainability team follows the Scrum process. This means you plan work in two-week "sprints", review your progress every day in the "standup" meeting, and review your accomplishments with the stakeholders during the "retrospective" meeting at the end of each sprint.

During sprint planning, you propose that we should include the package’s environmental impact in the shipment recommendation. Currently, which packaging we recommend for a shipment is based entirely on the monetary cost of the packaging. Take a look at the ShipmentService class to see how a shipment option is selected based on the lowest cost. This class relies on the MonetaryCostStrategy to determine the cost of a packaging option in USD. You notice this class implements an interface, CostStrategy. This gets you thinking that environmental impact can be thought of as a carbon cost, a CarbonCostStrategy!

Little do you know, but you've stumbled upon the Strategy pattern, a classic software design pattern. The goal of the Strategy pattern is to allow a piece of software to switch between different strategies to solve the same problem easily. Here the problem we are trying to solve is which packaging option to recommend. Our existing strategy selects the packaging based on minimizing the monetary cost, but you'll be creating a new strategy that selects the packaging based on minimizing the carbon cost. The Strategy pattern lets you store algorithms in separate classes and make them interchangeable.

If we decide to track a new cost type in the future, we can simply develop a new implementation of CostStrategy. If you'd like to learn more about the Strategy Design Pattern, we recommend this tutorials point article. In the terminology of this article, CostStrategy is our Strategy interface, and both MonetaryCostStrategy and CarbonCostStrategy are our concrete strategy classes. The ShipmentService class is our Context class.

Milestone 1: At What Cost?

You confer with your Data Engineering team, who crunch the numbers and determine that all the current and expected cost types can be calculated from two measurements: the amount of material (grams of mass) in the packaging and the cost per gram of material. In mastery task 3, you added a method to each packaging type to get the amount of material used, getMass().

The MonetaryCostStrategy determines a cost in USD by taking the mass in grams and multiplying it by the cost of the type of material per gram.

For the environmental cost, the Data Engineering team does some research and comes up with a sustainability index for each material, expressed in "carbon units" (cu) per gram of mass. CORRUGATE is 0.017 cu per gram, and LAMINATED_PLASTIC is 0.012 cu per gram. Just multiply the package’s mass by the sustainability index of its material to get the carbon cost in cu. Add these factors to the CarbonCostStrategy class and implement the calculation.

To compare, our B2K Box would cost 17cu, while the equivalent P20 PolyBag would be only 0.324 cu (although its per-gram carbon cost is similar, it weighs much less). With the MonetaryCostStrategy, the same B2K Box costs $5.43, while the P20 PolyBag costs $7.18. While the PolyBag monetarily costs 32% more than the Box, we are reducing the carbon credits we are using by 98%!

Implement the CarbonCostStrategy in the strategy package using these formulas and write unit tests to verify they work.

When you are done, check that the MT5CarbonIntrospectionTests tests pass.

Milestone 2: Designing a Blended Cost

After discussing further with the team, you start to dig into what it means to return the best packaging option. Your team decides to recommend packaging based on a combination of monetary cost and carbon cost. Your team decides that the appropriate balance is 80% monetary cost and 20% carbon cost.

The blended cost of our B2K Box is 7.7440, and our P20 PolyBag is 5.80880. (Don't worry about the units here; it's an arbitrary unit based on our 80/20 split.) That means even with our blended strategy, we should still select the PolyBag as our lowest cost packaging.

You Disagree and Commit, put aside your personal opinions about the appropriate ratios, and get cracking on the new code. Create a class diagram to represent your design for a WeightedCostStrategy. Create a new file in the src/resources directory called mastery_task_05_CD.puml with the PlantUML.

When you are done, check that the MT5DesignIntrospectionTests tests pass.

Optional Side Quest: Future Costs

Rather than hard-coding the 80/20 split, your WeightedCostStrategy would be more flexible if it accepted any number of CostStrategy objects, each with its own weight. You could use a Builder pattern like you saw in the Delivering on Our Promise! project, with an addStrategyWithWeight(CostStrategy, BigDecimal) method to make it obvious which weights went with each strategy; that would also let you guarantee the calling code had provided at least one strategy. This will require a little more code, but it will also make it easy to change the cost calculation in the future.

Milestone 3: Composing the Cost

At this point in our story, several more FCs have onboarded with polybag options. Add new PolyBag FcPackagingOptions to the PackagingDatastore, using the FC code and polybag volume pairs below.

  • "IAD2", "5000"
  • "YOW4", "2000"
  • "YOW4", "5000"
  • "YOW4", "10000"
  • "IND1", "2000"
  • "IND1", "5000"
  • "ABE2", "2000"
  • "ABE2", "6000"
  • "PDX1", "5000"
  • "PDX1", "10000"
  • "YOW4", "5000"

Implement your design for the WeightedCostStrategy in the strategy package. Update the getCostStrategy() method in the App class to return your newly created WeightedCostStrategy instead of a MonetaryCostStrategy.

When you are done, check that the MT5WegithedIntrospectionTests tests all pass.

Exit Checklist

  • ./gradlew -q clean :test --tests 'tct.MT5*' passes
  • ./gradlew -q clean :test --tests 'com.amazon.ata.*' passes

Resources