Skip to content

The Target model and 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: a program that only ever dialled had to import, and therefore link, the type that describes how to serve. Target breaks that:

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

A caller states only what dialling 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 test proves the server stack stays out of the graph.

The cost of that decoupling is that gtls.Pair is still a shared type carrying server-shaped fields — Key, ClientCAs, ClientAuth — that mean nothing on a dial. The factory reads two of its five fields and ignores the rest without complaint. That is a real sharp edge, and it is catalogued in Target fields.

How Dial selects credentials

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

  • TLS.Enabled == false → insecure 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 and nothing else.
  • TLS.Enabled == true, TLS.Cert empty → the same hardened config over the system roots.

There is no third state and no InsecureSkipVerify. A development server with a self-signed certificate is handled by pointing Cert at that certificate, which trusts exactly it — rather than by switching verification off, which would trust everything.

Why the credentials are not tamper-proof, and what to do about it

Dial places its derived credentials first in the option list and appends the caller's options after them. That looks protective and is not: gRPC applies dial options in order and a later grpc.WithTransportCredentials simply overwrites the earlier one. A caller who passes insecure credentials as an option gets a plaintext connection to a target that asked for TLS, with no error and no log line.

This documentation previously claimed the opposite. It was wrong, and the code is the authority.

The honest framing is that Target.TLS states the intent of the service that owns the target, and Dial is not a security boundary against its own caller. In a single Go program that distinction rarely matters, because the code assembling the options and the code assembling the Target are the same code. It starts to matter as soon as dial options are built from configuration, from a plugin, or from a library you did not write. Confirm a connection is actually encrypted shows how to check the outcome rather than trust the input.

Why the factory is separate from the interceptors

Circuit breaking and OpenTelemetry instrumentation are not in this module — they live in go/transit and reach Dial as grpc.DialOptions. That split is intentional, for three reasons.

An interceptor is identical everywhere; a dial target is a policy. Fail-fast circuit breaking behaves the same for every caller of every service, so it belongs in a shared, transport-neutral module that can be tested once. Which host to dial, which port, and which CA to trust are choices a particular service makes, so they belong in a factory that service owns.

The interceptors are reused by the server. go/transit is consumed by both clients and servers — the same package that supplies CircuitBreakerInterceptor for a client supplies LoggingInterceptor and RateLimitInterceptor for a server. Keeping it separate means one tested implementation of each concern instead of a client copy and a server copy that drift.

A client-only consumer should stay light. Because the factory pulls only the gRPC SDK, go/tls and cockroachdb/errors, a program that dials never links the server stack — controls, authn, gateway — or the CLI, config and TUI stacks. depfootprint_test.go enforces that by listing the forbidden module prefixes and failing if any appears in go list -deps.

Note the consequence: transit is not a dependency of this module at all. Consumers who want its interceptors add transit to their own go.mod. The OpenTelemetry packages that do appear in the graph arrive through the gRPC SDK.

Why Dial does not connect

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.

That is the gRPC SDK's model rather than a choice this module made, and it is the right one for a long-lived client: a service that dials its dependencies at startup should not fail to start because a dependency is briefly down, and a *grpc.ClientConn reconnects on its own. The cost is that every "is it working?" question moves to the call site. Errors returned by Dial splits the failures by where they surface, and Confirm a connection is actually encrypted shows how to force the question at startup when you would rather fail fast.

Why the module is this small

There is one function and one struct, and no plan for more. Anything that can be expressed as a grpc.DialOption — retries, keepalive, message sizes, load balancing, a custom resolver — already has a home in the gRPC SDK, and wrapping it in a second API here would add a translation layer, a second set of defaults and a second thing to keep current with upstream. The only things the factory adds are the two the SDK cannot supply on its own: a project-standard set of TLS defaults, and a target shape that does not drag server configuration into a client.

The corollary is that this module is a poor place to look for features. What grpcclient does not do is the list.

How grpcclient relates 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. Transit's own middleware model covers interceptor ordering.