Coming up
What a process reads before it serves anything, and what it should refuse to start without.
Building the App from what is configured
A connection is opened only when its configuration is present, and what is absent is a disabled implementation rather than a nil — which is what lets the same binary run in an environment with no redis and no bucket. The boot log is the counterweight: it states what the process was assembled from, because a missing connection is otherwise invisible until the first request that needed it.
package main
import (
"time"
core "gitlab.finema.co/finema/idin-core/v2"
)
// --- Example 1: bootstrap — build the App out of what is configured ----------
//
// The App is built once and holds everything that lives as long as the process:
// connection pools, the base logger, the Sentry client. Contexts are minted from
// it per request and per job run and own nothing — which is the v1 bug this
// split exists to fix, where IContext.Close() tore down shared pools on every
// request.
//
// A connection is opened only when its configuration is present. That is not
// tidiness: it is what lets the same binary run in an environment with no redis
// and no bucket without a single `if cache != nil` anywhere above, because an
// absent capability is a disabled implementation rather than a nil.
func bootstrap(env core.IENV) (*core.App, core.IError) {
cfg := env.Config()
opts := make([]core.Option, 0, 4)
// SQL. Pool sizes are deliberately not environment keys: they belong to a
// deployment's capacity plan, which is a decision worth reviewing in a diff
// rather than one worth changing from a dashboard at 2am.
if cfg.DBConnectionString != "" || cfg.DBHost != "" {
db, err := core.NewDatabase(env,
core.WithMaxOpenConns(20),
core.WithMaxIdleConns(5),
core.WithConnMaxLifetime(time.Hour),
)
if err != nil {
return nil, err
}
opts = append(opts, core.WithSQL("default", db))
// a read replica would be core.WithSQL("replica", replicaDB), reached
// with ctx.DBS("replica") — and it gets its own readiness check
}
// Cache. Missing CACHE_* is not an error: ctx.Cache() then reads as a miss
// and drops writes, so cache-aside code runs unchanged with and without
// redis. Silent degradation is safe here precisely because a miss can always
// be recomputed.
if cfg.CacheConnectionString != "" || cfg.CacheHost != "" || cfg.CacheAddrs != "" {
cache, err := core.NewCache(env)
if err != nil {
return nil, err
}
opts = append(opts, core.WithCache("default", cache))
}
// Storage and MQ are the opposite choice: unconfigured, every call fails
// with STORAGE_DISABLED / MQ_DISABLED instead of degrading quietly. A file
// that was silently dropped and a message nobody ever received cannot be
// recomputed, so the failure has to be loud at the call site.
if cfg.S3Bucket != "" {
storage, err := core.NewStorage(env)
if err != nil {
return nil, err
}
opts = append(opts, core.WithStorage(storage))
}
if cfg.MQConnectionString != "" || cfg.MQHost != "" {
mq, err := core.NewMQ(env)
if err != nil {
return nil, err
}
opts = append(opts, core.WithMQ(mq))
}
// Sentry is not in this list on purpose: NewApp builds it from SENTRY_DSN,
// and with no DSN every method is a no-op. Error reporting is therefore
// wired identically in every environment, so no reporting path is exercised
// for the first time in production.
return core.NewApp(env, opts...)
}
// The boot log states what the process was actually assembled from:
//
// {"level":"INFO","msg":"app ready","env":"dev","service":"example-service",
// "sql":[],"mongo":[],"cache":[],"mq":false,"storage":false,
// "mailer":false,"pusher":false,"sentry":false}
//
// It is the counterweight to everything above: because a missing connection
// degrades rather than refusing to boot, one absent line of configuration is
// otherwise invisible until the first request that needed it — and by then the
// symptom ("nothing is being cached") is several layers from the cause.
//
// core.Runner emits it as the first thing it does, so 03_runner.go gets it for
// free. A process that starts itself some other way calls app.LogCapabilities()
// directly; NewApp deliberately does not, because building the container is not
// the same event as starting the process, and every test builds one.Keys, defaults, and failing at boot
The APP_ prefix belongs to OS variables and never to the .env file — a key written the wrong way round binds nothing and reports nothing. Adding a key, giving it a default next to the field it fills, and rejecting a misspelled ROLE before it becomes a deployment with no worker in it.
package main
import (
"net/http"
"strconv"
"strings"
core "gitlab.finema.co/finema/idin-core/v2"
)
// --- Example 5: configuration, and failing at boot instead of at 2am ---------
//
// core.NewEnv() reads ./.env (or ./test.env when APP_ENV=test), then lets
// APP_-prefixed environment variables override it. Two naming rules, and the
// second one is where the hours go:
//
// OS environment APP_DB_HOST=localhost -> key db_host
// .env file DB_HOST=localhost -> key db_host
//
// An OS variable without APP_ is ignored entirely (so the system's own HOST and
// PATH cannot collide with configuration). A key written *with* APP_ inside the
// file binds "app_db_host", matches no field, and reports nothing — the value
// is simply never read.
//
// Adding a key is one field in ENVConfig with a koanf tag; the loader binds it
// automatically, and unlike v1 there is no second list to forget:
//
// NewFeatureURL string `koanf:"new_feature_url"` // APP_NEW_FEATURE_URL=...
//
// A bool that should default to true cannot be a bool field: "unset" and
// "explicitly false" are the same zero value. Read it as a string first, which
// is how the framework's own LOG_SOURCE and SENTRY_CAPTURE_BODY work.
func loadConfig() (core.IENV, core.IError) {
// NewEnv already fails on an APP_ENV that is not dev|test|mock|prod and on a
// malformed Sentry DSN. Everything the framework cannot know goes below.
env, err := core.NewEnv()
if err != nil {
return nil, err
}
if err := validateConfig(env); err != nil {
return nil, err
}
return env, nil
}
// serviceConfig is this service's own configuration, resolved once at boot so
// that a bad value is a failed deploy rather than a failed request. Parsing a
// setting on every use spreads the same error across every code path that reads
// it, and delays it until the one request that happened to take that path.
type serviceConfig struct {
Role role
// GreetingsPerMinute has no field in ENVConfig because it belongs to this
// service, not to the framework. env.String/Int is the escape hatch for
// exactly that; a key the framework owns should be added to ENVConfig.
GreetingsPerMinute int
}
const defaultGreetingsPerMinute = 60
func newServiceConfig(env core.IENV) (serviceConfig, core.IError) {
cfg := serviceConfig{
Role: roleFrom(env),
// a default belongs here, next to the field it fills, and not in a .env
// checked into the repository — the file then holds only what a
// deployment actually overrides
GreetingsPerMinute: defaultGreetingsPerMinute,
}
if raw := env.String("greetings_per_minute"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return cfg, core.Newf(http.StatusInternalServerError, "INVALID_CONFIG",
"GREETINGS_PER_MINUTE must be a positive integer, got %q", raw)
}
cfg.GreetingsPerMinute = n
}
return cfg, nil
}
func validateConfig(env core.IENV) core.IError {
cfg := env.Config()
// A misspelled role is the failure this catches. roleFrom() falls back to
// "api" for an unset key, which is the right default — but it would also
// swallow APP_ROLE=wroker and quietly deploy a second API instead of the
// worker, leaving every scheduled job unrun with nothing in any log to say so.
if r := strings.ToLower(strings.TrimSpace(env.String("role"))); r != "" {
switch role(r) {
case roleAPI, roleWorker, roleAll:
default:
return core.Newf(http.StatusInternalServerError, "INVALID_CONFIG",
"ROLE %q must be one of api|worker|all", r)
}
}
// SERVICE tags every Sentry event. Unset, a production incident arrives with
// no way to tell which service raised it.
if env.IsProd() && cfg.Service == "" {
return core.New(http.StatusInternalServerError, "INVALID_CONFIG",
"SERVICE must be set in production")
}
if _, err := newServiceConfig(env); err != nil {
return err
}
return nil
}
// logConfigWarnings covers what should be a warning rather than a refusal to
// start. ENV unset makes IsDev, IsTest, IsMock and IsProd all report false, so
// `if !env.IsProd() { … }` silently behaves as though it were dev — in
// production. It cannot be checked in loadConfig, which runs before any logger
// exists, so main calls this once the App is built.
func logConfigWarnings(app *core.App) {
if app.Config().ENV == "" {
app.Log().Warn("ENV is not set — every environment gate reads as false",
"expected", "dev|test|mock|prod")
}
}