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:
| Profile | Source path | How it gets on the classpath |
|---|---|---|
| dev | env/dev/clj | :dev alias in deps.edn |
| test | env/test/clj | :test alias in deps.edn + tests.edn |
| prod | env/prod/clj | Copied/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 {...}}})
| Key | Arity | When it runs | Typical use |
|---|---|---|---|
:init | (fn []) | First thing in start-app, before system.edn is read | Configure log levels, one-time process setup |
:start | (fn [system]) | After ig/init, receives the running system map | Seed 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 middleware | Dev-only request tooling (currently a no-op wrapper) |
:opts | map | Passed 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) viasaas.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 tosecrets.example.edn. Under the:testprofilesystem.edndisables the HTTP server (:server/httpis nil) — tests call handlers directly via ring-mock.
prod (env/prod/clj/saas/env.clj)
:init—saas.*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 atsecrets.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
- Decide which hook fits: process-level setup →
:init; needs a running component (db, email, etc.) →:start; teardown →:stop. - Edit the hook in each
env/{dev,test,prod}/clj/saas/env.cljthat needs the behavior. Keep the map shape and hook arities identical across all three files —saas.corecalls them the same way regardless of profile. - 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 howsaas.dev-userworks). - Never
:requiresuch 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 (seesaas.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.envis 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.envmust not requiresaas.core(circular dependency). Anything a hook needs from the system should come through the:startargument.:initruns before the config is read, so it cannot seesystem.ednvalues — it is for process-level setup only.- For runtime branching inside shared code, inject the
:system/envIntegrant component (fromsystem.edn) instead of sniffing the classpath.