API-DD

Practical guide

The type that turns a SQL change into an interface change

A Go example of an innocent-looking dependency: returning sql.Rows makes the consumer understand the query and turns internal changes into interface changes.

By API-DD

The catalog package contains the query that searches for products. Its public method, however, returns *sql.Rows:

package catalog

func (c *Catalog) Search(ctx context.Context, text string) (*sql.Rows, error) {
    return c.db.QueryContext(ctx, `
        SELECT id, title, price_cents
        FROM products
        WHERE title LIKE ?
        ORDER BY title
    `, "%"+text+"%")
}

The signature is short. The dependency it creates is not.

The controller consuming catalog must understand the database/sql protocol, close the rows, and—most importantly—repeat the exact shape of the query result:

rows, err := products.Search(r.Context(), r.URL.Query().Get("q"))
if err != nil {
    return err
}
defer rows.Close()

var result []ProductJSON
for rows.Next() {
    var item ProductJSON
    if err := rows.Scan(&item.ID, &item.Title, &item.PriceCents); err != nil {
        return err
    }
    result = append(result, item)
}
if err := rows.Err(); err != nil {
    return err
}

The query no longer ends inside catalog. It continues in the controller.

An internal change that is no longer internal#

Suppose the team replaces price_cents with a Money, adds a join, or changes the column order. Even if the screen's need remains unchanged, the controller must be edited too. Worse, if two swapped columns are strings, Scan may accept the change and put valid data in the wrong fields.

The specific problem is not SQL. It is that catalog returns a representation of its mechanism instead of an answer in its own vocabulary.

The question at the boundary is precise:

Does the consumer need to operate on SQL rows, or does it need to display catalog results?

In this case, it needs catalog results.

Make the response belong to the module#

The module can declare the message and result it intends to support:

package catalog

type SearchQuery struct {
    Text  string
    Limit int
}

type Summary struct {
    ID         string
    Title      string
    PriceCents int64
}

func (c *Catalog) Search(
    ctx context.Context,
    query SearchQuery,
) ([]Summary, error) {
    rows, err := c.db.QueryContext(ctx, `
        SELECT id, title, price_cents
        FROM products
        WHERE title LIKE ?
        ORDER BY title
        LIMIT ?
    `, "%"+query.Text+"%", query.Limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var result []Summary
    for rows.Next() {
        var item Summary
        if err := rows.Scan(&item.ID, &item.Title, &item.PriceCents); err != nil {
            return nil, err
        }
        result = append(result, item)
    }
    return result, rows.Err()
}

The controller is reduced to its own responsibility:

items, err := products.Search(r.Context(), catalog.SearchQuery{
    Text:  r.URL.Query().Get("q"),
    Limit: 20,
})
if err != nil {
    return err
}

return writeJSON(w, items)

Summary is also a representation, but now it is an explicit decision in the catalog API. The query, its column order, and the iteration protocol are under the module's control again.

Do not hide a real streaming requirement#

Returning a slice is not always the answer. If the consumer must process millions of items within a fixed memory budget, streaming is part of the conversation. Even then, the module does not need to expose *sql.Rows. It can offer its own iterator or a method such as ForEach(ctx, query, func(Summary) error), retaining the freedom to retrieve data from SQL, a file, or a remote API.

The rule is not “never return library types.” It is this:

If the consumer uses the type because of a domain need, it may belong in the contract. If it uses the type only because it reveals how the module works, the mechanism has leaked.

The visibility foundation develops the criterion: the API should publish what the consumer may assume while keeping the mechanism that fulfills it free to change.

A dependency is truly inside a module when changing it does not force a renegotiation with its consumers.