Skip to content

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.

go
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.

go
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")
	}
}

Maintained by Passakon Puttasuwan & Dev Core Team.