Design Patterns - A Review

Deisgn patterns are tools that developers can use when designing distributed applications. Today, we'll look at a few well known design patterns and how they're implemented in Java.

Builder Pattern

The intent of the builder pattern is to separate the construction of a complext object from its representation such that the same construction process can create different representations. It's essentially an abstraction that creates a more flexible object creation interface.

What problem does it solve?

Think about constructors in Java. If I want to make a object via the obnjects constructor, the constructor has to be declared and take the fields of the object as parameters. For example, the Product constructor needs parameters for each of its fields.


public class Product {


  private String id;
  private String name;
  private double price; 
  private String description; 

  public Product(String id) {
    this.id = id;
  }

  public Product(String id, String name) {
    this.id = id;
    this.name = name;
  }

  public Product(String id, String name, double price) {
    this.id = id;
    this.name = name;
    this.price = price;
  }

  // getters, setters, object methods
}

Notice that for different representations of a Product we need multiple contructors, each with a varying number of parameters. When the parameter number gets large, say 4+, then it can become difficult to remember which parameters are required and in what order when constructing an object. This is known as the Telescoping Constructor Anti-pattern and is what the builder pattern was made to solve.

The builder pattern decouple object creation and allows for a fluid object creation syntax where the same object creation mechanism can create different represetnations of the same object.


public class Product {

  private String id;
  private String name;
  private double price; 
  private String description; 

  private Product(ProductBuilder builder) {
    this.id = builder.id;
    this.name = builder.name;
    this.price = builder.price;
    this.description = builder.description;
  }

  public static class ProductBuilder {

    private String id;
    private String name;
    private double price; 
    private String description; 

    public ProductBuilder builder() {
      return this;
    }

    public ProductBuilder id(String id) {
      this.id = id;
      return this;
    }

    public ProductBuilder name(String name) {
      this.name = name;
      return this;
    }

    public ProductBuilder price(double price) {
      this.price = price;
      return this;
    }

    public ProductBuilder description(String description) {
      this.description = description
    }

    public static Product build() {
      return new Product(this);
    }
  }
}

The above can be used as:


Product mac = ProductBuilder.builder()
    .id("123")
    .name("Macbook")
    .price(949.99)
    .description("A sleek, fast, shiny, 15 inch Macbook Pro computer.")
    .build();

Product pc = ProductBuilder.builder()
    .id("456")
    .name("Personal Computer")
    .price(549.99)
    .description("A boring computer created by Microsoft.")
    .build();

System.out.println("You can buy a " + pc.getName() + "or a " + mac.getName);

Notice that you can leave fields out in the builder pattern while using the same builder.

Now, you may say "well thats a lot of bopiler pl"

Data Access Object

A data access object is meant to provide an abstract interface to some tupe of database or other persistence mechanism. It's intention is to define the API to a datastore without implementing the datastore. Then, the data access interface can be implemented elsehwere or in multiple different ways. This provides flexibility to an application's architecture.

Example

A real world example: There is a set of products that need to be saved in a datastore for my e-commerce application. In addition, I'll need an entire set of create, read, write, and remove operations so that my application can interact with my products. In this example, a data access object can be created to define the API for interacting with these products.

Below is my product entity which i want to persist and interact with using CRUD (create, read, update, and delete) operations.

import lombok.Data;

@Data
public class Product {

  private String id;
  private String name;
  private double price; 
  private String description; 

}

My data access object would look like the below:


public interface ProductDataAccess {

  Stream<Product> getAll();

  Optional<Product> getProductById(String id);

  Product updateProduct(Product product);

  void deleteProductById(String id);
}

The Data Access interface can then be implemented in multiple ways.


public class InMemoryProductRepository implements ProductDataAccess {

  private final Map<String, Product> inMemoryProductStore = new HashMap<>();

  Stream<Product> getAll() {
    return inMemoryProductStore.values().stream();
  }

  Optional<Product> getProductById(String id) {
    return inMemoryProductStore.get(id);
  }

  Product updateProduct(Product product) {
    inMemoryProductStore.set(product.getId(), product);
    return inMemoryProductStore.get(product.getId());
  }

  void deleteProductById(String id) {
    inMemoryProductStore.set(id, null);
  }
}

public class ProductSQLRepository implements ProductDataAccess {

  private final DataStore dataStore;

  public ProductRepository(DataStore dataStore) {
    this.dataStore = dataStore;
  }

  // Implement methods using SQL data store
}

public class ProductMongoDbRepository implements ProductDataAccess {

  private final MongoRepository dataStore;

  public ProductRepository(MongoRepository dataStore) {
    this.dataStore = dataStore;
  }

  // Implement methods using MongoRepository data store. 
}

Value

This is where the beauty of the Data Access Object comes in. It allows the application to define as an interface the operations that the application needs in order to interact with its entities. Then the actual persistence mechanism can be implemented in any way possible, as long as the implementation conforms to the API.

This is powerful. Think of a brand new project that you're working on. The requirements are vague, the data model is not quite hashed out. How do you make a decision on a data store at this time? Should you use a relational model or NoSQL? What database offerings will provide you the best performance for your data? The answer is: you don't know. And a data access interface allows you to delay that decision as long as you'd like because you can implement the data access with any implementation you like. Early in the project, simply use an in-memory map. As your application evolves you may see relational cahracteristics emerge--so switch to a SQL database. Further down the road, you may find your assumptions were wrong and that a document store is more efficient. The change is easy. You can create another implementation alongside your existing implementation, test and verify its efficacy, and then switch the implmentation with a single line code change.

Not only that, but a Data Access Object consolidates the point of data layer access to one class. It allows you to clearly define how your application will interact with the data layer, making your code more understandable and maintainable.

This is extremely powerful and shows the benefit of clearly defining interfaces in your application code. It also leads into Dependency Inversion and the Hexagonal Architecture, topics which I cover in other articles.

Ambassador Pattern

The goal of the ambassador pattern is to provide helper functionality to a client that is calling a shared resource that cannot be easily updated. This helper functionality acts as an ambassador when the client communicates with the service, handling things like retries, latency checks, client-side rate-limiting, and logging. The ambassador is designed to encapsulate all of this helper functionality in a client-side service that can be used alongside any client calling the shared resource.

Notes:

  • Typically sued when calling a shared legacy resource that cannot be updated or easily changed.
  • The ambassador is usually co-located with the client.

When to use it

You would use the ambassador pattern when you have some functionality needed everytime you call a shared resource. You can create an ambassador class that each of your clients can use when requesting the resource. This allows for a streamlined process when each client makes its request.

If for every client that calls a shared resource you need to do a certain number of things, say add logging statements, publish monitoring metrics like timers our counters, or perform back-offs and retries, this is when you'd want to create an ambassador.

Example

Say that you have some service that is heavily shared across multiple clients

interface CoolServiceApi {
    long performSomeOperation(int value) throws Exception;
}

@Slf4j
public class CoolSharedService implements CoolServiceApi {

    private static CoolSharedService service = null;

    static synchronized CoolSharedService getService() {
        if (service == null) {
            service = new CoolSharedService();
        }
        return service;
    }

    private CoolSharedService() {}

    @Override
    public long performOperation(int value) {
        long waitTime = (long) Math.floor(Math.random() * 1000);
        try {
            sleep(waitTime);
        } catch (InterruptedException e) {
            LOGGER.error("Thread sleep interrupted", e);
        }
        return waitTime >= 200 ? value * 10 : -1;
    }
}

A service ambassador can be used to dd monitoring and logging on top of the requests to the CoolSharedService:


@Slf4j
public class ServiceAmbassador implements CoolServiceApi {

  private static final int MAX_RETIRES = 3;
  private static final int DELAY_MS = 3000;
  private static final int FAILURE = -1;

  ServiceAmbassador() {}

  @Override
  public long performOperation(int value) {
    return callWithRetiresAndLatencyChecks(value);
  }

  private long requestAndCheckLatency(int value) {
    var startTime = System.currentTimeMillis();
    var result = CoolSharedService.getService().performOperation(value);
    var timeTaken = System.currentTimeMillis() - startTime;
    LOGGER.info("Time taken (ms): " + timeTaken);
    return result;
  }

  private long callWithRetiresAndLatencyChecks(int value) {
    var retries = 0;
    var result = (long) FAILURE;
    var isSuccess = true;

    while (retries <= MAX_RETIRES && !isSuccess) {
      result = requestAndCheckLatency(value);
      if (result == (long) FAILURE) {
        LOGGER.info("Failed to reach remote: (" + (i + 1) + ")");
        retries++;
        delayFor(DELAY_MS);
      } 
    }
    return result;
  }

  private void delayFor(int ms) {
    try {
      sleep(ms);
    } catch (InterruptedException e) {
      LOGGER.error("Thread sleep state interrupted", e);
    }
  }
}

Our client would then have a service ambassador that it can use to interact with the remove service:


@Slf4j
public class CoolClient {

  private final ServiceAmbassador serviceAmbassador = new ServiceAmbassador();

  long getCoolStuff(int value) {
    var result = serviceAmbassador.performOperation(value);
    LOGGER.info("Service result: " + result);
    return result;
  }
}

Now, if we had an application that created two clients, like so:

public class App {
  public static void main(String[] args) {
    var client1 = new CoolClient();
    var client2 = new CoolClient();

    client1.getCoolStuff(17);
    client2.getCoolStuff(75);
  }
}

you might get the below output:

Time taken (ms): 111
Service result: 120
Time taken (ms): 931
Failed to reach remote: (1)
Time taken (ms): 665
Failed to reach remote: (2)
Time taken (ms): 538
Failed to reach remote: (3)
Service result: -1

Notice that we essentially get logging, retries, and latenchy tracking built in to our requests to the CoolSharedService. This is extremely useful, especially if we want a streamlined monitoring solution for all of our clients that call the CoolSharedService. We could bundle the ServiceAmbassador into a .jar or other reusuable component so that other developers can include it in the project and use it whenever they call the CoolSharedService.

Value

The value of the ambassador pattern is plain to see, especially when you look at it from a microservices perspective. Say you have multiple containers running in your cluster, split out based on responsibility and functionality. Now say that all of these containers at some point in their runtime need to request a shared service. Each one of these containers can use the same ambassador when doing so. Then all of their requests can take advantage of the logging, monitoring, retries, etc. when making these external requests.

The ambassador pattern is a representation of the DRY principal. It allowsd you to consolidate the helper functionality around calling an external service and centralize it into a reusable and shareable component. This is improving code reuse and reducing code duplication at its finest.