Implementation contracts in Go
For a long time, I considered a filled interface type as an
implementation contract in Go. A very quick shorthand on how you can
check for implementation compliance is to add the following snippet to
your _test.go files:
var _ rpc.UserService = &UserService{}Until your UserService meets the interface contract, the test will result in failure. You’re likely to add this check to ensure your API is decoupled and can be used behind that interface.
Some interfaces don’t include all the functions available on the type. The http.ResponseWriter has optional Hijacker and Flusher interfaces which are available after casting the type.
There are several interfaces to point out in the standard library:
errorfmt.Stringerio.Reader,io.Writer,io.Closerhttp.ResponseWritertesting.TBfs.FS
Some extensions come from the proliferation of web servers:
Filler(Fill(*http.Request) error) to fill data model typesMiddleware(handler chain), passing a “next” HandlerFunc- error-returning extensions of
http.HandlerFunc(no common naming convention on this one)
I wanted to look at the way contracts are enforced with the new generic methods syntax introduced in Go 1.27, or in general with generics. Generics have been a late stage addition to the language, which results in some changed expectations around implementation contracts.
These observations came out the initial development of
titpetric/pdo, a wrapper I built
around jmoiron/sqlx for a request-scoped database client. It uses
generic functions to implement a CRUD interface for Go values, and
simplifies the database query APIs to Select, Get and Exec.
Let’s see some observations.
Enforcing package-level contracts
Some time ago, a number of drop-in replacements for encoding/json was
popular. Not to point at any particular one, but the whole premise was
as follows:
-"encoding/json"
+"github.com/segment-io/json" // probably incorrect import pathSo you’d think you could create an interface, list the APIs available in the standard library json package. A simple one:
package json_test
import (
"encoding/json"
)
type JsonDecoder interface {
Unmarshal(dest any, source []byte) error
}
var _ JsonDecoder = json // use of package json not in a selectorMost of you will know this doesn’t work. A package selector is not an instance. Matching interfaces and packages could lead to incorrect use, however there is a difference between usage and validation.
The main issue really is the inability to create an interface that would contain contracts for a package scope and check that a package implements it. This has been a restriction since ages ago.
You can assert at the shape of individual functions to satisfy each function in particular. There’s just not a way to check all of them based on the package selector.
Some approaches to package contract gaps are to rely on code generation, or mostly abandon the package namespace and deal mostly with structs.
Enforcing struct-level contracts
Mature services development leans into type safety. Each RPC call has a typed request payload, a typed response and an error return for any issue that occurs during execution.
An established naming convention to support this at least in the context of gRPC is for each Call to take a CallRequest, and return a CallResponse.
service UserService {
rpc Call(CallRequest) returns (CallResponse);
}Go has embedding and not inheritance. If I wanted to add an observability wrapper over my database, I’d implement the interface for the storage interactions something like this:
type MonitoredUserStorage struct {
*UserStorage
}
func (u *MonitoredUserStorage) Get(ctx context.Context, id string) (*model.User, error) {
ctx, close := telemetry.NewSpan(ctx, "userstorage.get")
defer close()
return u.UserStorage.Get(ctx, id)
}Or if I wanted to fake some *UserStorage call from tests:
type FakeUserStorage struct {
GetFn func(context.Context, string) (*model.User, error)
}
func (u *FakeUserStorage) Get(ctx context.Context, id string) (*model.User, error) {
if u.GetFn != nil {
return u.GetFn(ctx, id)
}
return ErrNotImplemented
}Just to throw in a close third place wrench (rarely seen):
type DirectUserStorage struct {
Get func(context.Context, string) (*model.User, error)
}The usage syntax for all three forms is:
monitoredStorage.Get(ctx, "1") // usage as base function, wrapper
fakeUserStorage.Get(ctx, "1") // usage as custom function, wrapper
directUserStorage.Get(ctx, "1") // direct instance field referenceSo, the last form just uses the Get field as the function it is. I
like to think of such functions as stateless, but that’s not really the
case, because a function handle can have a receiver.
server := &Server{} // <<-- state, deps
storage := DirectUserStorage{
Get: server.Get,
}Unfortunately the DirectUserStorage example can’t fulfill the
interface, because a field reference does not satisfy the interface.
From syntax nothing changes, but from the language itself, there is a
difference between field access and a function bound to a type. From the
perspective of the user, the invocation syntax is the same.
I don’t think there should be a distinction, it’s an interesting approach to provide a flat API surface without being restricted that the functions need to be stateless. They can carry clients, database handles, or anything else they need to work.
Enforcing application level contracts
Big systems grow, and you can’t enforce everything just by type checked use. Maybe if your tests are big enough, and if you measure for coverage in some places with a more precise stroke of the brush, you’re still left with a bunch of encouraged practices based on decisions you’ve already made.
Generics bring an additional level of syntax to the Go language. If you have the following type, you basically have type-safe access to anything you can store on context.
type TypeContext[T any] struct {
ContextKey any
}
func (t *TypeContext[T]) Get(ctx context.Context) T {
// extract value from ctx and t.ContextKey, cast to T
result, _ := ctx.Value(t.ContextKey).(T)
return result
}It’s common to attach some things in contexts, even if in a mostly “just
in case” basis. For example, *http.Request is often attached to a
context, as well as the ResponseWriter.
The main difference between an RPC and a REST API is that an RPC carries
the full request in the request body. To compose a request with REST, or
just HTTP in general, the request headers and path often play a role in
how the full request payload is decoded. APIs also write out custom
response headers; rate limiting can write an X-Rate-Limit-Remaining
header, and your login calls read and write Set-Cookie headers.
The APIs could get unfriendly for use.
getDatabase := NewTypeContext[*sql.DB](consts.DatabaseClientContextKey).Get
// storage scope/handler scope
db := getDatabase(ctx)Carrying a database handle on context is probably the wrong idea. Dependencies
for the most part should be passed or created in a New style constructor.
This also has the benefit of having strong allocation control, rather than
inline allocations littering the codebase.
So, the issue is, you somehow have to create an API somewhere that allows you to work with the context and the values it carries. The ways we do that holistically in Go is to either provide a package and export the symbol for use, or we lean into having a “type of types”, a factory for request-lived and service-lifetime dependencies.
You can only partially assert the implementation:
type GetResponseWriter interface {
Get(context.Context) http.ResponseWriter
}
var _ GetResponseWriter = (*TypeContext[http.ResponseWriter])(nil)A generic interface could be used, but a type hint must remain.
type GetType[T any] interface {
Get(context.Context) T
}
var _ GetType[http.ResponseWriter] = (*TypeContext[http.ResponseWriter])(nil)A direct assertion cannot be made. This is where I start daydreaming
about “abstract class” languages. The missing mechanic is to compare
an abstract GetType[T] to an abstract TypeContext[T].
Type extensions in Go 1.27
The Go 1.27 release is bringing generic methods to types. This allows us to write a different form of context management that is type safe:
type ContextKey struct{} // enum area
type ContextManager struct {}
func (c *ContextManager) Get[T any](ctx context.Context, key model.ContextKey) T {
result, _ := ctx.Value(key).(T)
return result
}With this, context access becomes “flat”:
w := ctxManager.Get[http.ResponseWriter](ctx, model.ResponseWriterContextKey)And this in turn becomes flattened to a package API:
func GetResponseWriter(ctx context.Context) http.ResponseWriter {
return ctxManager.Get[http.ResponseWriter](ctx, model.ResponseWriterContextKey)
}Ok, so with the new generics additions, how do we check the implementation contract with interfaces? We have generic interfaces, we have a generic type, should be easy to check if they match.
type ContextGetter[T any] interface {
Get(context.Context) T
}This form, while matching the type definition for ContextManager
exactly, can’t be asserted on the type, because the type is not generic,
only the function is.
type ResponseWriterGetter interface {
Get(context.Context) http.ResponseWriter
}This interface can’t be asserted on the ContextManager type, because the implementation of Get is generic and the interface is statically typed.
type TypeGetter interface {
Get[T any](context.Context) T
}This interface results in a compile time error. The interface doesn’t allow you to declare a generic function, and there’s no way to assert it further if it did.
The working assertion you can do is:
var _ func(context.Context) http.ResponseWriter = ((*ContextManager)(nil)).Get[http.ResponseWriter]
var _ func(context.Context) http.ResponseWriter = storage.GetResponseWriterYou wouldn’t do these assertions because just general use covers type safety. It’s a little bit silly to write these for code under your control. It’s not really holistic to do assertions like this.
Final thoughts
Interfaces are a good mechanism to enforce compile-time API signatures on structs, however they lack an approach where the interfaces could be extended to verify package signatures, generics signatures, and more.
I had no trouble of writing the code itself with accepted syntax, but tripping over the lack of generics type assertions now really means that this code can only ever work as a direct dependency. There are no mocks, no fakes, and honestly, maybe there shouldn’t be.
Interfaces have traditionally been a good introduction of a process or service. This was because you can show them to someone and discuss in abstract without showing any bit of code. The interface was real.
With generics, the interface that I should be making is:
type Writer interface {
Insert[T any](context.Context, query string, v T) error
Replace[T any](context.Context, query string, v T) error
Update[T any](context.Context, query string, v T, keyCols ...any) error
Exec(context.Context, query string, args ...any) error
}
type Reader interface {
Select[T any](ctx context.Context, query string, args ...any) ([]T, error)
Get[T any](ctx context.Context, query string, args ...any) (T, error)
}
// type Transactor, QueryState, Connection, Observer,...I kind of get this. It’s opinionated, customizable, doesn’t put barriers between SQL or ORM access, but it doesn’t really provide everything either. Previously you could enforce a contract from an idea. An interface was the design document, now it’s pseudo code.
If you want to run the pdo code, you’ll need gotip installed;
otherwise it is what it is. If the go 1.27 release is on schedule, we’ll
see generic functions become generally available around August.