On this page

Entities and queries

Keep Bun table mappings in internal/modules/shared/entities and queries in the owning module's repository. The owning module constructs the repository once and injects it into its service.

Extend the database-free Catalog starter when it needs persisted products. The following entity and repository are tutorial additions, not shipped Product types. Add its migration before executing queries. It does not change the database-free Catalog greeting example.

Define an entity#

Create internal/modules/shared/entities/product.go:

package entities

import "github.com/uptrace/bun"

// Product is owned by the catalog module.
type Product struct {
	bun.BaseModel `bun:"table:products,alias:p"`
	ID            string `bun:"id,pk"`
	Name          string `bun:"name,notnull"`
}

Model tags map fields; they do not create schema. Keep important invariants in database constraints and service validation. This example uses application-assigned string IDs and a non-null name.

The entity carries its Bun mapping and business state together. Keep HTTP request/response shapes in transports. Introduce a private persistence projection only when the shapes differ; a parallel model layer is not the default. See project conventions and shared types.

Create a repository#

Create the public contract in internal/modules/catalog/repository/repository.go:

package repository

import (
	"context"
	"github.com/wisalgroup/labena/internal/modules/shared/entities"
	"github.com/wisalgroup/labena/pkg/pagination"
)

type ProductRepository interface {
	Create(ctx context.Context, product *entities.Product) error
	Get(ctx context.Context, id string) (*entities.Product, error)
	List(ctx context.Context, page pagination.Pagination) ([]entities.Product, int, error)
}

Put the private implementation and its constructor in internal/modules/catalog/repository/product.go:

package repository

import (
	"context"
	"database/sql"
	"errors"

	"github.com/uptrace/bun"

	"github.com/wisalgroup/labena/internal/modules/shared/entities"
	"github.com/wisalgroup/labena/pkg/apperror"
	"github.com/wisalgroup/labena/pkg/database"
	"github.com/wisalgroup/labena/pkg/pagination"
)

var ErrProductNotFound = apperror.NotFound("The product was not found.")

type productRepository struct {
	db *bun.DB
}

func NewProductRepository(db *bun.DB) (ProductRepository, error) {
	if db == nil {
		return nil, errors.New("catalog: database is required")
	}
	return &productRepository{db: db}, nil
}

func (r *productRepository) Create(ctx context.Context, product *entities.Product) error {
	if product == nil {
		return errors.New("catalog: product is required")
	}
	executor, err := database.Executor(ctx, r.db)
	if err != nil {
		return err
	}
	_, err = executor.NewInsert().Model(product).Exec(ctx)
	return err
}

func (r *productRepository) Get(ctx context.Context, id string) (*entities.Product, error) {
	executor, err := database.Executor(ctx, r.db)
	if err != nil {
		return nil, err
	}
	product := new(entities.Product)
	err = executor.NewSelect().Model(product).Where("p.id = ?", id).Scan(ctx)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrProductNotFound
	}
	if err != nil {
		return nil, err
	}
	return product, nil
}

func (r *productRepository) List(ctx context.Context, page pagination.Pagination) ([]entities.Product, int, error) {
	executor, err := database.Executor(ctx, r.db)
	if err != nil {
		return nil, 0, err
	}
	page = pagination.NewPagination(page.Page, page.PerPage)
	products := make([]entities.Product, 0)
	total, err := executor.NewSelect().Model(&products).
		OrderExpr("p.id ASC").Limit(page.PerPage).Offset(page.Offset()).
		ScanAndCount(ctx)
	if err != nil {
		return nil, 0, err
	}
	return products, total, nil
}

All three operations resolve the executor from context. They work outside a transaction and join the caller's transaction when one exists. Calling r.db.NewInsert() directly would bypass that scope.

Get translates a missing row into a module error. Other errors remain available to the caller. Service operations should validate IDs and names before writing; repositories do not replace business validation.

ErrProductNotFound is a safe public application error owned by this repository. If several Catalog behaviors share it, place the sentinel in the module's domain package and reuse it. Do not replace raw database errors with public validation messages. See error ownership.

Return a page#

List receives pagination.Pagination and returns rows plus the total. The service constructs the standard page; the handler converts entities into public responses:

// Inside the proposed product service; s.products is ProductRepository.
items, total, err := s.products.List(ctx, page)
if err != nil {
    return nil, err
}
return pagination.NewPage(items, page, total), nil

Normalize programmatic input with pagination.NewPagination and use deterministic ordering. This example orders by unique product ID, following database collation, not creation time. For chronological results, use a timestamp plus unique ID and an index matching that order.

ScanAndCount reads rows and count separately. Concurrent writes can change the total between them; a page is not automatically a consistent snapshot. Stronger snapshot requirements need a deliberately chosen transaction/isolation policy. Deep offsets can also become expensive; measure actual query plans.

For cursor-based traversal, define a separate operation with a stable ordering and an explicit cursor contract. The equivalent query fragment for ID ordering is:

query := executor.NewSelect().Model(&products).OrderExpr("p.id ASC").Limit(limit)
if after != "" {
    query.Where("p.id > ?", after)
}
if err := query.Scan(ctx); err != nil {
    return nil, err
}

Here limit is validated to 1–100, after is the previous final ID, and the method returns the bounded slice. It does not provide a total count or replace the framework's pagination.Pagination convention for normal list endpoints.

Update selected fields#

Add this method to product.go and add its signature to ProductRepository when the service needs this operation:

func (r *productRepository) Rename(ctx context.Context, id, name string) error {
    executor, err := database.Executor(ctx, r.db)
    if err != nil {
        return err
    }
    _, err = executor.NewUpdate().Model((*entities.Product)(nil)).
        Set("name = ?", name).Where("id = ?", id).Exec(ctx)
    return err
}

This method updates only the name. Its contract does not distinguish a missing row from a successful update with no changes. If that distinction matters, design it explicitly: affected-row behavior for unchanged values differs by driver settings. Do not assume every zero count means “not found.”

For concurrent state transitions, include the expected state or version in the update condition and define what a failed condition means. A read followed by an unconditional update is not a concurrency guarantee.

Delete by identity#

Add Delete(context.Context, string) error to the interface if this operation is needed, and put the implementation in product.go.

func (r *productRepository) Delete(ctx context.Context, id string) error {
    executor, err := database.Executor(ctx, r.db)
    if err != nil {
        return err
    }
    _, err = executor.NewDelete().Model((*entities.Product)(nil)).
        Where("id = ?", id).Exec(ctx)
    return err
}

This is a hard delete; a missing row is a successful no-op. The service must decide whether deletion is allowed. Foreign-key constraints remain authoritative. Add soft deletion only when the product needs its retention and query rules.

Load relationships deliberately#

After defining a Bun relationship field named Items on a model, a query can load it with:

err := executor.NewSelect().Model(&order).
    Relation("Items").Where("o.id = ?", id).Scan(ctx)

This fragment requires an Order mapping with alias o, the relation tags, and corresponding schema; the sample project does not contain those persisted models. Load only the relationships an operation needs. Inspect query count and size; avoid issuing a relation query for every result in a loop.

Cross-module joins stay in deliberate repository queries. Central model definitions do not grant permission to write another module's data.

Raw SQL and dynamic input#

Use the same executor for raw SQL inside a transaction:

var count int
err := executor.NewRaw("SELECT count(*) FROM products WHERE name = ?", name).
    Scan(ctx, &count)

Bind values through placeholders. Choose table names, sort columns, and directions from an allowlist; do not concatenate request input into SQL. Keep result sets bounded and add indexes for actual filters and ordering.

Construct once and reuse#

In Catalog's New, borrow Resources.DB, construct NewProductRepository, and pass its interface to NewProductService. The service holds that repository, not the entire resource bundle. Neither package opens or closes the shared pool. See dependency injection for the construction pattern.

Verify queries and diagnose failures#

Test repository behavior against the actual database engine in tests/catalog: create/get, missing records, deterministic pages, update/delete semantics, and joining a transaction that rolls back. Keep tests out of the business repository package itself.

Symptom Check
Relation or column missing Migration applied to the same database used by the application
Writes survive an expected rollback Every query uses database.Executor and the callback context
A changed row appears on two pages Stable ordering and concurrent-write expectations
A query slows as data grows Bound results, indexes, query plan, and relation query count
Driver details leak to clients Return wrapped operational errors; translate only at the boundary

Use request SQL traces to inspect sanitized query structure. Continue with transactions, migrations, and database tests.

Documentation

Search guides, sections, and API names.