Skip to content

The Target model & the factory split

grpcclient answers one question well: what is the least a caller must state to dial a gRPC service securely? Its answer is a plain Target — host, port, and TLS material — and a Dial that turns that into a *grpc.ClientConn, drawing a deliberate line between the dial factory (this module) and the interceptors (go/transit).

Why Target is not a server-settings type

The dial factory was lifted from go-tool-base's dialLocal, which took a server-side ServerSettings. That coupled every client to a server configuration type. Target breaks that:

type Target struct {
    Host string   // empty ⇒ loopback (localhost)
    Port int
    TLS  tls.Pair // go/tls pair
}

A caller states only what dialing needs. go-tool-base keeps a thin DialLocal adapter that maps its ServerSettings onto a Target, so a server consumer is unaffected — but a client-only consumer never imports server settings, and the depfootprint guard proves the server stack stays out of the graph.

How Dial selects credentials

Dial never asks the caller for grpc.WithTransportCredentials; it derives the credentials from Target.TLS:

  • TLS.Enabled == false → insecure loopback credentials (insecure.NewCredentials()). This is the local-server default.
  • TLS.Enabled == true, TLS.Cert set → a TLS credential over go/tls's hardened client config (TLS 1.2 floor, curated cipher suites), trusting the CA bundle in Cert.
  • TLS.Enabled == true, TLS.Cert empty → the same hardened config over the system roots.

The derived credentials are placed first in the dial-option list, before the caller's options, so a caller's option can never silently downgrade the target's transport security.

Why the factory is separate from the interceptors

Circuit breaking and OpenTelemetry instrumentation are not in this module — they live in go/transit and are passed to Dial as grpc.DialOptions. That split is intentional:

  • An interceptor is identical everywhere; a dial target is a policy. Fail-fast circuit breaking behaves the same for every caller, so it belongs in a shared, transport-neutral module. Which host, port and CA to trust is a choice a service makes, so it belongs in a dial factory the service owns.
  • The interceptors are reused by the server too. go/transit is consumed by both clients and servers; keeping it separate means one tested implementation of each concern.
  • Light graph. Because the factory only pulls the gRPC SDK, go/tls and go/transit, a client-only consumer never links the server stack (controls, authn, gateway). A depfootprint_test.go guard enforces this.

Laziness

Dial uses grpc.NewClient, so the returned connection is lazy: it performs name resolution and connects on the first RPC, not at construction. Dial returning without error therefore means the target and options were valid, not that the server is reachable — surface connection failures where you make the call.

Relationship to go/transit

grpcclient.Dial  →  *grpc.ClientConn
      │  credentials from Target.TLS (via go/tls), endpoint from Host/Port
      └─ applies → go/transit client interceptors (circuit breaker, OTel) as dial options

The factory owns the connection and its credentials; transit owns everything that wraps a call. The transit middleware model covers the interceptor ordering.