HTTP Clients
Outbound HTTP clients call services outside your App.
Use httpx when application code needs typed request helpers, retries, request options, or diagnostic dumps around outbound HTTP calls.
HTTPX package reference
This page shows how to construct and inject outbound clients in a GoForj App. The HTTPX library page provides standalone usage and the complete client API reference.
Where Clients Live
Keep outbound clients in application-owned packages:
internal/billing
internal/notifications
internal/searchConstruct clients through providers and inject them into services. Do not hide outbound clients behind package globals.
Client Shape
Create internal/billing/client.go as a small typed boundary around httpx.Client:
package billing
import (
"context"
"net/url"
"github.com/goforj/httpx"
)
// Invoice is the billing response used by application services.
type Invoice struct {
ID string `json:"id"`
Status string `json:"status"`
}
// Client owns outbound calls to the billing service.
type Client struct {
http *httpx.Client
}
// NewClient constructs a billing client for one configured endpoint.
func NewClient(baseURL string) *Client {
return &Client{
http: httpx.New(
httpx.BaseURL(baseURL),
httpx.UserAgent("my-app/1.0"),
),
}
}
// FindInvoice returns one invoice while preserving caller cancellation.
func (c *Client) FindInvoice(ctx context.Context, id string) (Invoice, error) {
result := httpx.GetCtx[Invoice](
c.http,
ctx,
"/api/v1/invoices/"+url.PathEscape(id),
)
return result.Body, result.Err
}GoForj Apps currently pin github.com/goforj/httpx v1. Use that module path unless the App's go.mod has intentionally been upgraded.
Configure and Provide the Client
Keep environment lookup at the App composition boundary. Add a focused provider to app/wire/inject_services_app.go:
// provideBillingClient resolves required endpoint configuration when a reachable service needs it.
func provideBillingClient() (*billing.Client, error) {
baseURL := strings.TrimSpace(os.Getenv("BILLING_API_URL"))
if baseURL == "" {
return nil, errors.New("BILLING_API_URL is required")
}
return billing.NewClient(baseURL), nil
}
var appSet = wire.NewSet(
// existing App providers...
provideBillingClient,
billing.NewService,
)This is a composition fragment: add the errors, os, strings, and application package imports to the existing file. To make the provider reachable, inject *billing.Service into a controller constructor registered in app/wire/inject_http_controllers_app.go, or into a command registered in its matching Wire set. Wire evaluates providers only when something in the built graph depends on their result. Configure the endpoint through the runtime environment:
BILLING_API_URL=https://billing.internalThe application service receives the typed client rather than constructing HTTP dependencies inside a request or job:
type Service struct {
billing *Client
}
// NewService constructs the billing application service.
func NewService(billing *Client) *Service {
return &Service{billing: billing}
}
// FindInvoice delegates one outbound lookup through the typed billing boundary.
func (s *Service) FindInvoice(ctx context.Context, id string) (Invoice, error) {
return s.billing.FindInvoice(ctx, id)
}Make the Service Reachable
An entry point must consume the service before Wire includes its providers. For an HTTP app, inject it into a controller:
// internal/invoices/controller.go
type Controller struct {
billing *billing.Service
}
// NewController constructs the invoice HTTP controller.
func NewController(billing *billing.Service) *Controller {
return &Controller{billing: billing}
}Register that constructor in the HTTP controller set:
var appHttpControllerSet = wire.NewSet(
// existing controller providers...
invoices.NewController,
)Now the dependency path is complete:
HTTP routes -> invoices.Controller -> billing.Service -> billing.ClientWire retains every provider in that path. With BILLING_API_URL missing, constructing the HTTP runtime returns the provider error before the server starts.
Test the Boundary
Use httptest.Server so the client contract is executable without an external service. Create internal/billing/client_test.go:
package billing
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// TestClientFindInvoice verifies the outbound method, path, and typed response.
func TestClientFindInvoice(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/invoices/inv-42" {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
if _, err := fmt.Fprint(w, `{"id":"inv-42","status":"paid"}`); err != nil {
t.Errorf("write response: %v", err)
}
}))
defer server.Close()
invoice, err := NewClient(server.URL).FindInvoice(context.Background(), "inv-42")
if err != nil {
t.Fatalf("FindInvoice returned error: %v", err)
}
if invoice.ID != "inv-42" || invoice.Status != "paid" {
t.Fatalf("invoice = %#v", invoice)
}
}Run:
go test ./internal/billing
forj buildExpected result: the client test passes and forj build succeeds. When a registered controller or command depends on *billing.Service, a missing BILLING_API_URL makes Wire construction fail before that runtime starts; it is not validated merely because the provider appears in appSet.
Environment-Enabled Dumps
httpx.New() checks HTTP_TRACE. When the variable is present, the client enables request and response dump output for all requests made through that client.
Run a command with dumps enabled:
HTTP_TRACE=1 forj sync:billingUnset the variable to disable this behavior:
forj sync:billingHTTP_TRACE is useful for local diagnosis and temporary operator debugging. It should not be a normal production setting.
Request-Scoped Diagnostics
Use request-scoped options when only one call needs detail:
result := httpx.Get[map[string]any](
httpx.New(),
"https://httpbin.org/uuid",
httpx.Trace(),
httpx.Dump(),
)
if result.Err != nil {
return result.Err
}
fmt.Println(result.Body["uuid"])
// 00000000-0000-0000-0000-000000000000Use client-level options when every request from one client needs diagnostic output:
client := httpx.New(
httpx.DumpAll(),
httpx.TraceAll(),
)Prefer the narrowest diagnostic scope that proves the issue.
Capture Dumps
For tests or command output, capture dumps into a buffer:
var buf bytes.Buffer
client := httpx.New(httpx.DumpEachRequestTo(&buf))
result := httpx.Get[map[string]any](client, "https://httpbin.org/uuid")
if result.Err != nil {
return result.Err
}
log.Print(buf.String())Captured output is useful when a test or one-shot command needs to preserve the exact outbound exchange.
Output Shape
Dump output is intentionally low-level. It shows the outbound request and inbound response:
GET /uuid HTTP/1.1
Host: httpbin.org
User-Agent: my-app/1.0
HTTP/2 200 OK
Content-Type: application/json
{"uuid":"00000000-0000-0000-0000-000000000000"}Actual output can include more headers, redirects, retry attempts, and body content depending on the request and transport.
Safety
HTTP dumps can expose sensitive data:
- authorization headers
- cookies
- API tokens
- request bodies
- response bodies
- query strings
Do not enable broad dump output in production unless output is controlled, retained safely, and reviewed for secrets. Prefer request-scoped diagnostics when possible.
Next Steps
- Application Services explains where outbound calls belong in workflows.
- Configuration explains runtime environment configuration.
- HTTPX covers standalone package APIs.