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.
Go by Example ebook for free