Skip to content

Connecting and the driver layer ​

What a connection is, what a document type has to declare, and the thin helpers over the official driver that everything else is built on.

Connecting, and what "not configured" does ​

NewMongoDB pings before it returns, so a bad password is a boot failure rather than a 500 later. And unlike the cache, an unconfigured Mongo does not degrade quietly — every call fails with MONGO_DISABLED naming the keys that are missing.

go
package main

import (
	"errors"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)

// --- Example 1: connecting, and what "not configured" looks like ------------
//
// core.NewMongoDB pings before it returns, so a wrong password or an
// unreachable replica set is a boot failure rather than a 500 on the first
// request that happens to need a document. The App then owns the handle:
// app.Shutdown closes it, and nothing else should.

// User is the document the rest of this topic reads and writes.
//
// CollectionName on the *value* — not on *User — is what satisfies
// core.IDocument, and it is what makes mongorepo.New[User](ctx) compile.
//
// ID is a bson.ObjectID rather than a string on purpose. Driver v2 refuses to
// decode a Mongo-generated ObjectID into a Go string unless the client opts in
// (Decoder.ObjectIDAsHexString), which core does not — so `ID string` only
// works for a collection whose ids you generate yourself. mongorepo fills in
// either spelling after Create, and core.MongoByID takes the hex form of both.
// The omitempty is load-bearing: without it a zero id is sent to the server and
// the generated one never happens.
type User struct {
	ID        bson.ObjectID `bson:"_id,omitempty"        json:"id"`
	Email     string        `bson:"email"                json:"email"`
	Name      string        `bson:"name"                 json:"name"`
	Status    string        `bson:"status"               json:"status"`
	Age       int64         `bson:"age"                  json:"age"`
	Tags      []string      `bson:"tags"                 json:"tags"`
	Logins    int64         `bson:"logins"               json:"logins"`
	Orders    int64         `bson:"order_count"          json:"order_count"`
	Joined    *time.Time    `bson:"joined"               json:"joined"`
	DeletedAt *time.Time    `bson:"deleted_at,omitempty" json:"deleted_at,omitempty"`
}

func (User) CollectionName() string { return "users" }

// OrderItem is an embedded document — the shape ElemMatch exists for.
type OrderItem struct {
	SKU   string  `bson:"sku"   json:"sku"`
	Qty   int64   `bson:"qty"   json:"qty"`
	Price float64 `bson:"price" json:"price"`
}

// Order is the second collection, so the examples have something to $lookup and
// two collections to write in one transaction.
type Order struct {
	ID       bson.ObjectID `bson:"_id,omitempty" json:"id"`
	UserID   bson.ObjectID `bson:"user_id"       json:"user_id"`
	Status   string        `bson:"status"        json:"status"`
	Total    float64       `bson:"total"         json:"total"`
	Items    []OrderItem   `bson:"items"         json:"items"`
	PlacedAt *time.Time    `bson:"placed_at"     json:"placed_at"`
}

func (Order) CollectionName() string { return "orders" }

// newApp connects and registers the connection as "default", which is what
// ctx.DBMongo() resolves. A second cluster is a second core.WithMongo under its
// own name, read back with ctx.DBSMongo(name).
func newApp(env core.IENV) (*core.App, core.IError) {
	m, err := core.NewMongoDB(env,
		// 200ms is already the default; naming it here is a reminder that every
		// command slower than this is logged, which is the cheapest query budget
		// a service can have.
		core.WithMongoSlowQuery(200*time.Millisecond),
	)
	if err != nil {
		// Nothing to fall back to: Mongo is not optional for a service that
		// stores its data there, and a handle that pretends otherwise only moves
		// the failure somewhere less obvious.
		return nil, err
	}
	return core.NewApp(env, core.WithMongo("default", m))
}

// mongoIsNeverNil is this example's entry point: what an *unconfigured* Mongo
// does when someone uses it anyway.
//
// Capabilities are never nil in v2, but they do not all behave the same way
// when they are missing. The cache degrades silently — a miss just recomputes.
// Mongo does not: a read that quietly returns nothing and a write that quietly
// goes nowhere are both worse than a clear failure, so every call fails with
// MONGO_DISABLED naming the configuration that is missing.
func mongoIsNeverNil(ctx core.IContext) core.IError {
	// "audit" was never registered, so this is the disabled handle rather than
	// nil: the failure is an error you can read instead of a nil-interface panic
	// in whichever line happened to touch it first.
	audit := ctx.DBSMongo("audit")

	_, err := audit.Count("events", nil)
	if !errors.Is(err, core.ErrMongoDisabled) {
		return core.New(500, "EXAMPLE_FAILED",
			"an unregistered connection should refuse every call")
	}
	ctx.Log().Info("unregistered connection refuses loudly",
		"enabled", audit.Enabled(), "code", err.GetCode())

	// The honest branch, for a path that can genuinely run without Mongo.
	if !ctx.DBMongo().Enabled() {
		return core.New(503, "EXAMPLE_FAILED", "the default connection should be enabled here")
	}

	// Ping is the readiness check. Housekeeping commands like this one are kept
	// out of the query log, so a probe running every few seconds does not bury
	// the queries the service actually ran.
	return ctx.DBMongo().Ping()
}

// reportingHandle sends heavy, staleness-tolerant reads to a secondary without
// changing the connection every other query uses.
//
// WithReadPreference returns a new handle; it does not mutate the one it was
// called on. A read from a secondary is a read from *behind* the primary —
// right for a dashboard, wrong for "insert it, then read it back".
func reportingHandle(ctx core.IContext) core.IMongoDB {
	return ctx.DBMongo().WithReadPreference(readpref.SecondaryPreferred())
}

Find, write, claim — through IMongoDB ​

Filters are bson.M, results decode into your structs, and writes report what they did rather than only whether they failed. See Queries.

go
package main

import (
	"errors"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/utils"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// --- Example 2: the driver layer -------------------------------------------
//
// core.IMongoDB is a thin set of helpers over the official driver: filters are
// bson.M, results decode into your structs, failures come back as core.IError.
// It is what mongorepo is built on, and what to reach for when a query does not
// fit a repository. The handle from ctx.DBMongo() is already bound to the
// request context: no method takes a ctx, and a cancelled request cancels the
// query it started.

func driverLayer(ctx core.IContext) core.IError {
	m := ctx.DBMongo()

	id, err := driverInsert(m)
	if err != nil {
		return err
	}
	if err := driverRead(ctx, m, id); err != nil {
		return err
	}
	if err := driverUpdate(ctx, m, id); err != nil {
		return err
	}
	return driverClaim(ctx, m)
}

// driverInsert returns the new id as a hex string, ready to hand to a filter —
// which is the whole point of returning it rather than the driver's own value.
func driverInsert(m core.IMongoDB) (string, core.IError) {
	id, err := m.InsertOne("users", User{
		Email:  "[email protected]",
		Name:   "Driver Example",
		Status: "active",
		Joined: utils.ToPointer(time.Now()),
	})
	// A unique-index collision is a 409 with an answer to give, not a 500.
	if errors.Is(err, core.ErrDuplicateKey) {
		var existing User
		findErr := m.FindOne(&existing, "users", bson.M{"email": "[email protected]"})
		if findErr != nil {
			return "", findErr
		}
		return existing.ID.Hex(), nil
	}
	if err != nil {
		return "", err
	}
	return id, nil
}

func driverRead(ctx core.IContext, m core.IMongoDB, id string) core.IError {
	// MongoByID parses the hex into an ObjectID. A filter built from the raw
	// string matches nothing, silently — the classic way to lose an afternoon.
	var user User
	if err := m.FindOne(&user, "users", core.MongoByID(id)); err != nil {
		return err
	}

	var active []User
	if err := m.Find(&active, "users", bson.M{"status": "active"}, core.MongoFindOptions{
		Sort:       []string{"-joined", "name"}, // "-" is descending
		Limit:      20,                          // without one, Find reads the whole result set
		Projection: bson.M{"tags": 0},
		MaxTime:    5 * time.Second, // bounded on the *server*, not merely abandoned here
	}); err != nil {
		return err
	}

	// Count is exact and walks the index; EstimatedCount reads metadata —
	// instant, and approximate after an unclean shutdown.
	exact, err := m.Count("users", bson.M{"status": "active"})
	if err != nil {
		return err
	}
	rough, err := m.EstimatedCount("users")
	if err != nil {
		return err
	}

	var statuses []string
	if err := m.Distinct(&statuses, "users", "status", nil); err != nil {
		return err
	}

	ctx.Log().Info("driver reads", "found", user.Name, "active", len(active),
		"count", exact, "estimated", rough, "statuses", statuses)
	return nil
}

func driverUpdate(ctx core.IContext, m core.IMongoDB, id string) core.IError {
	// An update takes operators, not a document: bson.M{"logins": 1} without a
	// $set is a replace in disguise and the driver rejects it.
	res, err := m.UpdateOne("users", core.MongoByID(id), bson.M{
		"$set": bson.M{"status": "active"},
		"$inc": bson.M{"logins": 1},
	})
	if err != nil {
		return err
	}
	// Matched == 0 is "no such document"; Matched > 0 && Modified == 0 is "found
	// it, already correct". Telling those apart is why writes return a result.
	ctx.Log().Info("driver update", "matched", res.Matched, "modified", res.Modified)

	// One round trip for many independent writes. Unordered keeps going after a
	// failure — one bad document should not abandon the other nine hundred.
	bulk, err := m.BulkWrite("users", []mongo.WriteModel{
		mongo.NewUpdateManyModel().
			SetFilter(bson.M{"status": "new"}).
			SetUpdate(bson.M{"$set": bson.M{"status": "active"}}),
		mongo.NewDeleteManyModel().SetFilter(bson.M{"status": "expired"}),
	}, false)
	if err != nil {
		return err
	}
	ctx.Log().Info("driver bulk", "modified", bulk.Modified, "deleted", bulk.Deleted)
	return nil
}

// driverClaim is the read-then-write that must not be two operations: a Find
// followed by an Update lets two workers read the same document and both decide
// they own it. FindOneAndUpdate does both in one, so exactly one wins.
func driverClaim(ctx core.IContext, m core.IMongoDB) core.IError {
	var claimed User
	err := m.FindOneAndUpdate(&claimed, "users",
		bson.M{"status": "active"},
		bson.M{"$inc": bson.M{"logins": 1}},
		core.MongoFindModifyOptions{
			Sort:      []string{"joined"}, // which one, when several match
			ReturnNew: true,               // decode the document *after* the change
		})
	if errors.Is(err, core.ErrDocumentNotFound) {
		ctx.Log().Info("nothing to claim") // an empty queue is not a failure
		return nil
	}
	if err != nil {
		return err
	}
	ctx.Log().Info("claimed", "user", claimed.Name, "logins", claimed.Logins)
	return nil
}

Maintained by Passakon Puttasuwan & Dev Core Team.