A logbook for curious waters
← Software Engineering Field Guide

Leaky Abstraction

Before you begin: Functions, objects, return values, and exceptions.

An abstraction lets you use a capability without dealing with every implementation detail. It is leaky when those supposedly hidden details become necessary to use the capability correctly. 1

A customer directory might promise to find a customer’s name. If its callers must catch a storage library’s exception for an ordinary missing customer, they have learned something about storage that the directory was meant to hide.

Original version: a wrapper preserves the surprise

The following in-memory stand-in represents a vendor library. It returns a name for customer 7 and throws for an unknown identifier. There is no database or network in this example.

public sealed class VendorDirectory
{
    private readonly Dictionary<int, string> names =
        new Dictionary<int, string> { { 7, "Mira" } };

    public string Read(int id)
    {
        return names[id];
    }
}

public sealed class CustomerDirectory
{
    private readonly VendorDirectory vendor;

    public CustomerDirectory(VendorDirectory vendor)
    {
        this.vendor = vendor;
    }

    public string GetName(int id)
    {
        return vendor.Read(id);
    }
}

GetName(8) exposes the dictionary’s KeyNotFoundException. The extra class has not established what a missing customer means. Every caller must either know this implementation detail or be surprised by it.

class VendorDirectory:
    def __init__(self):
        self._names = {7: "Mira"}

    def read(self, customer_id):
        return self._names[customer_id]


class CustomerDirectory:
    def __init__(self, vendor):
        self._vendor = vendor

    def get_name(self, customer_id):
        return self._vendor.read(customer_id)

get_name(8) exposes the dictionary’s KeyError. The extra class has not established what a missing customer means. Every caller must either know this implementation detail or be surprised by it.

Revised version: define the missing-customer result

Keep the same vendor implementation. Change the directory’s public contract: a missing name is an absent result, not a vendor exception.

public sealed class VendorDirectory
{
    private readonly Dictionary<int, string> names =
        new Dictionary<int, string> { { 7, "Mira" } };

    public string Read(int id) { return names[id]; }
}

public sealed class CustomerDirectory
{
    private readonly VendorDirectory vendor;

    public CustomerDirectory(VendorDirectory vendor)
    {
        this.vendor = vendor;
    }

    public string? FindName(int id)
    {
        try { return vendor.Read(id); }
        catch (KeyNotFoundException) { return null; }
    }
}
var directory = new CustomerDirectory(new VendorDirectory());
Console.WriteLine(directory.FindName(8) ?? "Customer not found");

The caller handles null, the absence documented by FindName. Only the known missing-key exception is translated; an unrelated failure is not relabeled as a missing customer.

class VendorDirectory:
    def __init__(self):
        self._names = {7: "Mira"}

    def read(self, customer_id):
        return self._names[customer_id]


class CustomerDirectory:
    def __init__(self, vendor):
        self._vendor = vendor

    def find_name(self, customer_id):
        try:
            return self._vendor.read(customer_id)
        except KeyError:
            return None
directory = CustomerDirectory(VendorDirectory())
name = directory.find_name(8)
print("Customer not found" if name is None else name)

The caller handles None, the absence documented by find_name. Only the known missing-key exception is translated; an unrelated failure is not relabeled as a missing customer.

Hide details, not reality

This deliberately changes the public error contract. Existing callers catching the old exception need migration and tests. With a real library, translate only the exception whose documented meaning is exactly “not found.” A broad catch could turn an outage into a false claim that no customer exists.

An abstraction cannot make all physical constraints disappear. A remote directory can be slow or unavailable. A database query can be expensive. Good interfaces describe consequential limits instead of promising that remote access behaves exactly like reading memory.

The diagnostic question is concrete: which implementation fact must callers know, and should it be part of the documented contract or handled inside the abstraction? Adding another wrapper without answering that question adds distance, not clarity.

Sources

  1. Joel Spolsky — The Law of Leaky Abstractions, on implementation details that reappear at an abstraction’s surface.
  2. Microsoft — Best practices for exceptions, on handling failures deliberately rather than swallowing them.