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.