DEV Community

Lotus
Lotus

Posted on

soroi: Shared Queries and Cache Refreshes for Go Services

#go

A product page loads a product. The cart loads it again. If the requests overlap, the backend may read the same record twice. After an admin changes the price, there is another job to remember: retiring the old detail and category-list cache entries.

None of this is especially difficult. It becomes tedious when the same loading and refresh logic turns up in several services.

soroi is a typed query client for Go that handles cached results, shared in-flight loads, and refresh state. Its core has no external dependencies and supports Go 1.22 and later.

GitHub: Waterkyuu/soroi

A storefront gives these features a practical setting. The data access stays in the application's product repository; soroi coordinates how callers share its results.

Product details are often read repeatedly. A short freshness window lets the application reuse those reads. Create a Client at startup and inject it into the services that need it:

queries := soroi.NewClient(soroi.Options{
    StaleTime: 30 * time.Second,
})
defer queries.Close()
Enter fullscreen mode Exit fullscreen mode

The product endpoint can keep using its existing repository:

key := fmt.Sprintf("tenant:%d:product:%d", tenantID, productID)

product, err := soroi.Fetch(
    ctx,
    queries,
    key,
    func(ctx context.Context) (Product, error) {
        return productRepo.FindByID(ctx, tenantID, productID)
    },
)
if err != nil {
    return Product{}, err
}
Enter fullscreen mode Exit fullscreen mode

A cart lookup using the same Client and key can reuse the fresh product. If the first load is still running, overlapping callers can join it. The returned value stays a Product, so callers do not need to extract an any value and assert its type.

The key needs to match the business scope. Include the tenant and product ID, plus currency or customer tier whenever they change the result.

Price changes are where the refresh work usually spreads. Updating a product can affect both its detail and its category listing. Keeping those relationships beside the successful write makes them easier to find later:

changePrice := soroi.Mutation(
    queries,
    func(ctx context.Context, change PriceChange) (Product, error) {
        return productRepo.ChangePrice(ctx, tenantID, change)
    },
    func(_ context.Context, saved Product, change PriceChange) error {
        return queries.Invalidate([]any{
            fmt.Sprintf("tenant:%d:product:%d", tenantID, saved.ID),
            fmt.Sprintf("tenant:%d:category:%d", tenantID, change.CategoryID),
        })
    },
)
defer changePrice.Close()

result, err := changePrice.Mutate(ctx, input)
if err != nil {
    return Product{}, err
}
if result.SyncErr != nil {
    slog.ErrorContext(ctx, "price cache synchronization failed",
        "product_id", result.Data.ID, "error", result.SyncErr)
}
Enter fullscreen mode Exit fullscreen mode

Once the repository commits the write, the selected queries become stale. Ordinary endpoints reload them on the next read. Queries with enabled subscriptions can refresh in the background.

The separate synchronization error matters here: the price change may already have succeeded. A cache repair failure is a reason to repair the cache, rather than submit the business write again.

An admin page may also need to show the new price without a manual reload. If the application already uses server-sent events, a query subscription can feed that endpoint with product data and refresh state:

query := soroi.Query(queries, key,
    func(ctx context.Context) (Product, error) {
        return productRepo.FindByID(ctx, tenantID, productID)
    })
defer query.Close()

w.Header().Set("Content-Type", "text/event-stream")
response := http.NewResponseController(w)

for {
    select {
    case <-r.Context().Done():
        return
    case state, ok := <-query.Updates():
        if !ok {
            return
        }
        payload, err := json.Marshal(map[string]any{
            "product":    state.Data,
            "hasData":    state.HasData,
            "refreshing": state.Fetching,
            "failed":     state.Err != nil,
        })
        if err != nil {
            return
        }
        if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil {
            return
        }
        if err := response.Flush(); err != nil {
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The page can keep displaying the existing product while a refresh runs. If the refresh fails, it can retain that content and show an error instead of emptying the panel.

This flow shares a Client within one process. Subscriptions carry the latest query state and may skip intermediate changes. Order events and audit records that must be preserved individually still belong in the application's event flow.

Carts introduce another familiar problem: a list of product IDs that needs product details. When the repository supports a bulk read, the application can submit the known IDs together:

batcher, err := soroi.NewBatcher(
    ctx,
    func(ctx context.Context, ids []int64) (map[int64]soroi.BatchResult[Product], error) {
        found, err := productRepo.FindByIDs(ctx, tenantID, ids)
        if err != nil {
            return nil, err
        }
        results := make(map[int64]soroi.BatchResult[Product], len(ids))
        for _, id := range ids {
            product, ok := found[id]
            item := soroi.BatchResult[Product]{Data: product}
            if !ok {
                item.Err = ErrProductNotFound
            }
            results[id] = item
        }
        return results, nil
    },
    soroi.BatchOptions{MaxBatchSize: 100},
)
if err != nil {
    return nil, err
}
defer batcher.Close()

items := batcher.LoadMany(ctx, productIDs)
Enter fullscreen mode Exit fullscreen mode

FindByIDs still performs the actual bulk SQL query or product-service request. The results follow the input order, and each item carries its own data or error. The cart can handle a missing product without confusing it with a failure of the whole backend request.

This is useful for order aggregation and product enrichment. It organizes the loads around a bulk operation the data source already supports; it does not turn a loop of individual SQL queries into one query. LoadMany does not retain completed results as a cache.

Remote inventory reads need a different policy from product descriptions. A cart may tolerate a few seconds of cached stock, one retry after a network timeout, and a firm limit on the time spent waiting:

freshness := 3 * time.Second
timeout := 2 * time.Second
retries := 1

stock, err := soroi.Fetch(
    ctx,
    queries,
    fmt.Sprintf("warehouse:%d:sku:%d", warehouseID, skuID),
    func(ctx context.Context) (Stock, error) {
        return inventoryAPI.GetStock(ctx, warehouseID, skuID)
    },
    soroi.QueryOptions{
        StaleTime: &freshness,
        Timeout:   &timeout,
        Retry:     &retries,
        RetryIf: func(err error) bool {
            var networkErr net.Error
            return errors.As(err, &networkErr) && networkErr.Timeout()
        },
        RetryDelay: func(int) time.Duration {
            return 100 * time.Millisecond
        },
    },
)
if err != nil {
    return Stock{}, err
}
Enter fullscreen mode Exit fullscreen mode

A load initiated with this policy has a two-second budget, including retry waits, and retries a network timeout once. A caller joining an existing load uses that load's original policy. The inventory client must pass the context into its actual request for cancellation to work.

The short cache is for displaying inventory. Checkout validation and stock deduction still go through the inventory service's authoritative write path. Query retry settings also do not automatically retry the earlier price mutation.

After adoption, the existing application logs can show whether these shared reads are helping:

stats := queries.Stats()

slog.InfoContext(ctx, "product query statistics",
    "cache_hits", stats.CacheHits,
    "merged_requests", stats.MergedRequests,
    "loads", stats.Loads,
    "load_failures", stats.LoadFailures,
)
Enter fullscreen mode Exit fullscreen mode

These counters describe query activity for one Client. Actual SQL and HTTP call counts should still be recorded in the data access layer, especially when several query loads share a bulk callback.

soroi fits Go applications where several modules need the same data and writes need to trigger refreshes. Existing repositories and remote clients can stay in place while repeated query-management logic moves into a shared Client.

Top comments (0)