Skip to content

Transactions ​

go
err := ctx.DBMongo().Transaction(func(tx core.IMongoDB) error {
    if _, err := tx.InsertOne("orders", order); err != nil {
        return err
    }
    _, err := tx.UpdateOne("stock", bson.M{"sku": sku},
        bson.M{"$inc": bson.M{"qty": -1}})
    return err
})

Returning an error or panicking aborts it; returning nil commits.

You need a replica set ​

Mongo only offers transactions on a replica set or a sharded cluster. A standalone server fails with a message saying so — including the single-node docker run mongo most people develop against.

A single-node replica set is enough, and is what a compose file for local development should use:

yaml
mongo:
  image: mongo:7
  command: ["--replSet", "rs0", "--bind_ip_all"]
sh
mongosh --eval 'rs.initiate()'
sh
DB_MONGO_REPLICA_NAME=rs0

Without it, code that works in production fails locally with an error nobody recognises — and, worse, the reverse: a test suite on a standalone server never exercises the transactional path at all.

The rule that causes every bug ​

Use the tx handle inside. The outer handle is not in the transaction, and a write made through it commits immediately and survives the abort:

go
err := m.Transaction(func(tx core.IMongoDB) error {
    tx.InsertOne("orders", order)          // ✅ in the transaction
    m.InsertOne("audit", entry)            // ❌ committed regardless
    return errors.New("boom")              // order rolls back, entry does not
})

The same applies to ctx.DBMongo() and to any repository built with New inside the closure. If it did not get tx, it is not part of the transaction.

With repositories ​

One collection:

go
err := users.Transaction(func(tx *mongorepo.Repo[User]) error {
    if err := tx.Create(&user); err != nil {
        return err
    }
    _, err := tx.ByID(id).Inc("seats", -1)
    return err
})

Several collections — take the handle from core.IMongoDB and bind each repository to it:

go
err := ctx.DBMongo().Transaction(func(tx core.IMongoDB) error {
    users  := mongorepo.NewIn[User](tx)
    orders := mongorepo.NewIn[Order](tx)

    if err := orders.Create(&order); err != nil {
        return err
    }
    _, err := users.ByID(order.UserID).Inc("order_count", 1)
    return err
})

The context trap ​

Every helper on the tx handle already uses the session's context. A raw driver call does not, unless you give it one:

go
err := m.Transaction(func(tx core.IMongoDB) error {
    coll := tx.Collection("users")

    coll.InsertOne(tx.Context(), doc)              // ✅ in the transaction
    coll.InsertOne(context.Background(), doc)      // ❌ silently outside it
    return nil
})

There is no error for the second one. It succeeds, it is not rolled back, and nothing in the logs distinguishes it. tx.Context() inside a transaction is the session's context — use it for every driver call.

What belongs inside ​

Only the writes that must succeed or fail together. A Mongo transaction holds a session and locks on the documents it touches, and it has a 60-second server limit by default.

Keep outWhy
HTTP calls to another servicecannot be rolled back, and its latency is now lock-hold time
S3 uploadssame — an aborted transaction leaves the object behind
Publishing to pub/sub or a queuesubscribers can see an event for a transaction that then aborts
Long loops over many documentsthe 60s limit, and the write conflicts

The usual shape: commit first, then act on it.

go
if err := ctx.DBMongo().Transaction(func(tx core.IMongoDB) error {
    _, err := tx.InsertOne("orders", order)
    return err
}); err != nil {
    return err
}
ctx.PubSub().Publish("order.created", order)   // after the commit

Write conflicts ​

Two transactions writing the same document give one of them a transient transaction error. Mongo's driver retries the commit for some of these, but a conflict inside the callback surfaces to you.

Prefer designs that do not conflict:

  • Atomic operators — $inc, $push, $addToSet need no transaction at all, and no read-modify-write.
  • FindOneAndUpdate — claim a document in one operation instead of reading it, deciding, and writing it back.
  • One document — a write to a single document is already atomic. Embedding the thing that must stay consistent is often better than a transaction across two collections; that is the modelling advantage Mongo has, and a transaction is what you reach for when the model could not take it.

Testing ​

Transactional behaviour cannot be faked. Run the integration suite against a real replica set:

sh
docker run -p 27017:27017 mongo --replSet rs0
mongosh --eval 'rs.initiate()'
APP_DB_MONGO_HOST=127.0.0.1 APP_DB_MONGO_NAME=coretest \
  APP_DB_MONGO_REPLICA_NAME=rs0 make test-integration

See Integration tests.

Maintained by Passakon Puttasuwan & Dev Core Team.