Skip to content
Liddiard Research
All projects

go-dbscope

Why composing repositories into a transaction should not require changing the repositories themselves.

View go-dbscope on GitHub Go / Databases / Transactions

A repository should describe the data operation it performs against the database. Whether that operation belongs to a larger transaction is a separate decision. We built go-dbscope because keeping those two responsibilities apart became harder than it needed to be.

The idea

A repository unwraps the connection from the scope:

connection, err := scope.Connection(ctx)

With a normal context, that resolves to the application’s database connection. With a transaction context, it resolves to the active transaction.

The repository does not need separate transactional and non-transactional implementations. The caller decides where the transaction begins and passes the resulting context through the same application and repository methods it already uses.

normal context       -> database connection
transaction context  -> active transaction

Example

Consider registering a user and adding them to an organisation. These are separate repository operations, but from the application’s point of view they should either both succeed or both fail.

The repositories themselves know nothing about that requirement.

package main

import (
    "context"
    "log"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/liddiard-research/go-dbscope/feature/pgx"
)

type UserRepository struct {
    scope *pgxscope.Scope
}

func NewUserRepository(scope *pgxscope.Scope) *UserRepository {
    return &UserRepository{scope: scope}
}

func (r *UserRepository) Create(
    ctx context.Context,
    name string,
) (int64, error) {
    connection, err := r.scope.Connection(ctx)
    if err != nil {
        return 0, err
    }

    var id int64
    err = connection.QueryRow(
        ctx,
        "INSERT INTO users (name) VALUES ($1) RETURNING id",
        name,
    ).Scan(&id)

    return id, err
}

type MembershipRepository struct {
    scope *pgxscope.Scope
}

func NewMembershipRepository(scope *pgxscope.Scope) *MembershipRepository {
    return &MembershipRepository{scope: scope}
}

func (r *MembershipRepository) Add(
    ctx context.Context,
    userId int64,
    organisationId int64,
) error {
    connection, err := r.scope.Connection(ctx)
    if err != nil {
        return err
    }

    _, err = connection.Exec(
        ctx,
        `
            INSERT INTO memberships (user_id, organisation_id)
            VALUES ($1, $2)
        `,
        userId,
        organisationId,
    )

    return err
}

type RegistrationService struct {
    scope       *pgxscope.Scope
    users       *UserRepository
    memberships *MembershipRepository
}

func NewRegistrationService(
    scope *pgxscope.Scope,
    users *UserRepository,
    memberships *MembershipRepository,
) *RegistrationService {
    return &RegistrationService{
        scope:       scope,
        users:       users,
        memberships: memberships,
    }
}

func (s *RegistrationService) Register(
    ctx context.Context,
    name string,
    organisationId int64,
) error {
    txCtx, tx, err := s.scope.WithTx(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback(txCtx)

    userId, err := s.users.Create(txCtx, name)
    if err != nil {
        return err
    }

    if err := s.memberships.Add(txCtx, userId, organisationId); err != nil {
        return err
    }

    return tx.Commit(txCtx)
}

func run(ctx context.Context) error {
    pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    if err != nil {
        return err
    }
    defer pool.Close()

    scope := pgxscope.New(pool)

    users := NewUserRepository(scope)
    memberships := NewMembershipRepository(scope)

    registrations := NewRegistrationService(
        scope,
        users,
        memberships,
    )

    return registrations.Register(ctx, "Jack", 42)
}

func main() {
    if err := run(context.Background()); err != nil {
        log.Fatal(err)
    }
}

Both repositories have one implementation.

UserRepository.Create and MembershipRepository.Add can be called normally with an ordinary context or they can participate in the same transaction simply by receiving txCtx.

The transaction belongs to RegistrationService because that is the layer which knows that creating the user and creating their membership form one atomic operation.

Why we built it

The problem we kept running into was not writing transactions themselves. It was what transactions did to otherwise simple repository APIs.

An operation would already exist against the normal database connection. Later another part of the application needed to compose it into a transaction. The query had not changed but suddenly we were considering a second repository method, passing transaction objects through unrelated APIs or introducing another abstraction over both the connection and transaction.

That felt like the wrong place for the complexity.

The repository should care about the query. The layer composing several operations should care about the transaction.

Not another database API

We also did not want dbscope to become a database abstraction of its own.

If an application uses pgx, its repositories should continue to work in terms of pgx-style operations. If it uses database/sql, it should continue to use the standard library’s model. Another database library can provide its own integration.

dbscope sits at one narrow boundary: choosing which connection view a repository should use for the current context.

The built-in pgx integration exposes the operations shared by a pgx pool and transaction, such as Exec, Query and QueryRow. The database/sql integration applies the same idea to *sql.DB and *sql.Tx. The generic core can also be adapted to other database libraries without changing the transaction propagation model.

That means adopting dbscope does not require moving queries into a new DSL or hiding the capabilities of the underlying database library. The application keeps control of the database connection, transaction boundaries and driver-specific behaviour.

For us, that is the useful part of the design: transaction composition moves upwards, while database operations stay where they belong.

The GitHub README covers installation, transaction semantics, supported integrations and the complete API.