Overview

10 Polymorphic storage

This chapter shows how to persist a previously in-memory link shortener while keeping the design portable and modular. It uses Go’s database abstraction to add durable storage with SQLite, but deliberately structures the solution so it can work with other SQL databases. Along the way, it demonstrates the driver pattern in the standard library, connection pooling, and a consumer-driven interface style that enables polymorphism without tight coupling or premature abstractions.

Practically, the chapter walks through registering a driver and opening a pooled *sql.DB, embedding and applying a schema at startup, and implementing a SQLite-backed Shortener that inserts with ExecContext and retrieves with QueryRowContext and Scan. It hardens error handling by translating driver-specific errors (e.g., duplicate keys) into application-level ones via errors.As, uses placeholders to avoid injection, and adds fast integration tests with unique in-memory databases and test cleanup. It also extends the database/sql abstraction with driver.Valuer and sql.Scanner to support custom transformations (e.g., Base64-encoding URLs) when sending to or reading from the database.

Finally, it decouples HTTP handlers from storage by introducing small, focused interfaces owned by the consumer package (e.g., Shortener and Resolver) and satisfied implicitly by concrete implementations (in-memory or SQLite). This consumers-first approach keeps contracts minimal, improves testability, and makes it trivial to switch or compose implementations (e.g., swapping drivers or adding caches) with only configuration changes. The overarching guidance is to prefer concrete types, evolve interfaces only when needs emerge, and keep things simple, modular, and composable.

The sql package abstracts underlying databases with drivers. The sql package's registry stores registered drivers, and *DB manages a connection pool.
Connecting to the SQLite database using Open and PingContext.
The compiler embeds the file into the compiled binary.
Handlers use Shortener to shorten and resolve links from SQLite; internally, Shortener uses a *DB handle to execute queries on the database.
Resolve calls QueryRowContext to retrieve the URL from the database. The connection is reserved until Scan is called. Calling Scan copies the previously loaded raw row data into the uri variable and returns the connection to the pool.
The consumer owns the interface rather than the provider.

Summary

  • The sql package provides a uniform API to interact with various SQL databases.
  • The sql/driver package defines behaviors that database drivers must implement.
  • Database drivers are typically registered via side-effect importing, invoking their init functions. To enhance maintainability, generally avoid init functions.
  • sql.Open fails if we don't register the necessary driver.
  • go mod tidy cleans up the module dependency graph.
  • sql.Open verifies if a driver is registered and returns a *sql.DB handle.
  • sql.DB manages a database connection pool that is initially empty.
  • DB.Close closes the connection pool. But it rarely needs to be used.
  • DB.PingContext connects to the database and verifies its authenticity.
  • DB.ExecContext executes a query and returns the connection to the pool.
  • The embed package lets us embed files directly into the program binary.
  • The //go:embed directive can embed a single or multiple files into a variable.
  • Using placeholders in SQL queries makes SQL injection attacks less likely.
  • Pass Context downstream to allow the cancellation of ongoing operations.
  • T.Cleanup performs post-test cleanup work. Unlike defer, which runs right before its surrounding function returns, Cleanup runs after its test function returns.
  • T.Context returns a Context that is canceled before running the Cleanup functions.
  • errors.As looks up an error in the error chain and sets it to an error variable.
  • DB.QueryRowContext runs a query and returns a row. Calling Scan converts the row data, sets the converted data into a variable, and releases the connection. Not coupling QueryRowContext with Scan can lead to database connection leaks.
  • driver.Valuer allows us to process a value before sending it to the driver.
  • sql.Scanner allows us to process a value after retrieving it from the driver.
  • base64 is a package that provides Base64 encoding and decoding.
  • base64.StdEncoding.EncodeToString encodes a byte slice to Base64.
  • base64.StdEncoding.DecodeString decodes a Base64-encoded string.
  • Every interface should earn its place in the codebase.
  • Avoid declaring interfaces until a genuine requirement exists.
  • Focused interfaces are easier for concrete types to satisfy.
  • Return concrete types, and let consumer packages declare interfaces.
  • Declare interfaces where they are used.

FAQ

How do I interact with SQL databases in Go?Use the standard library’s database/sql package together with a database driver. You call sql.Open(driverName, dataSourceName) to obtain a *sql.DB handle, which abstracts connection pooling, retries, and error handling. Drivers are registered with the sql package and implement behaviors defined in database/sql/driver.
Why does sql.Open not connect immediately, and when should I call PingContext?sql.Open returns a ready-to-use *sql.DB with an empty pool; it does not establish a connection right away. Call DB.PingContext(ctx) to verify connectivity and create the first connection. Keep the *sql.DB for the entire program lifecycle to reuse pooled connections.
How do I register an SQLite driver, and what is a “blank import”?Install a driver (for example, modernc.org/sqlite) and import it with an underscore: import _ "modernc.org/sqlite". The blank import executes the driver’s init function, which registers the driver with database/sql. You don’t reference the package name in your code; you import it for its side effects (registration).
How can I manage and tune the database connection pool?Use these methods on *sql.DB: - SetMaxOpenConns(n) and SetMaxIdleConns(n) to control maximum open/idle connections - SetConnMaxLifetime(d) and SetConnMaxIdleTime(d) to recycle connections over time Benchmark and adjust carefully; overly large pools waste resources and can overload the database.
How do I embed and apply a SQL schema at startup?Activate file embedding by importing the embed package and add a go:embed directive: //go:embed schema.sql var schema string Then execute it once with DB.ExecContext(ctx, schema). This bundles the schema into the binary, so you can deploy without external SQL files.
What’s the safe way to insert and retrieve rows with database/sql?Use parameterized queries and context-aware methods: - Insert: DB.ExecContext(ctx, "INSERT ... VALUES ($1, $2)", key, url) - Fetch one row: DB.QueryRowContext(ctx, "SELECT ... WHERE short_key = $1", key).Scan(&dest) Placeholders prevent SQL injection, and calling Scan releases the connection back to the pool. Pass Context so operations can be canceled (for example, if an HTTP client disconnects).
How can I integration-test database-dependent code?Create a helper that dials a unique in-memory SQLite database per test (for example, file:TestName?mode=memory&cache=shared), and register tb.Cleanup to close it after the test finishes. Use t.Context() for a context bound to the test’s lifetime. This approach enables concurrent, isolated tests that still hit a real database.
How do I map driver-specific errors to application-level errors without leaking internal details?Use errors.As to extract the driver’s error type and inspect its code. For example, with SQLite, detect primary key violations (code 1555) and return a domain error like ErrConflict. This avoids exposing raw driver messages and lets your handlers respond consistently.
What are driver.Valuer and sql.Scanner, and when should I use them?They customize how values are sent to and read from the database: - driver.Valuer: Value() converts a Go value into a driver.Value for storage - sql.Scanner: Scan(src any) parses a database value into a Go value Examples include pq.Array for PostgreSQL arrays or a custom type (like base64String) to transparently encode/decode data.
How do Go’s implicit, consumer-driven interfaces enable polymorphic storage?Let the consuming package define small, focused interfaces (for example, rest.Shortener with Shorten, rest.Resolver with Resolve). Any concrete type implementing those methods (such as link.Shortener or sqlite.Shortener) satisfies the interface implicitly. Handlers depend only on required behavior, so you can swap storage backends (SQLite, Postgres, in-memory, cached) without changing handler code.

pro $24.99 per month

  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose one free eBook per month to keep
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime

lite $19.99 per month

  • access to all Manning books, including MEAPs!

team

5, 10 or 20 seats+ for your team - learn more


choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Go by Example ebook for free