javamoduleapi-designinterface-designleaky-abstraction

Accessing implementation specific methods on an object that is returned to its API


Let me start by abstractly formulating the problem: I have two public interface types. One of them contains a method which receives at least two instances of the other interface type. The implementation of the method depends on the implementation of the passed objects.

Consider the following public API, which consists of two interfaces:

public interface Node {
}

public interface Tree {
    void connect(Node parent, Node child);
}

Now, I want to implement that API, like so:

public class NodeImpl implements Node {
    private final Wrapped wrapped;

    public NodeImpl(Wrapped wrapped) {
        this.wrapped = wrapped;
    }

    public Wrapped getWrapped() {
        return wrapped;
    }
}

public class TreeImpl implements Tree {
    @Override
    public void connect(Node parent, Node child) {
        // connect parent and child using the wrapped object
    }
}

public class Wrapped {
    // wrapped object which actually represents the node internally
}

I need to access the wrapped objects in the connect method, which is impossible, because the getWrapped method is not part of the API. It is an implementation detail.

So the question is: How can I implement the connect method without leaking implementation detail to the API?

Here is what I tried so far:

Please note, that the Node interface might contain methods. However, this is unimportant for this question.

Also, please note that I am in control of both: The interface declaration as well as the implementation.


Another attempt to solve this is to convert the connect method to a addChild method in the Node interface and to make the Node interface generic:

public interface Node<T extends Node<T>> {
    void addChild(Node<T> child);
}

public class NodeImpl implements Node<NodeImpl> {
    private final Wrapped wrapped;

    public NodeImpl(Wrapped wrapped) {
        this.wrapped = wrapped;
    }

    public Wrapped getWrapped() {
        return wrapped;
    }

    @Override
    public void addChild(Node<NodeImpl> child) {
    }
}

public class Wrapped {
    // wrapped object which actually represents the node internally
}

public Node<NodeImpl> createNode() {
    return new NodeImpl(new Wrapped());
}

private void run() {
    Node<NodeImpl> parent = createNode();
    Node<NodeImpl> child = createNode();
    parent.addChild(child);
}

Node and createNode are part of the public API. NodeImpl and Wrapped should be hidden. run is the client code. As you can see, NodeImpl has to be visible to the client, so this is still a leaking abstraction.


Solution

  • The solution is to add the implementing class as a bounded type parameter, without exposing it to the API user. This can be achieved by using a ? as the type parameter in the return type, when creating the factory. In this way, the public API exposes none of the implementing classes (NodeFactoryImpl, NodeImpl, and Wrapped) to the API user.

    The API user can then match the ? with a bounded type parameter on a method (see connectN). In this way, the compiler is able to type-check the API usage without knowing the implementing classes and the private API implementation is able to work with the implementing classes without using downcasts.

    Note that the method call parent.addChild(child) only type-checks correctly, because the compiler is able to verify that both parent and child are of the same type N, which extends Node<N>. This call would not type-check, if parent and child were created by different factory instances.

    Note also, that to match the existential type ? with a bounded type parameter, we need to call a new method. There is currently no other way to work with existential types in Java.

    Client code using API:

    private void connect() {
        connectN(NodeFactory.createFactory());
    }
    
    private <N extends Node<N>> void connectN(NodeFactory<N> factory) {
        N parent = factory.createNode();
        N child = factory.createNode();
        parent.addChild(child);
    }
    

    Public API:

    public interface NodeFactory<N extends Node<N>> {
        public static NodeFactory<?> createFactory() {
            return new NodeFactoryImpl();
        }
        
        N createNode();
    }
    
    public interface Node<N extends Node<N>> {
        void addChild(N child);
    }
    

    Private API implementation:

    public class NodeFactoryImpl implements NodeFactory<NodeImpl> {
        public NodeImpl createNode() {
            return new NodeImpl(new Wrapped());
        }
    }
    
    public class NodeImpl implements Node<NodeImpl> {
        private final Wrapped wrapped;
    
        public NodeImpl(Wrapped wrapped) {
            this.wrapped = wrapped;
        }
    
        public Wrapped getWrapped() {
            return wrapped;
        }
    
        @Override
        public void addChild(NodeImpl child) {
            // use wrapped objects to connect the nodes
        }
    }
    
    public class Wrapped {
        // wrapped object which actually represents the node internally
    }