In this post we’ll try to raise the risks of implicit coupling created by leaky abstractions, and how this can be mitigated (in some cases) with a stronger contract that better seals the leaks of the abstraction. Let’s go first into the basics.

Avoid leaking information in your code!

Coupling

Coupling is according to Wikipedia:

[…] the degree of interdependence between software modules; a measure of how closely connected two routines or modules are; the strength of the relationships between modules

Any code ever written will be coupled to other modules, be it the framework we are building upon (Java, C++ std libs, …), or modules we have built on our own (other classes, libraries,…).

Abstraction

the quality of dealing with ideas rather than events.

In Software Engineering, an abstraction provides a simple interface to interact with a more complex module, to solve a problem we have.

It can be either a protocol (HTTPS, TCP, GraphQL,…) or an interface (command line interfaces such as mkdir, an interface you write in your code, printf,…).

The main benefit of this is to enable us to compose and build on top of other modules, while loosely coupling to them.

Leaky abstractions

A leaky abstraction can be understood as an abstraction that fails to abstract away all its concerns, forcing the consumers to also have to consider its limitations.

Consider using an ORM, only to find that doesn’t support cascade-deleting records. You’ll have to roll out your own cascade-delete mechanism.

Another example is failing to resolve the hostname via DNS when using HTTP. Despite dealing with a higher level of abstraction, we still are affected by underlying network conditions, and have to consider what impact they could have on our codebase.

All non-trivial abstractions, to some degree, are leaky.

Implicit coupling

When it comes to Computer Science, coupling is often measured in how many references to one module the module we are working with has. In order to lower the coupling between modules, following the Dependency inversion principle, we will produce code similar to the following:

public interface Logger {

void log(String message);

} public class ConsoleLogger implements Logger {

public void log(String message) {

System.out.println(message);

}

} public class MyClass {

private Logger logger;

//.. void myMethod() {

logger.log("Hello world!");

}

}

Following this pattern, we decouple MyClass from System.out.println and the ConsoleLogger by introducing an abstraction, the Logger interface, which will allow us to change the implementation without the consumer being affected.

Then again, what happens if we are decoupling with a leaky abstraction?

Let’s compare two different abstractions aiming to provide an API client listener for consumers to manage network errors.

Example 1:

public interface APIClientListener {

...

void didFail(Exception exception);

}

In the first example, we are calling our listener with a generic Exception type. This keeps the implementors of this abstraction decoupled from the APIClient . However, when we have to consume this interface, we will have to consider “In what ways will the APIClient fail?”, and this abstraction will offer no help.

The developer will have to look into the APIClient implementation, and study how errors are handled, and in what way they are thrown. Since we are using a generic Java Exception , it’s likely that we will also need to study what the dependencies the APIClient depends upon in order to be certain of what the different error scenarios will be.

The outcome of this work will be an integration with APIClient that contains a set of assumptions. When we change the behaviour of APIClient , we will have to consider how all consumers expect us to behave, otherwise we’ll risk introducing bugs as we break the assumptions the consumers built upon.

Example 2:

public enum APIClientError {

UNAUTHORIZED, NOT_FOUND, NO_CONNECTIVITY

} public interface APIClientListener {

...

void didFail(APIClientError error);

}

In this example we have strengthened the contract offered by APIClient to consumers by defining an enum with the different kinds of errors the APIClient can raise.

When we consume this APIClient , all the investigation we had to do to understand how it manages errors will not be required, nor any assumptions will need to be made.

When we update our APIClient , we won’t need to think what the consumers assumptions from the APIClient are, since they are clearly documented in the exposed API.

This results in both APIClient and its consumers being able to change independently without having to think too much about each other.

Summary

In summary, by using leaky abstractions, we will introduce coupling between the modules we are trying to decouple.

This coupling won’t be made explicit in our codebase through dependencies such as an import/include/require, or a use of a type.

Instead, this coupling will be present implicitly as a set of assumptions on how the abstraction we are decoupling should work.

These assumptions will force us to have to consider many modules at once when working on our codebase so as to avoid breaking them, introducing new bugs on our system.

Having to consider many modules at once will make our system more complex.

In order to avoid implicit coupling, create a stronger contract between the abstraction and consumer by modeling into your code the behaviour that both must comply with. Some of the usual ways:

If an action can be cancelled, offer in your abstraction a way to do so.

If multiple error scenarios can occur, ensure there’s an enumeration of what errors could occur.

When both sides of the abstraction are owned by you, build a common domain model. That is, prefer a Person class over String name, String surname, int age parameters.

class over parameters. Avoid stringly typed interfaces.

If you are throwing exceptions, use types you own.

If your language doesn’t support checked exceptions, prefer to pass an error enum, preferably in an Either monad.

References