Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

390 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

oniontx drawing

test lint Release GitHub go.mod Go version GitHub release date GitHub last commit GitHub MIT license

oniontx enables moving persistence logic control (for example: transaction management) from the Persistence (repository) layer to the Application (service) layer using an owner-defined contract.

The library provides two complementary approaches that can be used independently or together:

  • mtx package: Local ACID transactions for single-resource operations
  • saga package: Local compensating workflows for multi-resource coordination

Both packages maintain clean architecture principles by keeping transaction control at the application level while repositories remain focused on data access.

πŸ’‘ Key Features

  • Clean Architecture First: Transactions managed at the application layer, not in repositories
  • Dual Transaction Support:
    • mtx package for local ACID transactions (single database)
    • saga package for in-process compensating workflows (multiple services/databases)
  • Database Agnostic: Ready-to-use implementations for popular databases and libraries
  • Testability First: Built-in support for major testing frameworks
  • Type-Safe: Full generics support for compile-time safety
  • Context-Aware: Proper context propagation throughout transaction boundaries
  • Lightweight: The core library uses only the Go standard library and avoids unnecessary runtime dependencies

Package mtx: Local Transactions

drawing

πŸ”΄ NOTE: Use mtx when working with a single database instance. It manages ACID transactions across multiple repositories. For multiple repositories, use mtx.Transactor with saga.Sagaβ“˜.

The core entity is Transactor β€” it provides a clean abstraction over database transactions and offers:

Custom implementation

If required, oniontx provides the ability to implement custom algorithms for managing transactions (see examples).

Interfaces:

type (
	// Mandatory
	TxBeginner[T Tx] interface {
		comparable
		BeginTx(ctx context.Context) (T, error)
	}
	
	// Mandatory
	Tx interface {
		Rollback(ctx context.Context) error
		Commit(ctx context.Context) error
	}

	// Optional - used to put/get transactions from `context.Context`
	// (library contains default `CtxOperator` implementation)
	CtxOperator[T Tx] interface {
		Inject(ctx context.Context, tx T) context.Context
		Extract(ctx context.Context) (T, bool)
	}
)

Usage

Create a Transactor from a concrete transaction beginner and a context operator:

type txKey struct{}

operator := mtx.NewContextOperator[txKey, *Tx](txKey{})
transactor := mtx.NewTransactor[*DB, *Tx](db, operator)

Application services run business operations inside WithinTx:

err := transactor.WithinTx(ctx, func(ctx context.Context) error {
	if err := repoA.Insert(ctx, value); err != nil {
		return fmt.Errorf("insert A: %w", err)
	}
	if err := repoB.Insert(ctx, value); err != nil {
		return fmt.Errorf("insert B: %w", err)
	}
	return nil
})

Repositories can reuse an active transaction from context and fall back to the base connection when no transaction is active:

executor, ok := transactor.TryGetTx(ctx)
if !ok {
	executor = transactor.TxBeginner()
}

Nested WithinTx calls reuse the transaction already stored in context, so multiple use cases can participate in the same transaction without passing the transaction object through repository APIs.

Note: Nested WithinTx calls do not create database save-points or independent nested transactions. They participate in the same outer transaction, so an error from an inner call causes the outer transaction to be rolled back.

The test/integration module contains working Transactor implementations for stdlib, sqlx, pgx, gorm, redis, mongo:

Package saga: In-progress Workflow Engine

Use saga when coordinating operations across multiple services, databases, or external systems. It implements the In-Progress Workflow Engine (or In-Progress Local Saga) pattern with compensating actions to maintain consistency within a single process.

Unlike Distributed Sagas that require a centralized orchestrator or choreography between services, this implementation is designed as an In-Progress Workflow Engine where:

  • The saga execution happens within a single process/monolith
  • All steps are defined and executed locally
  • Compensations are called within the same process
  • No distributed coordination or persistent saga state is required

The Saga coordinates the execution of a business process consisting of multiple steps. Each step contains:

  • Action: The main operation to execute
  • Compensation: A rollback operation that undoes the action if later steps fail

Steps execute sequentially. If any step fails, all previous steps are automatically compensated in reverse completion order.

drawing

Example:

steps := []saga.Step{
    saga.NewStep("first_step").
        WithAction(
            // Add action with decorators
            saga.NewOperation(func(ctx context.Context, track saga.Track) error {
                err := fmt.Errorf("first_step_Error")
                return err
            }).
                // Protection against panics β€” important for production!
                // If the action panics, the panic will be caught
                // and returned as an error with ErrPanicRecovered
                WithPanicRecovery().
                // Add retry for action
                WithRetry(
                    // 2 retry attempts after the initial call, 1s between retries
                    saga.NewBaseRetryPolicy(2, 1*time.Second),
                ),
        ).
        // Add compensation
        WithCompensation(
            saga.NewOperation(func(ctx context.Context, track saga.Track) error {
                // Compensation logic.
                // Get data to understand what failed
                data := track.GetStepData()

                // Log the error that triggered compensation
                if len(data.Action.Errors) > 0 {
                    fmt.Printf("Compensating for error: %v\n", data.Action.Errors[0])
                }
                return performCompensation(ctx)
            }).
                // Compensation can also have retry logic
                WithRetry(
                    saga.NewAdvanceRetryPolicy(
                        2,                            // retry attempts after the initial call
                        1*time.Second,                // initial delay
                        saga.NewExponentialBackoff(), // exponential backoff
                    ).
                        // Jitter prevents "thundering herd" during mass failures
                        WithJitter(
                            saga.NewFullJitter(), // random delay
                        ).
                        // maximum delay cap
                        WithMaxDelay(10*time.Second),
                ),
        ).
        WithCompensationRequired(),
}

// Execute the saga
//
// With this approach:
// 1. If action fails, it will be retried according to the retry policy
// 2. If all attempts fail, compensations will run
// 3. Compensations will also retry on failure with exponential backoff
// 4. Jitter distributes load during mass failure scenarios
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

result, err := saga.NewSaga(steps).
    // Compensations can continue if the action context is canceled.
    WithCompensationContext(context.WithoutCancel).
    Execute(ctx)
if err != nil {
    // Check the saga outcome.
    if errors.Is(err, saga.ErrActionFailed) {
        // At least one action failed.
    }

    if errors.Is(err, saga.ErrCompensationFailed) {
        // At least one compensation failed.
    }

    // Inspect original action and compensation errors.
    for _, executionErr := range result.Errors() {
        if errors.Is(executionErr, yourDomainErr) { // your domain error
            // Handle or log the domain-specific cause.
        }
    }
}

More examples:

Testing

test package contains useful examples for creating unit test:

Releases

Packages

Used by

Contributors

Languages