Practical guide
An API is not HTTP: the same conversation inside a process
Following a cancellation from a handler into two packages separates transport, the application API, and internal collaborations.
When you hear "API," it's easy to imagine an HTTP route:
POST /bookings/42/cancellationThe route is an API, but the conversation does not end when you enter the process. The handler calls an application module; that module uses reservations and notifications. Each relationship has different consumers, messages and guarantees.
If we call API only at the HTTP edge, internal decisions are implicit. If we call any function API, the word stops helping us.
We can follow a specific request to find the boundaries that matter.
First boundary: HTTP translates the transport#
The handler receives details that belong to the Web:
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)
}
}Its responsibility is to translate:
- path, headers and authentication to application values;
- application results to codes, body and HTTP headers;
- cancellation of the connection to
context.Context.
The handler should not decide whether a confirmed reservation can be canceled or when a notification is issued. Those guarantees should still be true if the same capability is used from a CLI or a job tomorrow.
Second boundary: the application API expresses intent#
The cancellation package offers a conversation without HTTP vocabulary:
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) {
// internal coordination
}This is another API even though it has no URL, JSON, or server. Its consumer can request a cancellation and distinguish relevant results without knowing how bookings are loaded or messages are delivered.
The contract may declare:
A Pending reservation changes to Cancelled.
A Cancelled booking returns AlreadyCancelled without repeating effects.
A Confirmed reservation retains its status and returns ErrConfirmedBooking.
A missing booking returns ErrBookingNotFound.
The first cancellation produces BookingCancelled.HTTP translates this contract, but does not define it.
Third boundary: collaboration with bookings#
To fulfil the promise, cancellation needs a capability from the bookings package. It does not need to know SQL or a generic repository:
type Bookings interface {
Cancel(
context.Context,
bookings.ID,
) (bookings.CancelResult, error)
}The interface may be declared in cancellation because it expresses what that consumer needs. The bookings package can satisfy it with a specific type.
This contract is narrower than the application API:
Cancel changes the status of a reservation when its invariant allows it.
It knows nothing about HTTP.
It does not decide whether a notification should be sent to the client.A database, a transaction, or a remote actor remain provider mechanisms as long as they preserve that conversation.
Fourth boundary: collaboration with notifications#
The cancellation also produces an effect directed to another module:
type Notifications interface {
BookingCancelled(
context.Context,
bookings.ID,
bookings.CustomerID,
) error
}This API does not need to publish SendEmail, a template, or a Kafka topic. cancellation communicates a fact; the provider decides how to deliver it.
The name prevents the application from coordinating the mechanism:
// Too much mechanism exposed for this conversation:
mailer.RenderTemplate("booking-cancelled", data)
mailer.Send(customer.Email, subject, body)
// The capability the consumer needs:
notifications.BookingCancelled(ctx, booking.ID, booking.CustomerID)If other consumers need to react to the same fact, the boundary can evolve towards events. We do not need to make that decision before recognizing that an API already exists between these modules.
The full conversation#
The request crosses boundaries, but each one retains its own vocabulary:
| Boundary | Message | Main promise | Hidden detail |
|---|---|---|---|
| Client → HTTP | POST /bookings/{id}/cancellation | stable HTTP response | routing and middleware |
| HTTP → application | Cancel(BookingID) | cancellation result | original transport |
| Application → reservations | Cancel(BookingID) | valid state transition | persistence |
| Application → notifications | BookingCancelled(...) | accept the promised effect | email, queue or topic |
Two messages can be called Cancel and belong to different APIs. Context and guarantees clarify whether they share meaning. There is no need to add suffixes such as CancelUseCaseCommand or BookingRepositoryCancel just to make names globally unique.
The test chooses which API it observes#
A handler test protects the HTTP translation:
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)
}
}It does not need to inspect SQL or notifications. It observes the adapter contract.
An application test protects another guarantee:
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)
}
}It does not need an HTTP server. It observes the result and effect promised by the application API.
Choosing the observed API avoids two extremes: a huge test that goes through everything for every decision and internal tests that only fix helpers.
Don't make every helper a public API#
Looking at the system recursively does not mean creating a package, interface and documentation for each function.
A private helper can change along with its module and has no autonomous consumers. A relationship deserves to be treated as an API when there is a consumer that needs a stable conversation: another package, a part of the system with its own evolution, or a test that replaces a legitimate effect.
The depth depends on the risk. FormatName may be clear from its signature alone. A cancellation with states, retries, and effects needs explicit guarantees.
The recursion foundation applies the same perspective at every scale: system, application, package, or type. It does not claim that all APIs are the same; it lets us design each relationship from its consumer.
HTTP is a conversation at the edge. Modularity appears when we also design the conversations that continue after crossing it.