web-production-saas-starter/go-b2b-starter/docs/database.md
2025-12-16 18:54:41 +04:00

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 results
  • ErrTxClosed - Transaction already committed/rolled back
  • ErrTimeout - Operation exceeded timeout
  • ErrPoolClosed - 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