7.5 KiB
Database Guide
The database layer uses PostgreSQL with SQLC for type-safe SQL operations and the Adapter Pattern to keep SQLC isolated from business logic.
Architecture
The database layer has three components:
1. Store Interfaces (src/pkg/db/adapters/) - Contracts for database operations
2. Store Adapters (src/pkg/db/postgres/adapter_impl/) - Implement interfaces using SQLC
3. SQLC Generated Code (src/pkg/db/postgres/sqlc/gen/) - Auto-generated from SQL queries
Why Use Adapters?
- External modules depend on interfaces, not SQLC directly
- Easy to mock for testing
- Can swap database implementations
- SQLC internals stay contained
SQLC Workflow
1. Write SQL Query
Create queries in src/pkg/db/postgres/sqlc/query/{domain}.sql:
-- name: GetResourceByID :one
SELECT * FROM resources WHERE id = $1;
-- name: CreateResource :one
INSERT INTO resources (name, status)
VALUES ($1, $2)
RETURNING *;
-- name: ListResources :many
SELECT * FROM resources
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;
SQLC Annotations:
:one- Returns single row:many- Returns slice of rows:exec- Returns error only (no data)
2. Generate Code
make sqlc
Generates Go code in src/pkg/db/postgres/sqlc/gen/.
Never edit generated files - they are regenerated on every run.
3. Create Store Interface
Define interface in src/pkg/db/adapters/resource_store.go:
type ResourceStore interface {
GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error)
CreateResource(ctx context.Context, arg sqlc.CreateResourceParams) (sqlc.Resource, error)
ListResources(ctx context.Context, arg sqlc.ListResourcesParams) ([]sqlc.Resource, error)
}
4. Implement Adapter
Create adapter in src/pkg/db/postgres/adapter_impl/resource_store.go:
type resourceStore struct {
store sqlc.Store
}
func NewResourceStore(store sqlc.Store) adapters.ResourceStore {
return &resourceStore{store: store}
}
func (s *resourceStore) GetResourceByID(ctx context.Context, id int32) (sqlc.Resource, error) {
return s.store.GetResourceByID(ctx, id)
}
5. Register in DI
Add to src/pkg/db/inject.go:
container.Provide(func(sqlcStore sqlc.Store) adapters.ResourceStore {
return adapter_impl.NewResourceStore(sqlcStore)
})
Database Migrations
File Structure
Migrations live in src/pkg/db/postgres/sqlc/migrations/:
000001_create_schema.up.sql
000001_create_schema.down.sql
000002_add_indexes.up.sql
000002_add_indexes.down.sql
Naming Convention
Format: {6-digit-number}_{description}.{up|down}.sql
.up.sql- Apply the migration.down.sql- Rollback the migration
Example Migration
Up migration (000005_create_resources.up.sql):
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE app.resources (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_resources_status ON app.resources(status);
Down migration (000005_create_resources.down.sql):
DROP TABLE IF EXISTS app.resources;
DROP SCHEMA IF EXISTS app;
Running Migrations
make migrateup # Apply all pending migrations
make migratedown # Rollback last migration
Type Conversions
PostgreSQL types need conversion to Go types.
Nullable Fields
SQLC uses pgtype for nullable fields:
// Convert pgtype.Text to string
str := postgres.StringFromPgText(dbRecord.NullableField)
// Convert string to pgtype.Text
pgText := postgres.ToPgText(str)
// Convert pgtype.Int4 to int32
num := postgres.Int32FromPgInt4(dbRecord.NullableInt)
Helper functions in src/pkg/db/postgres/types_transform.go.
JSONB Fields
// Convert map to JSONB
jsonbData := postgres.ToJSONB(map[string]any{"key": "value"})
// Convert JSONB to map
data := postgres.JSONBToMap(dbRecord.Metadata)
Error Handling
The database layer provides specific error types in src/pkg/db/core/errors.go:
Common Errors:
ErrNoRows- Query returned no resultsErrTxClosed- Transaction already committed/rolled backErrTimeout- Operation exceeded timeoutErrPoolClosed- Connection pool is closed
Helper Functions:
if core.IsNoRowsError(err) {
return domain.ErrResourceNotFound
}
if core.IsConstraintError(err, "unique_name") {
return domain.ErrResourceAlreadyExists
}
if core.IsTimeoutError(err) {
return domain.ErrDatabaseTimeout
}
Transactions
Use transactions for multi-step operations that must be atomic.
Basic Transaction
func (r *repository) CreateWithRelation(ctx context.Context, resource *domain.Resource) error {
return r.db.WithTx(ctx, func(tx core.Transaction) error {
// Step 1: Create resource
created, err := tx.CreateResource(ctx, params)
if err != nil {
return err
}
// Step 2: Create relation
_, err = tx.CreateRelation(ctx, relationParams)
if err != nil {
return err // Transaction auto-rolls back on error
}
return nil // Transaction commits on success
})
}
Transaction Options
// Read-only transaction
err := r.db.WithTxOptions(ctx, &sql.TxOptions{ReadOnly: true}, func(tx core.Transaction) error {
// Read operations only
})
// Custom isolation level
err := r.db.WithTxOptions(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
}, func(tx core.Transaction) error {
// Operations
})
Best Practices
Always Use Context
// ✅ Good
func (r *repository) GetResource(ctx context.Context, id int32) (*Resource, error)
// ❌ Bad
func (r *repository) GetResource(id int32) (*Resource, error)
Handle Errors Appropriately
// ✅ Convert database errors to domain errors
resource, err := r.store.GetResourceByID(ctx, id)
if err != nil {
if core.IsNoRowsError(err) {
return nil, domain.ErrResourceNotFound
}
return nil, fmt.Errorf("failed to get resource: %w", err)
}
Use Prepared Statements
SQLC automatically creates prepared statements. Never concatenate SQL strings.
// ✅ Good (SQLC handles this)
SELECT * FROM resources WHERE name = $1
// ❌ Bad (SQL injection risk)
query := fmt.Sprintf("SELECT * FROM resources WHERE name = '%s'", name)
Indexes for Performance
Add indexes for commonly queried fields:
-- Foreign keys
CREATE INDEX idx_resources_org_id ON resources(organization_id);
-- Status fields
CREATE INDEX idx_resources_status ON resources(status);
-- Timestamps for sorting
CREATE INDEX idx_resources_created_at ON resources(created_at DESC);
-- Composite indexes for multi-column queries
CREATE INDEX idx_resources_org_status ON resources(organization_id, status);
File Locations
| Component | Path |
|---|---|
| Store interfaces | src/pkg/db/adapters/ |
| Store implementations | src/pkg/db/postgres/adapter_impl/ |
| SQL queries | src/pkg/db/postgres/sqlc/query/ |
| Migrations | src/pkg/db/postgres/sqlc/migrations/ |
| Generated code | src/pkg/db/postgres/sqlc/gen/ |
| Type helpers | src/pkg/db/postgres/types_transform.go |
| Error types | src/pkg/db/core/errors.go |
| DI setup | src/pkg/db/inject.go |
Next Steps
- Using in repositories: See Architecture Guide
- Building APIs: See API Development Guide
- SQLC documentation: https://docs.sqlc.dev/