Guía práctica
El tipo que convierte un cambio de SQL en un cambio de interfaz
Un ejemplo en Go de una dependencia que parece inocente: devolver sql.Rows obliga al consumidor a conocer la consulta y convierte cambios internos en cambios de interfaz.
El paquete catalog contiene la consulta que busca productos. Sin embargo, su método público devuelve *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+"%")
}La firma es corta. La dependencia que crea no.
El controlador que consume catalog tiene que conocer el protocolo de database/sql, cerrar las filas y, sobre todo, repetir la forma exacta del resultado de la consulta:
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
}Ahora la consulta no termina en catalog. Continúa en el controlador.
Un cambio interno que ya no es interno#
Supón que el equipo reemplaza price_cents por un Money, añade una unión o cambia el orden de las columnas. Aunque la necesidad de la pantalla siga siendo la misma, también debe editar el controlador. Peor aún: si dos columnas intercambiadas son cadenas, Scan puede aceptar el cambio y colocar datos válidos en campos equivocados.
El problema concreto no es SQL. Es que catalog entrega una representación de su mecanismo en lugar de una respuesta de su propio vocabulario.
La pregunta para el límite es precisa:
¿El consumidor necesita operar con filas SQL o necesita mostrar resultados del catálogo?
En este caso necesita resultados del catálogo.
Haz que la respuesta pertenezca al módulo#
El módulo puede declarar el mensaje y el resultado que quiere sostener:
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()
}El controlador queda reducido a su responsabilidad:
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 también es una representación, pero ahora es una decisión explícita de la API de catalog. La consulta, el orden de sus columnas y el protocolo de iteración vuelven a quedar bajo control del módulo.
No escondas una necesidad real de streaming#
Devolver un slice no es siempre la respuesta. Si el consumidor debe procesar millones de elementos con memoria acotada, el streaming forma parte de la conversación. Aun así, no hace falta entregar *sql.Rows. El módulo puede ofrecer un iterador propio o un método como ForEach(ctx, query, func(Summary) error) y conservar la libertad de obtener los datos desde SQL, un archivo o una API remota.
La regla no es “nunca devuelvas tipos de librería”. Es esta:
Si el consumidor usa ese tipo por una necesidad del dominio, puede pertenecer al contrato. Si lo usa solo porque revela cómo trabaja el módulo, se ha filtrado el mecanismo.
El fundamento de visibilidad desarrolla el criterio: la API debe publicar lo que el consumidor puede asumir y mantener libre el mecanismo que lo hace posible.
Una dependencia está realmente dentro de un módulo cuando cambiarla no obliga a renegociar con sus consumidores.