What problems does the adapter design pattern solve?

Published in the Java Developer group
Software development is made more difficult by incompatible components that need to work together. For example, if you need to integrate a new library with an old platform written in earlier versions of Java, you may encounter incompatible objects, or rather incompatible interfaces. What problems does the adapter design pattern solve? - 1What to do in this case? Rewrite the code? We can't do that, because analyzing the system will take a lot of time or the application's internal logic will be violated. To solve this problem, the adapter pattern was created. It helps objects with incompatible interfaces to work together. Let's see how to use it!

More about the problem

First, we'll simulate the behavior of the old system. Suppose it generates excuses for being late to work or school. To do this, it has an Excuse interface that has generateExcuse(), likeExcuse() and dislikeExcuse() methods.

public interface Excuse {
   String generateExcuse();
   void likeExcuse(String excuse);
   void dislikeExcuse(String excuse);
}
The WorkExcuse class implements this interface:

public class WorkExcuse implements Excuse {
   private String[] excuses = {"in an incredible confluence of circumstances, I ran out of hot water and had to wait until sunlight, focused using a magnifying glass, heated a mug of water so that I could wash.",
   "the artificial intelligence in my alarm clock failed me, waking me up an hour earlier than normal. Because it is winter, I thought it was still nighttime and I fell back asleep. Everything after that is a bit hazy.",
   "my pre-holiday mood slows metabolic processes in my body, leading to depression and insomnia."};
   private String [] apologies = {"This will not happen again, of course. I'm very sorry.", "I apologize for my unprofessional behavior.", "There is no excuse for my actions. I am not worthy of this position."};

   @Override
   public String generateExcuse() { // Randomly select an excuse from the array
       String result = "I was late today because " + excuses[(int) Math.round(Math.random() + 1)] + "\\n" +
               apologies[(int) Math.round(Math.random() + 1)];
       return result;
   }

   @Override
   public void likeExcuse(String excuse) {
       // Duplicate the element in the array so that its chances of being chosen are higher
   }

   @Override
   public void dislikeExcuse(String excuse) {
       // Remove the item from the array
   }
}
Let's test our example:

Excuse excuse = new WorkExcuse();
System.out.println(excuse.generateExcuse());
Output:

"I was late today because my pre-holiday mood slows metabolic processes in my body, leading to depression and insomnia.
I apologize for my unprofessional behavior.
Now imagine that you've launched an excuse-generating service, collected statistics, and noticed that most of your users are university students. To better serve this group, you asked another developer to create a system that generates excuses specifically for university students. The development team conducted market research, ranked excuses, hooked up some artificial intelligence, and integrated the service with traffic reports, weather reports, and so on. Now you have a library for generating excuses for university students, but it has a different interface: StudentExcuse.

public interface StudentExcuse {
   String generateExcuse();
   void dislikeExcuse(String excuse);
}
This interface has two methods: generateExcuse, which generates an excuse, and dislikeExcuse, which prevents the excuse from appearing again in the future. The third-party library cannot be edited, i.e. you cannot change its source code. What we have now is a system with two classes that implement the Excuse interface, and a library with a SuperStudentExcuse class that implements the StudentExcuse interface:

public class SuperStudentExcuse implements StudentExcuse {
   @Override
   public String generateExcuse() {
       // Logic for the new functionality
       return "An incredible excuse adapted to the current weather conditions, traffic jams, or delays in public transport schedules.";
   }

   @Override
   public void dislikeExcuse(String excuse) {
       // Adds the reason to a blacklist
   }
}
The code cannot be changed. The current class hierarchy looks like this: What problems does the adapter design pattern solve? - 2This version of the system only works with the Excuse interface. You cannot rewrite the code: in a large application, making such changes could become a lengthy process or break the application's logic. We could introduce a base interface and expand the hierarchy: What problems does the adapter design pattern solve? - 3To do this, we have to rename the Excuse interface. But the extra hierarchy is undesirable in serious applications: introducing a common root element breaks the architecture. You should implement an intermediate class that will let us use both the new and old functionality with minimal losses. In short, you need an adapter.

The principle behind the adapter pattern

An adapter is an intermediate object that allows the method calls of one object to be understood by another. Let's implement an adapter for our example and call it Middleware. Our adapter must implement an interface that is compatible with one of the objects. Let it be Excuse. This allows Middleware to call the first object's methods. Middleware receives calls and forwards them in a compatible way to the second object. Here's is the Middleware implementation with the generateExcuse and dislikeExcuse methods:

public class Middleware implements Excuse { // 1. Middleware becomes compatible with WorkExcuse objects via the Excuse interface

   private StudentExcuse superStudentExcuse;

   public Middleware(StudentExcuse excuse) { // 2. Get a reference to the object being adapted
       this.superStudentExcuse = excuse;
   }

   @Override
   public String generateExcuse() {
       return superStudentExcuse.generateExcuse(); // 3. The adapter implements an interface method
   }

    @Override
    public void dislikeExcuse(String excuse) {
        // The method first adds the excuse to the blacklist,
        // Then passes it to the dislikeExcuse method of the superStudentExcuse object.
    }
   // The likeExcuse method will appear later
}
Testing (in client code):

public class Test {
   public static void main(String[] args) {
       Excuse excuse = new WorkExcuse(); // We create objects of the classes
       StudentExcuse newExcuse = new SuperStudentExcuse(); // that must be compatible.
       System.out.println("An ordinary excuse for an employee:");
       System.out.println(excuse.generateExcuse());
       System.out.println("\n");
       Excuse adaptedStudentExcuse = new Middleware(newExcuse); // Wrap the new functionality in the adapter object
       System.out.println("Using new functionality with the adapter:");
       System.out.println(adaptedStudentExcuse.generateExcuse()); // The adapter calls the adapted method
   }
}
Output:

An ordinary excuse for an employee:
I was late today because my pre-holiday mood slows metabolic processes in my body, leading to depression and insomnia.
There is no excuse for my actions. I am not worthy of this position. Using new functionality with the adapter:
An incredible excuse adapted to the current weather conditions, traffic jams, or delays in public transport schedules. The generateExcuse method simply passes the call to another object, without any additional changes. The dislikeExcuse method required us to first blacklist the excuse. The ability to perform intermediate data processing is a reason why people love the adapter pattern. But what about the likeExcuse method, which is part of the Excuse interface but not part of the StudentExcuse interface? The new functionality does not support this operation. The UnsupportedOperationException was invented for this situation. It is thrown if the requested operation is not supported. Let's use it. This is how the Middleware class's new implementation looks:

public class Middleware implements Excuse {

   private StudentExcuse superStudentExcuse;

   public Middleware(StudentExcuse excuse) {
       this.superStudentExcuse = excuse;
   }

   @Override
   public String generateExcuse() {
       return superStudentExcuse.generateExcuse();
   }

   @Override
   public void likeExcuse(String excuse) {
       throw new UnsupportedOperationException("The likeExcuse method is not supported by the new functionality");
   }

   @Override
   public void dislikeExcuse(String excuse) {
       // The method accesses a database to fetch additional information,
       // and then passes it to the superStudentExcuse object's dislikeExcuse method.
   }
}
At first glance, this solution doesn't seem very good, but imitating the functionality can complicate the situation. If the client pays attention, and the adapter is well documented, such a solution is acceptable.

When to use an adapter

  1. When you need to use a third-party class, but its interface is incompatible with the main application. The example above shows how to create an adapter object that wraps calls in a format that a target object can understand.

  2. When several existing subclasses need some common functionality. Instead of creating additional subclasses (which will lead to duplication of code), it is better to use an adapter.

Advantages and disadvantages

Advantage: The adapter hides from the client the details of processing requests from one object to another. The client code doesn't think about formatting data or handling calls to the target method. It's too complicated, and programmers are lazy :) Disadvantage: The project's code base is complicated by additional classes. If you have a lot of incompatible interfaces, the number of additional classes can become unmanageable.

Don't confuse an adapter with a facade or decorator

With only a superficial inspection, an adapter could be confused with the facade and decorator patterns. The difference between an adapter and a facade is that a facade introduces a new interface and wraps the whole subsystem. And a decorator, unlike an adapter, changes the object itself rather than the interface. What problems does the adapter design pattern solve? - 4

Step-by-step algorithm

  1. First, be sure you have a problem that this pattern can solve.

  2. Define the client interface that will be used to indirectly interact with incompatible objects.

  3. Make the adapter class inherit the interface defined in the previous step.

  4. In the adapter class, create a field to store a reference to the adaptee object. This reference is passed to the constructor.

  5. Implement all the client interface methods in the adapter. A method may:

    • Pass along calls without making any changes

    • Modify or supplement data, increase/decrease the number of calls to the target method, etc.

    • In extreme cases, if a particular method remains incompatible, throw an UnsupportedOperationException. Unsupported operations must be strictly documented.

  6. If the application only uses the adapter class through the client interface (as in the example above), then the adapter can be painlessly expanded in the future.

Of course, this design pattern is not a panacea for all ills, but it can help you elegantly solve the problem of incompatibility between objects with different interfaces. A developer who knows the basic patterns is several steps ahead of those who only know how to write algorithms, because design patterns are required to create serious applications. Code reuse isn't so difficult, and maintenance becomes enjoyable. That's all for today! But we'll soon continue to get to know various design patterns :)
Comments (1)
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION
Lisa Level 41
29 January 2022
I like the first excuse the best but for some reason I can't understand it is never shown :)