Errors Across the Seam
Get, Update, and Delete all reduce to one translation: "the row isn't
there" becomes ErrNotFound, and everything else is a generic wrapped
error. That covers those three methods completely — but Create can fail
for a reason neither of those categories fits. Give tasks.title a UNIQUE
constraint and Create("write lesson", 2) twice, and the second call fails
not because anything is missing, but because something already exists. An
HTTP handler above this store needs to tell that apart from "the database is
unreachable": one is a 409 Conflict the client should see and maybe retry
differently; the other is a 500 and an alert.
Naming a second failure
var ErrDuplicate = errors.New("task title already exists")
Detecting it means going one level below database/sql, into what the
driver actually returns:
var sqliteErr *sqlite.Error
if errors.As(err, &sqliteErr) && sqliteErr.Code() == sqliteConstraintUnique {
return Task{}, ErrDuplicate
}
modernc.org/sqlite doesn't export a friendly ErrConstraintUnique — you
get a *sqlite.Error with an integer Code(), 2067, which is SQLite's own
extended result code for SQLITE_CONSTRAINT_UNIQUE. This is normal, not a
gap in the driver: database/sql is deliberately driver-agnostic, so
anything database-specific — including "which integer means duplicate" —
necessarily lives one layer down, in a driver-specific error type you reach
with errors.As. Every SQL driver you'll use professionally has some
version of this: a typed error, an inconvenient integer, and a comment
explaining what it means because the constant isn't exported either.
Translate what you have a plan for, and nothing else
It would be easy to reach for a switch over every sqlite.Error code and
map each to some sentinel. Resist it. ErrNotFound and ErrDuplicate exist
because the layer above has different, specific behavior for each — a 404
and a 409. A sqliteErr.Code() you have no distinct response for isn't a
sentinel candidate; it's just an error, and it should stay one, wrapped with
%w so whoever reads the log still sees SQLite's actual message. Translating
everything "for consistency" throws that message away and replaces it with
whichever sentinel the switch happened to fall into — which, for a genuinely
unexpected failure, is actively worse than not translating it at all: it
tells the next person debugging a real outage the wrong thing with total
confidence.
Try it
Update Create to return ErrDuplicate when the title already exists,
leaving every other failure — including ones you haven't seen — as a
generic wrapped error.
Practice
1. Translate a Duplicate, Not Everything
IntermediateImplement
Create(ctx context.Context, db *sql.DB, title string, priority int) (Task, error).The table already exists:
tasks (id, title UNIQUE, done, priority).- Insert the task; the database assigns the id.
- If the insert fails because
titlealready exists (aUNIQUEconstraint violation), returnErrDuplicate— not a generic wrapped error. - Any other failure stays a generic error, wrapped with
%w. Do not map it toErrDuplicateor invent a new sentinel for it.
package main import ( "context" "database/sql" "errors" "fmt" _ "modernc.org/sqlite" ) var ErrDuplicate = errors.New("task title already exists") type Task struct { ID int64 Title string Priority int } // sqliteConstraintUnique is SQLite's extended result code for a UNIQUE // constraint violation; modernc.org/sqlite doesn't export a named constant. const sqliteConstraintUnique = 2067 // Create inserts a task, returning ErrDuplicate if title already exists. func Create(ctx context.Context, db *sql.DB, title string, priority int) (Task, error) { // TODO: implement return Task{}, fmt.Errorf("not implemented") } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.