gocrud

package module
v1.4.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 9, 2026 License: MIT Imports: 14 Imported by: 0

README

go-crud

A lightweight, generic CRUD library for Go — designed to eliminate repetitive boilerplate code without the overhead of a full ORM.

Overview

go-crud provides a simple, flexible interface for defining CRUD operations in Go.

It’s ideal if you:

  • Don’t want to hand-write the same Create, Read, Update, and Delete logic for each model.
  • Don’t need (or want) the complexity of large ORM frameworks.
  • Prefer to stay close to standard database/sql.

Inspired by Andrew Pillar’s excellent article: 👉 A Simple CRUD Library for PostgreSQL with Generics in Go

Although designed to work with any SQL database, it has primarily been tested with PostgreSQL. If you encounter issues or have improvements, please open an issue or PR — feedback is very welcome!

Usage

You can use go-crud in two ways:

  1. With reflection (simpler setup, slightly slower)
  2. Without reflection (manual setup, marginally faster)
1. Using Reflection

Each CRUD action maps to a generic repository method. To get started, define a new model and embed the gocrud.Reflection interface in your model.

type ModelWithReflection struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Type  string `json:"type"`
	IP    string `json:"ip"`
	Actions []string `json:"actions"`
	gocrud.Reflection
}

repo := gocrud.NewGenericRepository(db, "table_name", func() *ModelWithReflection {
	return &ModelWithReflection{}
})

got, err := repo.Get(ctx, id)
Why the embedded interface?

gocrud.Reflection interface provides a StructToMap(interface{}) map[string]any method that converts your struct into a map of field_name:pointer_to_field using Go's reflection.

The map keys, which are struct field names, are used to validate against column names returned by the query.

While map values, which are pointers to struct fields, are passed to rows.Scan() so your model can be populated.

Custom Column Names with db Tag

By default, field names are lowercased to match database columns. You can override this behavior using the db struct tag:

type User struct {
    ID        int    `db:"user_id"`      // maps to "user_id" column
    FirstName string `db:"first_name"`   // maps to "first_name" column
    LastName  string                     // maps to "lastname" (lowercase field name)
    gocrud.Reflection
}

This is useful when your database column names don't match your Go field names (e.g., snake_case columns with PascalCase fields).

Automatic Timestamps

go-crud automatically handles created_at and updated_at timestamp fields:

Operation created_at updated_at
Create Set to now Set to now
Update Preserved Set to now

For PostgreSQL (native timestamp support), use time.Time:

type Item struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
    gocrud.Reflection
}

For SQLite (stores timestamps as strings), use gocrud.NullTime:

type Item struct {
    ID        int            `json:"id"`
    Name      string         `json:"name"`
    CreatedAt gocrud.NullTime `json:"created_at" db:"created_at"`
    UpdatedAt gocrud.NullTime `json:"updated_at" db:"updated_at"`
    gocrud.Reflection
}

// Access the time value via .Time field
fmt.Println(item.CreatedAt.Time)

NullTime implements sql.Scanner and driver.Valuer, handling both native time values and string representations.

Recognized field names:

  • created_at, createdat, created
  • updated_at, updatedat, updated, modified_at, modifiedat

Models without these fields continue to work unchanged.


2. Without Reflection

If you prefer to avoid reflection, you can implement StructToMap yourself. In benchmarks, the performance difference is negligible but this approach gives you full control.

type ModelWithoutReflection struct {
	ID      int      `json:"id"`
	Name    string   `json:"name"`
	Type    string   `json:"type"`
	IP      string   `json:"ip"`
	Actions []string `json:"actions"`
}

func (o *ModelWithoutReflection) StructToMap(interface{}) map[string]any {
	return map[string]any{
		"id":      &o.ID,
		"name":    &o.Name,
		"type":    &o.Type,
		"ip":      &o.IP,
		"actions": &o.Actions,
	}
}

repo := gocrud.NewGenericRepository(db, "table_name", func() *ModelWithoutReflection {
	return &ModelWithoutReflection{}
})

got, err := repo.Get(ctx, id)

Initialization

You can create a new generic repository using:

gocrud.NewGenericRepository[M gocrud.Model](
	db *sql.DB,
	table string,
	callback func() M,
) *Repository[M]
Parameters
  1. db — your *sql.DB connection
  2. table — the name of the database table to target
  3. callback — a function returning a new instance of your model
Why the Callback?

The callback allows the repository methods to initialize a new instance of the concrete type (your model) at runtime. For example, the Get() method calls the callback to create a fresh model instance to fill it with data retrieved from the database.

Case in point:

repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} })

Now, whenever the repository needs to return a User, it executes the callback to allocate a new one.

Repository Hooks

go-crud supports an opt-in hook system for validation, transformation, and mutation callbacks. Configure hooks using the builder pattern:

repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate().
    WithTransform().
    WithOnMutate(cacheInvalidator)
Hook Execution Order

For Create and Update operations:

  1. Validate - validates the model before persisting
  2. Transform - transforms/normalizes data
  3. Database operation - INSERT or UPDATE
  4. OnMutate - callback for side effects

For Delete operations:

  1. Database operation - DELETE
  2. OnMutate - callback for side effects
Validation Hook

Models can implement the Validatable interface to enable automatic validation:

type Validatable interface {
    Validate(ctx context.Context, db DBQuerier) error
}

Example with field validation and uniqueness check:

func (u *User) Validate(ctx context.Context, db gocrud.DBQuerier) error {
    if u.Email == "" {
        return errors.New("email is required")
    }

    // Check uniqueness
    var exists bool
    row := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", u.Email)
    if err := row.Scan(&exists); err != nil {
        return err
    }
    if exists {
        return errors.New("email already exists")
    }

    return nil
}

Enable validation with WithValidate():

repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate()
Transformation Hook

Models can implement the Transformatable interface to normalize/enrich data before persistence:

type Transformatable interface {
    Transform(ctx context.Context) (Model, error)
}

Example for normalizing email addresses:

func (u *User) Transform(ctx context.Context) (gocrud.Model, error) {
    u.Email = strings.ToLower(strings.TrimSpace(u.Email))
    u.Name = strings.TrimSpace(u.Name)
    return u, nil
}

Enable transformation with WithTransform():

repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithTransform()
OnMutate Callback

Register a callback to run after successful Create, Update, or Delete operations:

cache := make(map[int]*User)

repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithOnMutate(func(ctx context.Context) {
        // Clear cache on any mutation
        for k := range cache {
            delete(cache, k)
        }
    })

Common use cases:

  • Cache invalidation
  • Publishing domain events
  • Updating search indices
  • Audit logging

The callback does not return an error (best-effort execution).

HTTP Route Registration

go-crud provides helper functions to register HTTP handlers for your CRUD operations:

mux := http.NewServeMux()
repo := gocrud.NewGenericRepository(db, "items", func() *Item { return &Item{} })
eh := gocrud.DefaultErrorHandler{}

gocrud.RegisterCreate("POST /items", mux, repo.Create, eh)
gocrud.RegisterGet("GET /items/{id}", mux, repo.Get, eh)
gocrud.RegisterGetAll("GET /items", mux, repo.GetAll, eh)
gocrud.RegisterDelete("DELETE /items/{id}", mux, repo.Delete, eh)
gocrud.RegisterUpdate("POST /items/{id}", mux, repo.Update, eh)
Custom Error Handling

The ErrorHandler interface allows you to customize how errors are returned to clients:

type ErrorHandler interface {
    WriteError(w http.ResponseWriter, r *http.Request, err error, customMsg string, statusCode int)
}

The DefaultErrorHandler uses the custom message if provided, otherwise falls back to err.Error():

type DefaultErrorHandler struct{}

func (d DefaultErrorHandler) WriteError(w http.ResponseWriter, _ *http.Request, err error, customMsg string, statusCode int) {
    if customMsg != "" {
        http.Error(w, customMsg, statusCode)
        return
    }
    http.Error(w, err.Error(), statusCode)
}

To implement custom error handling (e.g., JSON responses, logging), create your own type that implements ErrorHandler:

type JSONErrorHandler struct {
    Logger *log.Logger
}

func (j JSONErrorHandler) WriteError(w http.ResponseWriter, r *http.Request, err error, customMsg string, statusCode int) {
    j.Logger.Printf("error: %v", err)

    msg := customMsg
    if msg == "" && err != nil {
        msg = err.Error()
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

Complete Example

Here's a full example combining hooks, HTTP handlers, and automatic timestamps:

type User struct {
    ID        int            `json:"id"`
    Email     string         `json:"email"`
    Name      string         `json:"name"`
    CreatedAt gocrud.NullTime `json:"created_at" db:"created_at"`
    UpdatedAt gocrud.NullTime `json:"updated_at" db:"updated_at"`
    gocrud.Reflection
}

// Validation hook
func (u *User) Validate(ctx context.Context, db gocrud.DBQuerier) error {
    if u.Email == "" {
        return errors.New("email is required")
    }

    var exists bool
    row := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", u.Email)
    if err := row.Scan(&exists); err != nil {
        return err
    }
    if exists {
        return errors.New("email already exists")
    }

    return nil
}

// Transformation hook
func (u *User) Transform(ctx context.Context) (gocrud.Model, error) {
    u.Email = strings.ToLower(strings.TrimSpace(u.Email))
    u.Name = strings.TrimSpace(u.Name)
    return u, nil
}

func main() {
    db, _ := sql.Open("sqlite3", "app.db")

    // Setup repository with all hooks
    cache := make(map[int]*User)
    repo := gocrud.NewGenericRepository(db, "users", func() *User { return &User{} }).
        WithValidate().
        WithTransform().
        WithOnMutate(func(ctx context.Context) {
            for k := range cache {
                delete(cache, k)
            }
        })

    // Register HTTP handlers
    mux := http.NewServeMux()
    eh := gocrud.DefaultErrorHandler{}

    gocrud.RegisterCreate("POST /users", mux, repo.Create, eh)
    gocrud.RegisterGet("GET /users/{id}", mux, repo.Get, eh)
    gocrud.RegisterGetAll("GET /users", mux, repo.GetAll, eh)
    gocrud.RegisterUpdate("POST /users/{id}", mux, repo.Update, eh)
    gocrud.RegisterDelete("DELETE /users/{id}", mux, repo.Delete, eh)

    http.ListenAndServe(":8080", mux)
}

Notes

Tested with both SQLite and PostgreSQL. Should be compatible with any SQL database supported by database/sql. Contributions, bug reports, and performance improvements are highly appreciated!

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RegisterCreate

func RegisterCreate[In Model](pattern string, mux *http.ServeMux, f func(context.Context, In) (int, error), eh ErrorHandler)

RegisterCreate registers an HTTP handler for creating resources.

The handler:

  1. Decodes JSON from the request body into the model type
  2. Calls the provided function (typically Repository.Create)
  3. Returns the created resource ID as JSON with 201 status

Repository hooks are executed during the create operation:

  • Validation (if configured with WithValidate)
  • Transformation (if configured with WithTransform)
  • OnMutate callback (if configured with WithOnMutate)

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate().
    WithTransform().
    WithOnMutate(cacheInvalidator)
mux := http.NewServeMux()
RegisterCreate("POST /users", mux, repo.Create, DefaultErrorHandler{})

func RegisterDelete

func RegisterDelete(pattern string, mux *http.ServeMux, f func(context.Context, int) error, eh ErrorHandler)

RegisterDelete registers an HTTP handler for deleting a resource by ID.

The handler:

  1. Extracts the ID from the URL path parameter
  2. Calls the provided function (typically Repository.Delete)
  3. Returns 200 status on success

Repository hooks are executed during the delete operation:

  • OnMutate callback (if configured with WithOnMutate)

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithOnMutate(cacheInvalidator)
mux := http.NewServeMux()
RegisterDelete("DELETE /users/{id}", mux, repo.Delete, DefaultErrorHandler{})

func RegisterGet

func RegisterGet[Out Model](pattern string, mux *http.ServeMux, f func(ctx context.Context, id int) (Out, error), eh ErrorHandler)

RegisterGet registers an HTTP handler for retrieving a single resource by ID.

The handler:

  1. Extracts the ID from the URL path parameter
  2. Calls the provided function (typically Repository.Get)
  3. Returns the resource as JSON with 200 status

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} })
mux := http.NewServeMux()
RegisterGet("GET /users/{id}", mux, repo.Get, DefaultErrorHandler{})

func RegisterGetAll

func RegisterGetAll[Out any](pattern string, mux *http.ServeMux, f func(context.Context) ([]Out, error), eh ErrorHandler)

RegisterGetAll registers an HTTP handler for retrieving all resources.

The handler:

  1. Calls the provided function (typically Repository.GetAll)
  2. Returns the resources as a JSON array with 200 status

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} })
mux := http.NewServeMux()
RegisterGetAll("GET /users", mux, repo.GetAll, DefaultErrorHandler{})

func RegisterUpdate

func RegisterUpdate[In Model](pattern string, mux *http.ServeMux, f func(ctx context.Context, in In, id int) error, eh ErrorHandler)

RegisterUpdate registers an HTTP handler for updating a resource.

The handler:

  1. Decodes JSON from the request body into the model type
  2. Extracts the ID from the URL path parameter
  3. Calls the provided function (typically Repository.Update)
  4. Returns 200 status on success

Repository hooks are executed during the update operation:

  • Validation (if configured with WithValidate)
  • Transformation (if configured with WithTransform)
  • OnMutate callback (if configured with WithOnMutate)

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate().
    WithTransform().
    WithOnMutate(cacheInvalidator)
mux := http.NewServeMux()
RegisterUpdate("POST /users/{id}", mux, repo.Update, DefaultErrorHandler{})

Types

type DBQuerier added in v1.3.2

type DBQuerier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

DBQuerier is an interface for database query operations. It provides a subset of *sql.DB functionality to enable easier testing and mocking in validation logic.

type DefaultErrorHandler added in v1.3.4

type DefaultErrorHandler struct{}

DefaultErrorHandler is the default implementation of ErrorHandler. It writes errors as plain text HTTP responses.

func (DefaultErrorHandler) WriteError added in v1.3.4

func (d DefaultErrorHandler) WriteError(w http.ResponseWriter, _ *http.Request, err error, customMsg string, statusCode int)

WriteError writes an error response to the HTTP response writer. If customMsg is provided, it's used instead of the error message.

type ErrorHandler added in v1.3.4

type ErrorHandler interface {
	WriteError(w http.ResponseWriter, r *http.Request, err error, customMsg string, statusCode int)
}

ErrorHandler is an interface for handling HTTP errors in a consistent way. Implement this interface to customize error responses across all CRUD endpoints.

type Model

type Model interface {
	StructToMap(d interface{}) map[string]any
}

Model is the base interface that all repository models must implement. It provides reflection capabilities to convert structs to maps for database operations.

type NullTime added in v1.3.6

type NullTime struct {
	Time  time.Time
	Valid bool
}

NullTime represents a time.Time that can scan from both native time types (PostgreSQL) and string representations (SQLite).

func (*NullTime) Scan added in v1.3.6

func (nt *NullTime) Scan(value any) error

Scan implements the sql.Scanner interface. It handles time.Time, string, []byte, and nil values.

func (NullTime) Value added in v1.3.6

func (nt NullTime) Value() (driver.Value, error)

Value implements the driver.Valuer interface.

type Reflection

type Reflection struct{}

func (*Reflection) StructToMap

func (r *Reflection) StructToMap(d interface{}) map[string]any

type Repository

type Repository[M Model] struct {
	// contains filtered or unexported fields
}

Repository is a generic CRUD repository for database operations.

Thread Safety: The repository uses a mutex to ensure thread-safe Create, Update, Delete, Get, and GetAll operations. This prevents race conditions when multiple goroutines access the same repository instance. However, this may become a bottleneck under very high concurrency. Consider using separate repository instances per request if needed.

Hook Execution Order: For Create and Update operations, hooks execute in this order:

  1. Validate (if enabled via WithValidate)
  2. Transform (if enabled via WithTransform)
  3. Database operation (INSERT/UPDATE)
  4. OnMutate callback (if configured via WithOnMutate)

For Delete operations:

  1. Database operation (DELETE)
  2. OnMutate callback (if configured)

func NewGenericRepository

func NewGenericRepository[M Model](db *sql.DB, table string, callback func() M) *Repository[M]

NewGenericRepository creates a new generic CRUD repository.

Parameters:

  • db: The database connection
  • table: The name of the database table
  • callback: A factory function that returns a new instance of the model type

The repository can be configured with optional hooks using the builder pattern:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate().
    WithTransform().
    WithOnMutate(cacheInvalidator)

By default, the repository:

  • Does NOT validate models (use WithValidate to enable)
  • Does NOT transform models (use WithTransform to enable)
  • Does NOT call mutation callbacks (use WithOnMutate to register)
  • DOES automatically set created_at and updated_at timestamps if fields exist

func (*Repository[M]) Create

func (r *Repository[M]) Create(ctx context.Context, model M) (int, error)

func (*Repository[M]) Delete

func (r *Repository[M]) Delete(ctx context.Context, id int) error

func (*Repository[M]) Get

func (r *Repository[M]) Get(ctx context.Context, id int) (M, error)

func (*Repository[M]) GetAll

func (r *Repository[M]) GetAll(ctx context.Context) ([]M, error)

func (*Repository[M]) GetDB added in v1.3.4

func (r *Repository[M]) GetDB() *sql.DB

func (*Repository[M]) GetTable

func (r *Repository[M]) GetTable() string

func (*Repository[M]) Update

func (r *Repository[M]) Update(ctx context.Context, model M, id int) error

func (*Repository[M]) WithOnMutate added in v1.4.0

func (r *Repository[M]) WithOnMutate(fn func(context.Context)) *Repository[M]

WithOnMutate registers a callback function that will be called after successful Create, Update, and Delete operations. This is useful for side effects like cache invalidation, event publishing, or audit logging.

The callback is called after the database operation completes successfully. It does not return an error - if the callback fails, it won't affect the database operation (best-effort execution).

Common use cases:

  • Cache invalidation
  • Publishing domain events
  • Updating search indices
  • Audit logging

Example:

cache := make(map[int]*User)
repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithOnMutate(func(ctx context.Context) {
        // Clear cache on any mutation
        for k := range cache {
            delete(cache, k)
        }
    })

func (*Repository[M]) WithTransform added in v1.4.0

func (r *Repository[M]) WithTransform() *Repository[M]

WithTransform enables transformation for Create and Update operations. When enabled, the repository will call the model's transform method (if it implements Transformatable) after validation but before persisting to the database.

This allows you to normalize or enrich data before it's stored.

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithTransform()

func (*Repository[M]) WithValidate added in v1.4.0

func (r *Repository[M]) WithValidate() *Repository[M]

WithValidate enables validation for Create and Update operations. When enabled, the repository will call the model's validate method (if it implements Validatable) before persisting data to the database.

If validation fails, the operation is aborted and the validation error is returned.

Example:

repo := NewGenericRepository(db, "users", func() *User { return &User{} }).
    WithValidate()

type Transformatable added in v1.4.0

type Transformatable interface {
	Transform(ctx context.Context) (Model, error)
}

Transformatable is an interface that models can implement to transform data before it is persisted to the database. The transform method is called after validation but before the database operation when the repository is configured with WithTransform().

The method must return a Model that satisfies the same type M as the repository. Transformations are applied to both Create and Update operations.

Common use cases:

  • Normalizing data (e.g., lowercasing email addresses)
  • Enriching data (e.g., setting default values)
  • Sanitizing input (e.g., trimming whitespace)

Security note: Be cautious when implementing transform. Ensure that transformations don't bypass intended security restrictions or modify sensitive fields unexpectedly.

Example:

func (u *User) transform(ctx context.Context) (Model, error) {
    u.Email = strings.ToLower(strings.TrimSpace(u.Email))
    u.Name = strings.TrimSpace(u.Name)
    return u, nil
}

type Validatable added in v1.3.0

type Validatable interface {
	Validate(ctx context.Context, db DBQuerier) error
}

Validatable is an interface that models can implement to provide custom validation logic. The validate method is called automatically before Create and Update operations when the repository is configured with WithValidate().

The method receives a DBQuerier interface (compatible with *sql.DB) to allow database queries for validation (e.g., checking uniqueness constraints).

Example:

func (u *User) validate(ctx context.Context, db DBQuerier) error {
    if u.Email == "" {
        return errors.New("email is required")
    }
    // Check uniqueness
    var exists bool
    row := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)", u.Email)
    if err := row.Scan(&exists); err != nil {
        return err
    }
    if exists {
        return errors.New("email already exists")
    }
    return nil
}

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL