Design patterns - Dependency Inversion Principle

From Juneday education
Jump to: navigation, search

Videos and slides are on our TODO

Description

In this chapter, we'll discuss the Dependency Inversion Principle.

Aim of the principle

The intent of this principle is to get re-usable high-level components. In order to create any re-usable components at all, we should as usual decouple components (classes, objects) from each other and one way of doing so is to always program against a super-type (like an interface for instance). But there's more to it than that!

The Dependency Inversion Principle states that:

A. High-level modules should not depend upon low-level modules. Both should depend upon abstractions.

B. Abstractions should not depend upon details. Details should depend upon abstractions.

The first part is most relevant to Java and we'll focus on that part. First of all, we should think about what "high-level modules" mean. We'd like to think about a high-level module as a high-level abstraction. A high-level abstraction is an abstraction which uses other abstractions. In a system, a high-level abstraction could be a GUI which uses some components, which it gets from some kind of storage. Components and Storage are abstractions, so the GUI is on a higher abstraction level. A Storage can be described as a component which allows us to fetch and save for instance products. Fetch, Save and Product are abstractions, so the Storage then is on a higher level than the actual way Products are fetched, etc.

The Dependency Inversion Principle takes things a step further than just saying that we should use abstractions and that we should use interfaces or abstract classes to describe the abstractions. It also says that the high-level module should be completely shielded from low-level modules using abstractions as the only thing the high-level module should depend on. When we say "depend on", we usually mean that if something changes, it affects everything depending on the thing that changed. So, we want to protect our high-level module from changes in low-level modules.

Note that this is not only the matter of being able to change a low-level module by replacing it with another low-level module. We are also interested in not being affected by changes inside the low-level module.

The principle also wants the high-level module to own the abstractions it is depending on. This is an important difference from simply programming against a super-type! One way to own the abstraction is to have the first say on what the abstraction is, and when to change it if ever. And how can we make the high-level module own the abstraction? By letting the team responsible for the high-level module write the abstraction (perhaps an interface) and make it part of their Java package!

This is an excellent way to ensure that the abstraction described in the e.g. interface fits the needs of the high-level module (and probably also in a language which fits them well - they get to name the interface and the methods). Making it part of their package, also saves them the problem of depending on the low-level module by using import statements to get access to the abstraction.

If the interface on the other hand, would be part of the low-level module, they'd be forced to "know about" (depend on) the package for the low-level module and import it. And if the low-level module team owns the abstraction (interface), it's kind of more likely that they write the interface to serve their needs, based on their implementation. It's also likely that they'd change the interface if they discover that their implementation needs to change.

So, the aim of the Dependency Inversion Principle, is to make sure that the abstractions for the high-level module is under the team for the high-level product's control. Only they decide if and how it may change. This puts the team of the low-level module in a position where they depend on the abstraction as well. This is actually an improvement over how things traditionally gets designed in more procedural languages.

In non-OO languages, some claim, it is not uncommon to start with low-level code, and work your way up to the highest abstraction. Doing so, claim the proponents of the Dependency Inversion Principle and OO languages, typically leads to the low-level code dictating the higher-level code what abstractions to use.

We're not sure this is always the case, but we see the problem that would mean if it is true. We'll start with an example where the abstractions are not in place and where components are far too coupled with each other.

Problem description

Let's pretend we are going to write a system for a motion detector which turns on a lamp if it detects motion within some area. To test our code, we are writing a simulation in Swing.

Here's the first design of the Detector and Lamp classes (we're using a panel for the area to monitor, and a background color to signify that the lamp is lit):

class Lamp {

  private JPanel area;

  public Lamp(JPanel area) {
    this.area = area;
  }

  public void turnOn() {
    area.setBackground(Color.RED);
  }

  public void turnOff() {
    area.setBackground(Color.GREEN);
  }
}

class LampSensor extends MouseAdapter {

  private Lamp lamp;

  public LampSensor(Lamp lamp) { this.lamp = lamp; }

  public void mouseEntered(MouseEvent me) {
    lamp.turnOn();
  }

  public void mouseExited(MouseEvent me) {
    lamp.turnOff();
  }
}

(You'll have to imagine the import statements needed).

So, the LampSensor has access to its Lamp and when it detects motion (the mouse will simulate that) it will turn the light on or off.

The Lamp, in our simulation, is simply a JPanel which is green when the lamp is off, and red when the lamp is turned on.

Now, we can use the LampSensor as a MouseListener on a JPanel (which we wrap inside a Lamp) in a Swing application for our simulation:

public class LampSensorGUI {

  private LampSensor lampSensor;
  private JPanel sensorArea; // Center of the application is the monitored area
  private JPanel west;
  private JPanel east;
  private JPanel north;
  private JPanel south;
  private JFrame frame;
  private JLabel outsideS, outsideW, outsideE,outsideN;
  private JLabel detection;
  Lamp lamp;

  public LampSensorGUI() {
    initComponents();
    layoutComponents();
    frame.setVisible(true);
  }

  private void initComponents() {
    frame = new JFrame("Layout changer");
    frame.setLayout(new BorderLayout());
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setPreferredSize(new Dimension(500,500));
    sensorArea = new JPanel();
    west = new JPanel();
    east = new JPanel();
    north = new JPanel();
    south = new JPanel();
    sensorArea = new JPanel();
    outsideW = new JLabel("Outside detection");
    outsideE = new JLabel("Outside detection");
    outsideS = new JLabel("Outside detection");
    outsideN = new JLabel("Outside detection");
    detection = new JLabel("Detection area");
    sensorArea.setBackground(Color.GREEN);
    lamp=new Lamp(sensorArea);
    lampSensor = new LampSensor(lamp);
    sensorArea.addMouseListener(lampSensor);
  }

  private void layoutComponents() {
    west.add(outsideW);
    east.add(outsideE);
    north.add(outsideN);
    south.add(outsideS);
    sensorArea.add(detection);
    frame.add(sensorArea, BorderLayout.CENTER);
    frame.add(west, BorderLayout.WEST);
    frame.add(east, BorderLayout.EAST);
    frame.add(south, BorderLayout.SOUTH);
    frame.add(north, BorderLayout.NORTH);
    frame.pack();
  }

  public static void main(String[] args) {
    new LampSensorGUI();
  }
}

The application actually works as we hoped. When the mouse enters the central monitored area (the JPanel for the LampSensor and the Lamp), the Lamp lights up and the area turns red. When the mouse exits the area, the area turns green (meaning no movement is detected and the Lamp is off).

What is the problem with our design?

What if we want to monitor a different area, such as North, and light up a different area, such as South?

How would we do that in our design? It would be very hard without changing a lot of code. The interesting lines of code are these:

    lamp = new Lamp(sensorArea);
    lampSensor = new LampSensor(lamp);
    sensorArea.addMouseListener(lampSensor);

The LampSensor knows about the Lamp! It needs a reference to the Lamp in order to turn it on or off. It gets the Lamp as an argument to the constructor. To make things worse, it knows that the Lamp is a JPanel (which is the type for the constructor argument). This is typically programming to an implementation, and not a super-type. And to make things even worse, the Lamp too knows about the very same JPanel. Not only are Lamp and LampSensor both using an implementation rather than a super-type, they contain references to the same JPanel.

This makes it very tricky to change the code so that the LampSensor affects something which is not a JPanel. In the real world, this would be almost like a motion sensor which can only be connected to a Lamp and not an alarm for instance.

Another problem with the design is that it is the client code's responsibility to register the lampSensor as a listener to the the sensorArea JPanel. Why can't the sensor attach itself to the area it is monitoring?

Solution - Using more abstractions!

Note that we've removed the import statements from the code for brevity. You will need to add them for the exercise.

What we want to do is to use abstractions between the sensor and the thing it is controlling. We want an abstraction of a "Sensor" which detects motion and notifies a "SensorClient" about the presence or absence of motion. Should we care what the Sensor is notifying? No, it should be able to notify anything which qualifies as a SensorClient (a device which can accept signals from a motion sensor). It could be a "sensor-capable lamp" for instance. So we should make the Sensor unaware of the Lamp.

The solution we went for, was to create two interfaces for the Sensor - SensorClient relationship:

public interface Sensor {
  public void setClient(SensorClient client);
  public void sendDetected();
  public void sendUndetected();
}

It should be possible to change client to the sensor at runtime, so we declare a setClient() method which accepts a SensorClient reference. The SensorClient could also be set via a constructor (but we can't declare constructors in an interface - you could use an abstract class for the Sensor as an alternative approach!).

The other two methods define the abstraction of a Sensor, it can send signals somewhere, when it detects motion or that motion is absent.

So, let's look at our abstraction for the SensorClient:

public interface SensorClient {
  public void inside();
  public void outside();
}

We decided to define an interface for all devices which should work with a Sensor, the capability to receive the signals of motion or no motion (inside() is supposed to mean that motion is inside the monitored area, and outside() that the motion is not in the area).

So how can we create a concrete Sensor? Let's look at the code for a possible implementation of a MotionSensor:

public class MotionSensor extends MouseAdapter implements Sensor {
  private SensorClient client;
  private JComponent area;

  // Pass in a reference to the area to monitor
  public MotionSensor(JComponent area) {
    this.area = area;
    area.addMouseListener(this);
    // Cool if it detects also movement while inside
    area.addMouseMotionListener(this);    
  }

  @Override
  public void setClient(SensorClient client) {
    this.client = client;
  }

  @Override
  public void sendDetected() {
    client.inside();
    Timer t = new Timer();
    t.schedule(new TimerTask() {
        public void run(){
          sendUndetected();
        }
      }, 2000);
  }

  @Override
  public void sendUndetected() {
    client.outside();    
  }

  @Override
  public void mouseEntered(MouseEvent me) {
    sendDetected();
  }

  @Override
  public void mouseExited(MouseEvent me) {
    sendUndetected();
  }

  @Override
  public void mouseMoved(MouseEvent me) {
    Point mousePos = MouseInfo.getPointerInfo().getLocation();
    Rectangle bounds = area.getBounds();
    bounds.setLocation(area.getLocationOnScreen());
    if (bounds.contains(mousePos)) {
      sendDetected();
    }
  }
}

There are a few notable improvements here! First, we are using JComponent for the area to monitor. That is surely more general than JPanel. Second, the MotionSensor is responsible for attaching listeners to the area. It actually also added a mouse motion listener in addition to the mouse listener from the first design. It can now detect continuous movement inside the area, not just entering and leaving. It also turns of the client after 2 seconds of inactivity.

The client is now of type SensorClient, so the Sensor doesn't know about the concrete type of the client. Here's what our implementaion of the Lamp as a SensorClient looks like:

public class Lamp implements SensorClient {

  private JComponent component;
  private Color origColor;

  public Lamp(JComponent component) {
    this.component = component;
    origColor=component.getBackground();
  }

  public void inside() {
    component.setBackground(Color.RED);
  }

  public void outside() {
    component.setBackground(origColor);
  }
}

It's worth noting here, that the Lamp in this new design has no reference to the Sensor. It gets called via the inside() and outside() methods, whenever someone wants to turn it on or off. It could just as well be a light-switch actually (a lamp button).

Now, the client code has become simpler. The client is no longer responsible for adding the listener(s) to the monitored area. It just has to set up a Lamp and set it as the client to a MotionListener which is created with the monitored area as argument to the MotionListener constructor.

Exercises

Task 1 - Change the LampSensorGUI application to use more abstractions

Your task is to change the system from the problem description to use the new abstractions and classes. You may copy the classes above, but you should try and change the LampSensorGUI class yourself!

You need to figure out what import statements you need for the new classes. If you put your classes in packages, you'll need to consider also that for the import statements, of course.

Here are some snippets which may come in handy (in no particular order, you'll figure that out):

private MotionSensor lampSensor;in 
lampSensor.setClient(lamp);
lamp=new Lamp(ADD_THE_COMPONENT_YOU_WANT_AS_LAMP_HERE);
private SensorClient lamp;
lampSensor = new MotionSensor(ADD_THE_COMPONENT_YOU_WANT_AS_THE_MONITORED_AREA_HERE);

If you want to devide your classes into packages, we suggest the following layout:

.
`-- org
    `-- inversion
        |-- gadgets
        |   `-- Lamp.java
        |-- gui
        |   `-- LampSensorGUI.java
        `-- sensor
            |-- MotionSensor.class
            |-- MotionSensor.java
            |-- SensorClient.java
            `-- Sensor.java

Note that the MotionSensor team now "owns" the interfaces/abstractions it needs (the team developing the MotionSensor has control over the Sensor and SensorClient interfaces, since they are in the same package). An alternative could be to view the GUI as the highest abstraction and let it own the Sensor and SensorClient abstractions, and put the MotionSensor and Lamp in the gadgets (it depends on the perspective). The GUI here serves more as a simulation, so we didn't make it "owner" of the interfaces. It is not easy to come up with a medium size exercise which fully shows the dependency inversion principle, but we hope that you will learn something from this minimal example.

Task 2 - Change what area is monitored and what area will be the "lamp"

Now, play around with the MotionSensor in the GUI! Try different combinations of areas to monitor and components to use for the lamp (the coloring). Add some new type of components, like JButtons etc, and see if you can use them both as monitoring areas and for the lamp.

There are no solutions for this extra exercise. Use your imagination.

Suggested solutions

Expand using link to the right to see the full content.

You can download source code including suggested solutions TODO: here (github). But here are the source code for the suggested solutions:

Suggested solutions to the first exercise

// org/inversion/gui/LampSensorGUI.java
package org.inversion.gui;

import java.awt.*;
import java.awt.event.*;
import javax.swing.*;
import org.inversion.sensor.Sensor;
import org.inversion.sensor.SensorClient;
import org.inversion.sensor.MotionSensor;
import org.inversion.gadgets.Lamp;

public class LampSensorGUI {
  private JPanel sensorArea;
  private JPanel west;
  private JPanel east;
  private JPanel north;
  private JPanel south;
  private JFrame frame;
  private JLabel outsideS, outsideW, outsideE,outsideN;
  private JLabel detection;
  private Sensor lampSensor;
  private SensorClient lamp;

  public LampSensorGUI() {
    initComponents();
    layoutComponents();
    frame.setVisible(true);
  }

  private void initComponents() {
    frame = new JFrame("Layout changer");
    frame.setLayout(new BorderLayout());
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setPreferredSize(new Dimension(500,500));
    sensorArea = new JPanel();
    west = new JPanel();
    east = new JPanel();
    north = new JPanel();
    south = new JPanel();
    sensorArea = new JPanel();
    outsideW = new JLabel("Outside detection");
    outsideE = new JLabel("Outside detection");
    outsideS = new JLabel("Outside detection");
    outsideN = new JLabel("Outside detection");
    detection = new JLabel("Detection area");
    sensorArea.setBackground(Color.GREEN);
    lamp=new Lamp(south);
    lampSensor = new MotionSensor(north);
    lampSensor.setClient(lamp);
  }

  private void layoutComponents() {
    west.add(outsideW);
    east.add(outsideE);
    north.add(outsideN);
    south.add(outsideS);
    sensorArea.add(detection);
    frame.add(sensorArea, BorderLayout.CENTER);
    frame.add(west, BorderLayout.WEST);
    frame.add(east, BorderLayout.EAST);
    frame.add(south, BorderLayout.SOUTH);
    frame.add(north, BorderLayout.NORTH);
    frame.pack();
  }

  public static void main(String[] args) {
    new LampSensorGUI();
  }
}
// org/inversion/gadgets/Lamp.java
package org.inversion.gadgets;

import java.awt.*;
import javax.swing.*;
import org.inversion.sensor.SensorClient;

public class Lamp implements SensorClient {

  private JComponent component;
  private Color origColor;

  public Lamp(JComponent component) {
    this.component = component;
    origColor=component.getBackground();
  }

  public void inside() {
    component.setBackground(Color.RED);
  }

  public void outside() {
    component.setBackground(origColor);
  }
}
// org/inversion/sensor/MotionSensor
package org.inversion.sensor;

import java.awt.event.*;
import javax.swing.*;
import java.awt.*;
import java.util.Timer;
import java.util.TimerTask;

public class MotionSensor extends MouseAdapter implements Sensor {

  private SensorClient client;
  private JComponent area;

  public MotionSensor(JComponent area) {
    this.area = area;
    //System.out.println(area);
    area.addMouseListener(this);
    // Cool if it detects also movement while inside
    area.addMouseMotionListener(this);    
  }

  public void setClient(SensorClient client) {
    this.client = client;
  }

  public void sendDetected() {
    client.inside();
    Timer t = new Timer();
    t.schedule(new TimerTask() {
        public void run() {
          sendUndetected();
        }
      }, 2000);
  }

  public void sendUndetected() {
    client.outside();    
  }

  public void mouseEntered(MouseEvent me) {
    sendDetected();
  }

  public void mouseExited(MouseEvent me) {
    sendUndetected();
  }

  @Override
  public void mouseMoved(MouseEvent me) {
    Point mousePos = MouseInfo.getPointerInfo().getLocation();
    Rectangle bounds = area.getBounds();
    bounds.setLocation(area.getLocationOnScreen());
    //System.out.println(bounds);
    if (bounds.contains(mousePos)) {
      sendDetected();
    }
  }
}
// org/inversion/sensor/Sensor.java
package org.inversion.sensor;
public interface Sensor {
  public void setClient(SensorClient client);
  public void sendDetected();
  public void sendUndetected();
}
// org/inversion/sensor/SensorClient.java
package org.inversion.sensor;

public interface SensorClient {
  public void inside();
  public void outside();  
}

Chapter links

Videos

Slides and videos are on our TODO list! You'll have to do with the text above and examples.

Source code

  • Github: dependency-inversion-principle repository

Books this chapter is a part of

Further reading

Book TOC | previous chapter | next chapter