- Book: Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Drop one line from a Go service's import block and sql.Open answers with sql: unknown driver "postgres" (forgotten import?). The standard library guesses your bug for you. The missing line is import _ "github.com/lib/pq", and that underscore is the visible end of a ports-and-adapters design that has sat in Go's standard library since the language hit 1.0.
The package was exp/sql until the weekly snapshot of 2012-01-20 renamed it to database/sql, and it shipped in Go 1 on 28 March 2012. Alistair Cockburn had written up the ports and adapters pattern in 2005, with the stated intent of letting an application be "developed and tested in isolation from its eventual run-time devices and databases". Fourteen years after Go 1, database/sql is still a clean blueprint of that idea, plus one design decision you should not copy.
The core owns the port
Start with who defines what. The interfaces live in database/sql/driver, and the package doc opens with: "Package driver defines interfaces to be implemented by database drivers as used by package sql." The consumer writes the contract. Postgres, MySQL and SQLite authors implement it.
That is the rule the Go wiki's Code Review Comments gives for every interface: "Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values." database/sql follows it to the letter. Here is the required surface, abridged from the package docs:
// database/sql/driver (abridged)
type Driver interface {
Open(name string) (Conn, error)
}
type Conn interface {
Prepare(query string) (Stmt, error)
Close() error
Begin() (Tx, error) // deprecated
}
type Stmt interface {
Close() error
NumInput() int
Exec(args []Value) (Result, error) // deprecated
Query(args []Value) (Rows, error) // deprecated
}
type Rows interface {
Columns() []string
Close() error
Next(dest []Value) error
}
That is small on purpose. The design goals document that sits in the package directory lists a clear split between the driver layer and the user-facing layer, type conversion handled mostly in the sql package so drivers deal with fewer types, and optional interfaces for special cases or faster paths. Your application code never touches driver.Conn. It talks to *sql.DB, *sql.Rows and *sql.Tx, which are concrete types the core controls.

The core defines one socket. Every database driver is a plug shaped to fit it.
Adapters plug themselves in
Each driver is an adapter, and it registers itself. This is the whole init in lib/pq's conn.go:
func init() {
sql.Register("postgres", &Driver{})
}
go-sql-driver/mysql does the same with sql.Register(driverName, &MySQLDriver{}), and its package comment tells you to use it via import _ "github.com/go-sql-driver/mysql". The official Go database tutorial explains why the underscore is there: if you never call a function from the driver package directly, you need a blank import so the package still loads.
import (
"database/sql"
_ "github.com/lib/pq"
)
func open(dsn string) (*sql.DB, error) {
return sql.Open("postgres", dsn)
}
The blank import runs init, init writes into a map inside database/sql, and sql.Open looks the name up in that map. Your code depends only on the port. The adapter is a line in your import block. The SQLDrivers wiki page lists roughly 60 adapters built this way, from pgx to modernc.org/sqlite, and marks the ones that pass a shared compatibility test suite.
Optional interfaces: capabilities without a fat port
Here is the part worth borrowing first. The required port stayed tiny, but the protocol grew a lot after 2012: context cancellation, named parameters, session resets, connection validation. The Go team did not add those methods to driver.Conn, which would have broken every driver. They added separate, optional interfaces and let the core detect them at runtime.
Go 1.8 brought QueryerContext, ExecerContext, ConnBeginTx and Pinger. Go 1.9 brought NamedValueChecker. Go 1.10 brought Connector, DriverContext and SessionResetter. Go 1.15 brought Validator. An old driver that implements none of them still compiles and still works.
The detection is a type assertion with a fallback. A simplified sketch of what queryDC in sql.go does:
// simplified sketch of database/sql's queryDC
queryerCtx, ok := dc.ci.(driver.QueryerContext)
var queryer driver.Queryer
if !ok {
queryer, ok = dc.ci.(driver.Queryer)
}
if ok {
rows, err := ctxDriverQuery(ctx, queryerCtx,
queryer, query, args)
if err != driver.ErrSkip {
return rows, err // adapter handled it
}
}
// fallback: Prepare, Query, Close the statement
The driver package docs spell out the chain: if a Conn implements neither QueryerContext nor Queryer, DB.Query prepares the statement, runs it, and closes it. And driver.ErrSkip, whose message reads "driver: skip fast-path; continue as if unimplemented", lets an adapter say "I have this capability, but not for this particular call."
Argument checking follows the same idea. According to convert.go, the core looks for NamedValueChecker on the statement first, then on the connection, and falls back to the statement's column converter or its own default checker when neither exists or when the checker returns ErrSkip. pgx's stdlib adapter implements CheckNamedValue, ResetSession and QueryContext. A smaller driver can skip all three and still get correct behaviour from the core.
That gives you a port that can grow for a decade without a breaking change.
The pool lives in the core
The other half of the design is what adapters do not have to write. The sql.OpenDB doc comment says the returned DB "maintains its own pool of idle connections". The DB struct in that same file holds freeConn, connRequests, maxOpen, maxLifetime and maxIdleTime. Idle connections default to defaultMaxIdleConns = 2, and SetMaxOpenConns documents that "The default is 0 (unlimited)."
No driver reimplements pooling, lifetimes or waiting for a free connection. The adapter contributes hooks the core calls at the right moment. If a Conn implements Validator, the driver docs say IsValid runs before the connection goes back to the pool. If it implements SessionResetter, ResetSession runs before reuse. And driver.ErrBadConn tells the core to retry on a fresh connection, with a warning not to return it if the server might already have done the work.
Policy in the core, mechanism in the adapter. When you design your own ports, retries, timeouts, circuit breaking and pooling belong on the core side for the same reason: otherwise every adapter writes its own slightly different version.
The half you should not copy: a global registry
Now the part that breaks the hexagonal rules. In sql.go, the registry is a package-level map behind a mutex:
var driversMu sync.RWMutex
var drivers = make(map[string]driver.Driver)
Adapters are wired by import side effects into global state, not by an explicit composition root, which Mark Seemann describes as "a (preferably) unique location in an application where modules are composed together." Three costs follow from that.
First, the wiring fails at runtime, not compile time. That is the error from the top of this post: the message has to guess at the forgotten import because the compiler never sees the dependency.
Second, names collide globally. Register panics with sql: Register called twice for driver on a duplicate name. pgx works around that in its init: it registers "pgx" only if sql.Drivers() does not already contain it, and always registers "pgx/v5".
Third, the map is now hard to change. The comment above it in sql.go says it should be an internal detail, but that other packages reach into it via linkname, and asks that its type signature not change.

Side-effect registration on the left, an explicit composition root on the right.
Go 1.10 gave you the exit. sql.OpenDB takes a driver.Connector value instead of a name string, so main hands the adapter to the core directly:
// pgx is github.com/jackc/pgx/v5
// stdlib is github.com/jackc/pgx/v5/stdlib (pgx's adapter)
cfg, err := pgx.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
db := sql.OpenDB(stdlib.GetConnector(*cfg))
MySQL has the same shape through mysql.NewConnector, which the Go tutorial uses as its example. Importing the package still runs its init, so the registry entry still exists, but your code no longer depends on a magic string or on import order.
Apply the good half to your own ports
Take the three habits that aged well and leave the registry behind. Say a billing service sends notifications. The service package owns the port, a richer capability is optional, and there is always a fallback:
package billing
// Sender is the port. billing owns it.
type Sender interface {
Send(ctx context.Context, m Message) error
}
// BatchSender is optional. Detected, never required.
type BatchSender interface {
SendBatch(ctx context.Context, ms []Message) error
}
func (s *Service) notifyAll(
ctx context.Context, ms []Message,
) error {
if bs, ok := s.sender.(BatchSender); ok {
return bs.SendBatch(ctx, ms)
}
for _, m := range ms {
if err := s.sender.Send(ctx, m); err != nil {
return err
}
}
return nil
}
Retries and rate limits go in Service, not in each adapter, the same way sql.DB owns the pool. And the wiring happens in one visible place:
func main() {
db := mustOpenDB()
repo := postgres.NewInvoiceRepo(db)
sender := smtp.NewSender(loadSMTPConfig())
svc := billing.NewService(repo, sender)
runHTTP(svc)
}
No init, no name strings, no blank imports. If you forget an adapter, the code does not compile. For tests, pass a fake Sender into NewService, which is exactly the isolation Cockburn's 2005 write-up was after.
Next time you type import _ for a database driver, read it as the stdlib showing you the pattern, and the one place it cut a corner. Wire your own adapters in main.
If this was useful
database/sql gets the port and the capability detection right, and gets the wiring wrong. Hexagonal Architecture in Go works through both halves for your own services: where ports live in the package layout, how persistence adapters stay behind them, and how to build a composition root in main that the compiler can check. If the billing example above looks like the direction you want your codebase to go, that book is the long version of it.

Top comments (0)