Skip to content

Connections & configuration ​

A connection is opened once, at startup, and registered on the App. Nothing opens one per request — that was v1's bug, and it is why IContext.Close() no longer exists.

go
func main() {
    env, err := core.NewEnv()
    if err != nil {
        panic(err)
    }

    db, err := core.NewDatabase(env)
    if err != nil {
        panic(err)          // a misconfigured database is a boot failure
    }

    app, err := core.NewApp(env, core.WithSQL("default", db))
    if err != nil {
        panic(err)
    }
    defer app.Shutdown(context.Background())
    // ...
}

NewDatabase connects eagerly, so a wrong password is an error at boot rather than a 500 on the first request that happens to need the database.

Drivers ​

postgres, mysql, sqlserver and oracle. The driver comes from DB_DRIVER, or is inferred from the scheme of a connection string:

SchemeDriverDB_DRIVER
postgres://, postgresql://postgrespostgres
mysql://mysqlmysql
sqlserver://, mssql://SQL Serversqlserver
oracle://Oracleoracle

sqlite ใช้ได้ในเทสต์ผ่าน coretest ไม่ใช่ผ่าน NewDatabase — schema ที่ deploy จริงไม่เคยเป็น sqlite

SQL Server ​

sh
DB_DRIVER=sqlserver
DB_HOST=mssql DB_PORT=1433 DB_NAME=app DB_USER=sa DB_PASSWORD='p@ss/word'

รหัสผ่านถูก escape ผ่าน userinfo ของ URL ตัวที่มี @ หรือ / (ซึ่งรหัสผ่านที่ generate มามักมี) จึงไม่ทำให้ DSN แตกผิดที่

Oracle ​

sh
DB_DRIVER=oracle
DB_HOST=oracle DB_PORT=1521      # 1521 คือ default ไม่ต้องใส่ก็ได้
DB_NAME=ORCLPDB1                 # service name
DB_SID=ORCL                      # ใส่เมื่อเชื่อมด้วย SID แทน service name
DB_USER=app DB_PASSWORD=secret

Oracle ตั้งชื่อ service หรือ SID ไม่ใช่ database: DB_NAME คือ service name, DB_SID คือ SID

Configuring the DSN ​

Two ways, and the first one wins outright:

sh
# 1. one URI — every other DB_* field is ignored
DB_CONNECTION_STRING=postgres://user:pass@host:5432/mydb?sslmode=disable

# 2. discrete fields
DB_DRIVER=postgres
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mydb
DB_USER=app
DB_PASSWORD=secret
DB_SSLMODE=disable        # postgres only

Use the URI when the DSN needs an option the discrete fields do not cover — a connection timeout, a search path, a client certificate. Use the fields when the deployment already builds the parts separately (a Kubernetes secret per value, say), because assembling them into a URI only adds a place to get quoting wrong.

A mysql://… URI is converted to the Go DSN the mysql driver expects (user:pass@tcp(host:port)/db), with parseTime=True, charset=utf8mb4 and loc=UTC filled in when they are not already there. parseTime matters: a time.Time column scans as []byte without it.

Every key, with defaults, is in Configuration → Database.

The pool ​

Pool sizes are not environment keys — they are options on NewDatabase, because the right numbers depend on what the process is (an API serving 200 concurrent requests and a cron worker running one query at a time do not want the same pool) rather than on which environment it runs in:

go
db, err := core.NewDatabase(env,
    core.WithMaxOpenConns(50),                 // default 20
    core.WithMaxIdleConns(10),                 // default 5
    core.WithConnMaxLifetime(30*time.Minute),  // default 1h
)
OptionWhat it boundsGetting it wrong
WithMaxOpenConnsconnections this process may hold at oncetoo high and N replicas exhaust the server's max_connections; too low and requests queue behind each other
WithMaxIdleConnsconnections kept open while idletoo low and a bursty service reconnects constantly
WithConnMaxLifetimehow long one connection is reusedtoo long and a connection outlives a failover or a proxy's idle timeout, failing the query that finds out

The arithmetic worth doing once: MaxOpenConns × replicas must stay comfortably under the server's connection limit, with room left for migrations, other services and a human with psql.

Named connections ​

Register as many as the service needs. "default" is what ctx.DB() returns; everything else is ctx.DBS(name):

go
primary, _ := core.NewDatabase(env)   // from DB_*

// a second connection is a second *gorm.DB — NewDatabase reads one set of
// DB_* keys, so anything else is opened directly
replica, _ := gorm.Open(postgres.Open(env.String("DB_REPLICA_CONNECTION_STRING")),
    &gorm.Config{NowFunc: func() time.Time { return time.Now().UTC() }})

app, _ := core.NewApp(env,
    core.WithSQL("default", primary),
    core.WithSQL("readonly", replica),
)

env.String reads any key that is not part of ENVConfig, so a second DSN needs no change to the framework — see Configuration.

go
ctx.DB()                 // primary
ctx.DBS("readonly")      // the replica
repository.NewWithDB[User](ctx, ctx.DBS("readonly")).FindAll()

Reads sent to a replica are reads that arrive behind the primary. That is fine for a report and wrong for "create it, then read it back" — route by whether the caller can tolerate staleness, not by whether the statement is a SELECT.

ctx.DBS on a name that was never registered returns nil, which panics at the first use. It is a wiring mistake, and the fastest way to find one is for it to be loud.

Health checks ​

go
sqlDB, err := ctx.DB().DB()
if err == nil {
    err = sqlDB.PingContext(ctx)
}

Use it in a readiness probe, not a liveness probe: a database that is briefly unreachable should stop traffic being routed to the pod, not restart it.

Shutdown ​

app.Shutdown(ctx) closes the pools it was given, after the HTTP server has stopped accepting and in-flight work has drained. Handing a *gorm.DB to core.WithSQL transfers that responsibility — do not also close it yourself.

go
defer app.Shutdown(context.Background())

Using a connection the framework did not open ​

core.WithSQL takes any *gorm.DB, so a service with its own dialector, plugins or an existing *sql.DB wires that one instead:

go
gdb, _ := gorm.Open(postgres.New(postgres.Config{Conn: existingSQLDB}), &gorm.Config{})
app, _ := core.NewApp(env, core.WithSQL("default", gdb))

The framework's SQL logger and the UTC NowFunc are set up by NewDatabase — a hand-built connection has whatever you configured on it and nothing more.

Tracing queries ​

Query spans and db.* breadcrumbs in Sentry are opt-in, on any connection:

go
_ = core.InstrumentGorm(db)
app, _ := core.NewApp(env, core.WithSQL("default", db))

See Sentry for what it records and how to tune it.

Maintained by Passakon Puttasuwan & Dev Core Team.