Environments & Lifecycle Hooks

ShipClojure runs in one of three environments (profiles): dev, test, and prod. Each environment customizes logging, secrets resolution, and system startup/shutdown behavior through a small, well-defined hook system.

How it works: classpath-swapped namespaces

There is no runtime if (= env :prod) switch for environment behavior. Instead, each environment provides its own version of the saas.env namespace, and only one of them is ever on the classpath:

ProfileSource pathHow it gets on the classpath
devenv/dev/clj:dev alias in deps.edn
testenv/test/clj:test alias in deps.edn + tests.edn
prodenv/prod/cljCopied/compiled into the uberjar by build.clj

All three define the same var — saas.env/defaults — with the same shape. The rest of the application (notably saas.core and saas.config) requires saas.env and stays completely environment-agnostic.

Some environments also ship extra namespaces alongside saas.env:

  • dev: saas.dev-user (seeds a local dev user), saas.dev-middleware, saas.content.export
  • test: saas.hooks (Kaocha hooks), saas.test-util
  • dev/prod: environment-specific saas.content.* for the blog/content system

The defaults contract

Each env/{profile}/clj/saas/env.clj must define:

(def defaults
  {:init       (fn [] ...)        ;; runs BEFORE config is read / system starts
   :start      (fn [system] ...)  ;; runs AFTER the system is initialized
   :stop       (fn [] ...)        ;; runs BEFORE the system is halted
   :middleware ...                ;; env-specific ring middleware wrapper
   :opts       {:profile  :dev    ;; aero options for reading system.edn
                :resolver {...}}})
KeyArityWhen it runsTypical use
:init(fn [])First thing in start-app, before system.edn is readConfigure log levels, one-time process setup
:start(fn [system])After ig/init, receives the running system mapSeed data (dev user), attach log handlers, "started" log
:stop(fn [])First thing in stop-app, before ig/halt!"shutting down" log, env-specific cleanup
:middleware(fn [handler opts])Extension point for env-specific ring middlewareDev-only request tooling (currently a no-op wrapper)
:optsmapPassed to aero when reading resources/system.edn:profile for #profile readers, :resolver for #include

The :start hook receives the initialized system map (the same map stored in the saas.core/system atom), so it can grab components directly, e.g. (:db.sql/connection system). Do not require saas.core from saas.env to reach the system atom — that creates a circular dependency; this is exactly why the system is passed in as an argument.

Lifecycle flow

saas.core/start-app and stop-app drive the hooks:

start-app
  1. (:init defaults)                 ;; e.g. configure log levels
  2. saas.config/system-config        ;; aero reads system.edn with (:opts defaults)
  3. ig/expand -> ig/init             ;; system comes up
  4. (reset! system ...)              ;; stored in the saas.core/system atom
  5. ((:start defaults) @system)      ;; e.g. seed dev user

stop-app
  1. (:stop defaults)                 ;; e.g. shutdown log
  2. ig/halt!                         ;; system goes down
  3. (reset! system nil)

Callers can override any hook per invocation by passing a params map, which is how the test fixtures force the test profile from a dev REPL:

(core/start-app {:opts {:profile :test
                        :resolver {".secrets.edn" (io/resource "secrets.example.edn")}}})

See saas.test-util/system-fixture and saas.hooks in env/test/clj.

In production, -main additionally registers a JVM shutdown hook that calls stop-app followed by shutdown-agents.

What each environment configures

dev (env/dev/clj/saas/env.clj)

  • :init — verbose app logging (saas.* at :debug), noisy third-party libraries clamped to :warn (see Logging)
  • :start — seeds the dev user (dev@saas.dev) via saas.dev-user/seed-dev-user!
  • :opts:profile :dev, resolves #include ".secrets.edn" to the real .secrets.edn resource, :persist-data? true

test (env/test/clj/saas/env.clj)

  • :init — quiet logging (most third-party libs at :error)
  • :opts:profile :test, secrets resolve to secrets.example.edn. Under the :test profile system.edn disables the HTTP server (:server/http is nil) — tests call handlers directly via ring-mock.

prod (env/prod/clj/saas/env.clj)

  • :initsaas.* at :info, third-party libs at :warn/:info
  • :start — attaches a rotating JSON file log handler (logs/saas-app.json, see Logging)
  • :opts:profile :prod. The resolver intentionally points at secrets.example.edn: its values are #or [#env ...] forms, so real production secrets come from environment variables (e.g. fly secrets), with the example values as dev-friendly fallbacks (see Secrets).

All lifecycle logs use structured Telemere signals with stable ids so they can be filtered/queried: :saas/starting, :saas/started, :saas/stopped, each with :data {:profile ...}.

Adding environment-specific behavior

  1. Decide which hook fits: process-level setup → :init; needs a running component (db, email, etc.) → :start; teardown → :stop.
  2. Edit the hook in each env/{dev,test,prod}/clj/saas/env.clj that needs the behavior. Keep the map shape and hook arities identical across all three files — saas.core calls them the same way regardless of profile.
  3. For behavior that needs new code, add namespaces under the relevant env/{profile}/clj/ directory so they only exist on that profile's classpath (this is how saas.dev-user works).
  4. Never :require such namespaces from cljs files on the shared classpath (src/cljs, src/cljc) — the release build runs on the base classpath and fails to resolve them. Gate the code behind a compile-time macro instead (see saas.dev-only/with-dev-credentials, used by the login page's "Use dev account" button).

Example — dev-only seeding against the running database:

;; env/dev/clj/saas/env.clj
:start (fn [system]
         (seed-dev-user! (:db.sql/connection system))
         (t/log! {:id :saas/started :data {:profile :dev}}
                 "saas started successfully"))

Gotchas

  • Only one saas.env is visible at a time. If you rename a var in one env file, rename it in all three, or the other profiles will fail to compile (CI runs the test profile; the uberjar build compiles prod).
  • saas.env must not require saas.core (circular dependency). Anything a hook needs from the system should come through the :start argument.
  • :init runs before the config is read, so it cannot see system.edn values — it is for process-level setup only.
  • For runtime branching inside shared code, inject the :system/env Integrant component (from system.edn) instead of sniffing the classpath.