API-DD

Guía práctica

Una API no es HTTP: la misma conversación dentro de un proceso

Seguir una cancelación desde un handler hasta dos paquetes permite separar transporte, API de aplicación y colaboraciones internas.

Por Lautaro Mei

Al oír «API», es fácil imaginar una ruta HTTP:

text
POST /bookings/42/cancellation

La ruta es una API, pero la conversación no termina al entrar en el proceso. El handler llama a un módulo de aplicación; ese módulo utiliza reservas y notificaciones. Cada relación tiene consumidores, mensajes y garantías diferentes.

Si llamamos API solo al borde HTTP, las decisiones internas quedan implícitas. Si llamamos API a cualquier función, la palabra deja de ayudarnos.

Podemos seguir una petición concreta para encontrar los límites que importan.

Primer límite: HTTP traduce el transporte#

El handler recibe detalles que pertenecen a la Web:

go
func (h *Handler) CancelBooking(w http.ResponseWriter, r *http.Request) {
    id := bookings.ID(r.PathValue("bookingID"))

    result, err := h.cancellations.Cancel(r.Context(), id)
    if err != nil {
        h.writeError(w, err)
        return
    }

    switch result {
    case cancellation.Cancelled:
        w.WriteHeader(http.StatusNoContent)
    case cancellation.AlreadyCancelled:
        w.WriteHeader(http.StatusOK)
    }
}

Su responsabilidad es traducir:

El handler no debería decidir si una reserva confirmada puede cancelarse ni cuándo se emite una notificación. Esas garantías deben seguir siendo ciertas si mañana la misma capacidad se usa desde una CLI o un job.

Segundo límite: la API de aplicación expresa la intención#

El paquete cancellation ofrece una conversación sin vocabulario HTTP:

go
package cancellation

type Result uint8

const (
    Cancelled Result = iota
    AlreadyCancelled
)

var (
    ErrBookingNotFound = errors.New("booking not found")
    ErrConfirmedBooking = errors.New("confirmed booking cannot be cancelled")
)

type Service struct {
    bookings      Bookings
    notifications Notifications
}

func (s *Service) Cancel(
    ctx context.Context,
    id bookings.ID,
) (Result, error) {
    // coordinación interna
}

Esta es otra API aunque no tenga URL, JSON ni servidor. Su consumidor puede pedir una cancelación y distinguir resultados relevantes sin saber cómo se cargan reservas o cómo se entregan mensajes.

El contrato puede declarar:

text
Una reserva Pending cambia a Cancelled.
Una reserva Cancelled devuelve AlreadyCancelled sin repetir efectos.
Una reserva Confirmed conserva su estado y devuelve ErrConfirmedBooking.
Una reserva ausente devuelve ErrBookingNotFound.
La primera cancelación produce BookingCancelled.

HTTP traduce este contrato, pero no lo define.

Tercer límite: colaboración con reservas#

Para cumplir la promesa, cancellation necesita una capacidad del paquete bookings. No necesita conocer SQL ni un repositorio genérico:

go
type Bookings interface {
    Cancel(
        context.Context,
        bookings.ID,
    ) (bookings.CancelResult, error)
}

La interfaz puede estar declarada en cancellation porque expresa lo que ese consumidor necesita. El paquete bookings puede satisfacerla con un tipo concreto.

Su contrato es más estrecho que el de aplicación:

text
Cancel cambia el estado de una reserva cuando su invariante lo permite.
No sabe nada de HTTP.
No decide si debe enviarse una notificación al cliente.

Una base de datos, una transacción o un actor remoto son mecanismos del proveedor mientras conserven esa conversación.

Cuarto límite: colaboración con notificaciones#

La cancelación también produce un efecto dirigido a otro módulo:

go
type Notifications interface {
    BookingCancelled(
        context.Context,
        bookings.ID,
        bookings.CustomerID,
    ) error
}

Esta API no necesita publicar SendEmail, una plantilla ni un topic de Kafka. cancellation comunica un hecho; el proveedor decide cómo entregarlo.

El nombre evita que la aplicación coordine el mecanismo:

go
// Demasiado visible para esta conversación:
mailer.RenderTemplate("booking-cancelled", data)
mailer.Send(customer.Email, subject, body)

// Capacidad que el consumidor necesita:
notifications.BookingCancelled(ctx, booking.ID, booking.CustomerID)

Si otros consumidores necesitan reaccionar al mismo hecho, el límite puede evolucionar hacia eventos. No hace falta decidirlo para reconocer que ya existe una API entre módulos.

La conversación completa#

La petición atraviesa límites, pero cada uno conserva un vocabulario propio:

LímiteMensajePromesa principalDetalle oculto
Cliente → HTTPPOST /bookings/{id}/cancellationrespuesta HTTP establerouting y middleware
HTTP → aplicaciónCancel(BookingID)resultado de la cancelacióntransporte original
Aplicación → reservasCancel(BookingID)transición válida de estadopersistencia
Aplicación → notificacionesBookingCancelled(...)aceptar el efecto prometidoemail, cola o topic

Dos mensajes pueden llamarse Cancel y pertenecer a APIs diferentes. El contexto y las garantías aclaran si comparten significado. No es necesario añadir sufijos como CancelUseCaseCommand o BookingRepositoryCancel solo para que los nombres sean globalmente únicos.

El test elige qué API observa#

Un test del handler protege la traducción HTTP:

go
func TestConfirmedBookingReturnsConflict(t *testing.T) {
    cancellations := stubCancellations{
        err: cancellation.ErrConfirmedBooking,
    }
    handler := NewHandler(cancellations)

    response := httptest.NewRecorder()
    request := requestToCancel("booking-42")
    handler.CancelBooking(response, request)

    if response.Code != http.StatusConflict {
        t.Fatalf("expected 409, got %d", response.Code)
    }
}

No necesita comprobar SQL ni notificaciones. Observa el contrato del adaptador.

Un test de aplicación protege otra garantía:

go
func TestFirstCancellationNotifiesOnce(t *testing.T) {
    bookings := fakeBookings{result: bookings.Cancelled}
    notifications := &fakeNotifications{}
    service := NewService(bookings, notifications)

    result, err := service.Cancel(context.Background(), "booking-42")

    if err != nil || result != Cancelled {
        t.Fatalf("cancel: result=%v err=%v", result, err)
    }
    if notifications.cancelled != 1 {
        t.Fatalf("expected one notification, got %d", notifications.cancelled)
    }
}

No necesita un servidor HTTP. Observa resultado y efecto prometidos por la API de aplicación.

Elegir la API observada evita dos extremos: una prueba enorme que atraviesa todo para cada decisión y pruebas internas que solo fijan helpers.

No conviertas cada helper en una API pública#

Mirar el sistema de forma recursiva no significa crear un paquete, una interfaz y documentación para cada función.

Un helper privado puede cambiar junto con su módulo y no tiene consumidores autónomos. Una relación merece tratarse como API cuando existe un consumidor que necesita una conversación estable: otro paquete, una parte del sistema con evolución propia o un test que sustituye un efecto legítimo.

La profundidad depende del riesgo. FormatName puede quedar claro con su firma. Una cancelación con estados, reintentos y efectos necesita garantías explícitas.

El fundamento de recursión aplica la misma perspectiva a cada escala: sistema, aplicación, paquete o tipo. No afirma que todas las APIs sean iguales; permite diseñar cada relación desde su consumidor.

HTTP es una conversación en el borde. La modularidad aparece cuando también diseñamos las conversaciones que continúan después de cruzarlo.