AI Agent Quick Reference: This guide provides everything an AI coding agent needs to work effectively with the Boundary Framework - from quick commands to architectural patterns.
🛑 CRITICAL REMINDERS - READ THESE FIRST
GIT OPERATIONS - REQUIRE EXPLICIT PERMISSION:
- ❌ NEVER stage files with
git addwithout asking first- ❌ NEVER commit with
git commitwithout explicit user permission- ❌ NEVER push with
git pushwithout explicit user permission- ✅ ALWAYS show user what changes will be committed and ASK before committing
- ✅ ALWAYS ask "Should I commit and push these changes?" and wait for confirmation
CODE EDITING:
- Use
clj-paren-repairto fix unbalanced parentheses (never manually repair delimiters)- Use
clj-nrepl-evalfor REPL evaluation during development- Follow parinfer conventions for proper formatting
- All documentation must be kept up to date, accurate and in the English language
Boundary is a module-centric software framework built on Clojure that implements the "Functional Core / Imperative Shell" architectural paradigm. Our PRD can be found here.
user, billing, workflow, error_reporting, logging, metrics) contains complete functionality from pure business logic to external interfacesGoals:
Non-Goals:
For macOS (using Homebrew):
# Install required dependencies
brew install openjdk clojure/tools/clojure
For Linux (Ubuntu/Debian):
# Install JDK
sudo apt-get update
sudo apt-get install -y default-jdk rlwrap
# Install Clojure CLI
curl -L -O https://download.clojure.org/install/linux-install-1.12.3.1577.sh
chmod +x linux-install-*.sh && sudo ./linux-install-*.sh
Install Babashka and bbin (required for Clojure tooling):
# macOS
brew install babashka/brew/babashka borkdude/brew/bbin
# Linux
bash <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
bash <(curl -s https://raw.githubusercontent.com/babashka/bbin/main/bbin)
Install clj-paren-repair (automatic parenthesis fixing):
bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-paren-repair --main-opts '["-m" "clojure-mcp-light.paren-repair"]'
# Verify installation
clj-paren-repair --help
Install clj-nrepl-eval (REPL evaluation from command line):
bbin install https://github.com/bhauman/clojure-mcp-light.git --tag v0.2.1 --as clj-nrepl-eval --main-opts '["-m" "clojure-mcp-light.nrepl-eval"]'
# Verify installation
clj-nrepl-eval --help
# Clone the repository
git clone <repository-url> boundary
cd boundary
# Run tests to verify setup
clojure -M:test:db/h2
# Start a development REPL
clojure -M:repl-clj
# In the REPL, load and start the system
user=> (require '[integrant.repl :as ig-repl])
user=> (ig-repl/go) ; Start the system
# Check if tests pass
clojure -M:test:db/h2
# Lint the codebase
clojure -M:clj-kondo --lint src test
# Check for outdated dependencies (if alias exists)
clojure -M:outdated
The command clj-nrepl-eval is installed on your path for evaluating Clojure code via nREPL.
Discover nREPL servers:
clj-nrepl-eval --discover-ports
Evaluate code:
clj-nrepl-eval -p <port> "<clojure-code>"
# With timeout (milliseconds)
clj-nrepl-eval -p <port> --timeout 5000 "<clojure-code>"
Note: The REPL session persists between evaluations - namespaces and state are maintained. Always use :reload when requiring namespaces to pick up changes.
Use clj-kondo via Bash after making Clojure edits to verify syntax:
clojure -M:clj-kondo --lint src test
Use clojure-lsp CLI commands when available for operations like:
clojure-lsp format --dry # Check formatting
clojure-lsp clean-ns # Clean up namespaces
clojure-lsp diagnostics # Deeper analysis
# Testing
clojure -M:test:db/h2 # All tests (requires H2 driver)
clojure -M:test:db/h2 -n boundary.user.core.user-test # Single namespace
clojure -M:test:db/h2 --focus-meta :unit # Unit tests only
clojure -M:test:db/h2 --focus-meta :user # User module tests
clojure -M:test:db/h2 --watch --focus-meta :unit # Watch mode
# Code Quality
clojure -M:clj-kondo --lint src test # Lint codebase
clojure -M:clj-kondo --config .clj-kondo/config.edn --lint src # With config
# REPL Development
clojure -M:repl-clj # Start REPL
# In REPL:
(require '[integrant.repl :as ig-repl])
(ig-repl/go) # Start system
(ig-repl/reset) # Reload and restart
(ig-repl/halt) # Stop system
# Build
clojure -T:build clean && clojure -T:build uber # Build uberjar
# Clojure LSP & Tools
clojure-lsp format --dry # Check formatting
clojure-lsp clean-ns # Clean namespaces
clojure-lsp diagnostics # Deeper analysis
# nREPL Evaluation
clj-nrepl-eval --discover-ports # Find nREPL ports
clj-nrepl-eval -p <port> "<clojure-code>" # Evaluate code
# Parenthesis Repair
clj-paren-repair <file> # Fix one file
clj-paren-repair <file1> <file2> # Fix multiple files
# By Test Type
clojure -M:test:db/h2 --focus-meta :unit # Pure core functions
clojure -M:test:db/h2 --focus-meta :integration # Shell services
clojure -M:test:db/h2 --focus-meta :contract # Adapter implementations
# By Module
clojure -M:test:db/h2 --focus-meta :user # User module
clojure -M:test:db/h2 --focus-meta :billing # Billing module
| Layer | Rules | Examples |
|---|---|---|
Core (core/*) | Pure functions only, no side effects | user.clj, audit.clj |
Shell (shell/*) | All side effects, I/O, validation | service.clj, persistence.clj, http.clj |
Ports (ports.clj) | Protocol definitions (abstractions) | IUserService, IUserRepository |
Schema (schema.clj) | Malli schemas for validation | CreateUserRequest, UserEntity |
;; Functions and variables: kebab-case
(defn calculate-user-tier [user] ...)
(def max-login-attempts 5)
;; Predicates: end with ?
(defn active-user? [user] ...)
;; Collections: plural
(def users [...])
(def audit-logs [...])
;; Records: PascalCase
(defrecord UserService [user-repository session-repository] ...)
ALWAYS use kebab-case internally. Convert snake_case ONLY at system boundaries.
| Location | Format | Example | Reason |
|---|---|---|---|
| Clojure code | kebab-case | :password-hash, :created-at | Clojure convention |
| Database columns | snake_case | password_hash, created_at | SQL convention |
| API requests/responses | camelCase | passwordHash, createdAt | JSON/REST convention |
Conversion Points (System Boundaries):
db->entity and entity->db functions transform between snake_case (DB) and kebab-case (internal)Common Pitfall Example:
;; ❌ WRONG - Using snake_case internally
(defn authenticate [user password]
(verify-password password (:password_hash user))) ; BUG! Internal should be kebab-case
;; ✅ CORRECT - Using kebab-case internally
(defn authenticate [user password]
(verify-password password (:password-hash user))) ; Correct internal convention
;; ✅ CORRECT - Conversion at persistence boundary
(defn db->user-entity [db-record]
(-> db-record
(clojure.set/rename-keys {:password_hash :password-hash
:created_at :created-at})))
Utility Functions (in boundary.shared.core.utils.case-conversion):
;; Database conversions
(kebab-case->snake-case-map entity) ; Before INSERT/UPDATE
(snake-case->kebab-case-map db-row) ; After SELECT
;; API conversions (with type transformations)
(user-specific-kebab->camel entity) ; Before API response
(user-specific-camel->kebab request) ; After API request
Why This Matters:
:password_hash vs :password-hash mismatch in service layer(ns boundary.user.shell.service
;; Clojure core and standard libraries first
(:require [clojure.string :as str]
[clojure.set :as set]
;; Third-party libraries alphabetically
[malli.core :as m]
[taoensso.timbre :as log]
;; Project namespaces alphabetically
[boundary.user.core.user :as user-core]
[boundary.user.ports :as ports]
[boundary.shared.core.time :as time])
;; Java imports separate
(:import [java.util UUID]
[java.time Instant]))
;; Good
(defn create-user
[user-data]
(let [validated (validate-user user-data)
prepared (prepare-user validated)]
(save-user prepared)))
;; Bad - closing parens on separate lines
(defn create-user
[user-data]
(let [validated (validate-user user-data)
prepared (prepare-user validated)
]
(save-user prepared)
)
)
(defn calculate-membership-tier
"Calculate user membership tier based on account age and activity.
Args:
user - User entity map with :created-at and :activity-score
current-date - java.time.Instant for calculation reference
Returns:
Keyword - :bronze, :silver, :gold, or :platinum
Pure: true"
[user current-date]
...)
;; Core functions: return data (no exceptions for business logic)
(defn validate-user-data
[user-data]
{:valid? false
:errors {:email ["Email is required"]}})
;; Shell functions: use ex-info with structured data
(when-not valid?
(throw (ex-info "Validation failed"
{:type :validation-error
:errors errors
:data user-data})))
CRITICAL: ALWAYS use kebab-case internally. ONLY convert at system boundaries.
The Problem: Mixing snake_case and kebab-case internally causes subtle bugs that are hard to track.
;; ❌ WRONG - Using snake_case internally (in service layer)
(defn authenticate [user password]
(if (and (:password_hash user) ; BUG! Should be :password-hash
(verify-password password (:password_hash user)))
{:authenticated true}
{:authenticated false}))
;; ✅ CORRECT - Using kebab-case internally
(defn authenticate [user password]
(if (and (:password-hash user) ; Correct! Internal kebab-case
(verify-password password (:password-hash user)))
{:authenticated true}
{:authenticated false}))
;; ✅ CORRECT - Transform at persistence boundary ONLY
(defn db->user-entity [db-record]
(-> db-record
(clojure.set/rename-keys {:created_at :created-at
:password_hash :password-hash
:updated_at :updated-at})))
Best Practice:
db->entity boundary in shell/persistence.cljRecent Bug Examples:
:password_hash (snake_case) but entity had :password-hash (kebab-case):created_at (snake_case) instead of :created-at (kebab-case)The Problem: Reloading namespaces with defrecord doesn't update existing instances.
;; Change UserService defrecord implementation
(defrecord UserService [repo]
IUserService
(create-user [this data]
;; NEW IMPLEMENTATION
...))
;; ❌ WRONG - System still has old implementation
(ig-repl/reset) ; Doesn't recreate defrecord instances
;; ✅ CORRECT - Full system restart
(ig-repl/halt)
(ig-repl/go)
;; OR clear cache and restart REPL
rm -rf .cpcache
clojure -M:repl-clj
The Problem: Manual editing of Clojure code often creates unbalanced parentheses.
;; ❌ WRONG - Manual editing
(defn process-user [user]
(let [validated (validate user)
processed (process validated)] ; Missing closing paren
processed)
;; ✅ CORRECT - Use clj-paren-repair
# Always use clj-paren-repair to fix unbalanced delimiters
Best Practice: Use clj-paren-repair for all Clojure file edits with delimiter errors.
# Fix parentheses in one or more files
clj-paren-repair src/boundary/user/core/user.clj
clj-paren-repair src/boundary/user/core/user.clj src/boundary/user/shell/service.clj
# The tool automatically formats files with cljfmt when it processes them
IMPORTANT: Do NOT try to manually repair parenthesis errors. If you encounter unbalanced delimiters, run clj-paren-repair on the file instead of attempting to fix them yourself. If the tool doesn't work, report to the user that they need to fix the delimiter error manually.
;; ❌ WRONG - Validation in core
(defn create-user-core [user-data]
(when-not (m/validate UserSchema user-data) ; Side effect!
(throw (ex-info ...))) ; Exceptions in pure code!
...)
;; ✅ CORRECT - Validation in shell
(defn create-user-service [this user-data]
(let [[valid? errors data] (validate-request UserSchema user-data)]
(if valid?
(user-core/create-user data) ; Core receives clean data
(throw (ex-info "Validation failed" {:errors errors})))))
;; ❌ WRONG - Core depends on concrete implementation
(ns boundary.user.core.user
(:require [boundary.user.shell.persistence :as db])) ; BAD!
(defn find-user [id]
(db/find-by-id id)) ; Core calling shell directly!
;; ✅ CORRECT - Core depends on ports (protocols)
(ns boundary.user.core.user)
(defn find-user-decision [user-id existing-user]
(if existing-user
{:action :use-existing :user existing-user}
{:action :not-found :user-id user-id}))
;; Shell orchestrates
(ns boundary.user.shell.service
(:require [boundary.user.ports :as ports]
[boundary.user.core.user :as core]))
(defn find-user [this user-id]
(let [existing (.find-by-id user-repository user-id)
decision (core/find-user-decision user-id existing)]
(case (:action decision)
:use-existing (:user decision)
:not-found nil)))
┌─────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ REST API (Ring) │ CLI (tools.cli) │ Web (HTMX) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ IMPERATIVE SHELL (shell/*) │
│ • Side effects (I/O, network, persistence) │
│ • Validation and coercion (Malli) │
│ • Error translation (domain → HTTP/CLI) │
│ • Logging, metrics, error reporting │
│ • Adapter implementations │
└─────────────────────────────────────────────────────────┘
│
▼ (depends on)
┌─────────────────────────────────────────────────────────┐
│ PORTS (ports.clj) │
│ • Protocol definitions (interfaces) │
│ • Abstract contracts │
│ • No implementations │
└─────────────────────────────────────────────────────────┘
▲ (implements)
│
┌─────────────────────────────────────────────────────────┐
│ FUNCTIONAL CORE (core/*) │
│ • Pure functions only (no side effects) │
│ • Business logic and rules │
│ • Domain calculations │
│ • Testable without mocks │
└─────────────────────────────────────────────────────────┘
| Direction | Allowed? | Rule |
|---|---|---|
| Shell → Core | ✅ | Shell calls core functions with validated data |
| Core → Ports | ✅ | Core depends on abstract interfaces only |
| Shell → Adapters | ✅ | Shell provides concrete implementations |
| Core → Shell | ❌ | NEVER - Core must not depend on shell |
| Core → Adapters | ❌ | NEVER - Core must not depend on concrete impls |
| Library | Purpose | Usage in Boundary |
|---|---|---|
| Clojure 1.12.1 | Core language | Foundation for all modules |
| Integrant | System lifecycle | Component management and dependency injection |
| Aero | Configuration | Environment-based config with profile overlays |
| Library | Purpose | Usage in Boundary |
|---|---|---|
| next.jdbc | Database connectivity | PostgreSQL connections and queries |
| HoneySQL | SQL generation | Type-safe SQL query building |
| HikariCP | Connection pooling | Database connection management |
| Malli | Schema validation | Request validation and data coercion |
| Library | Purpose | Usage in Boundary |
|---|---|---|
| Ring | HTTP abstraction | Web server foundation |
| Reitit | Routing | HTTP request routing |
| Cheshire | JSON processing | Request/response serialization |
| HTMX | Progressive enhancement | Dynamic web UI interactions |
| Hiccup | HTML generation | Server-side rendering |
| Library | Purpose | Usage in Boundary |
|---|---|---|
| TeleMere | Structured logging | Application logging and telemetry |
| tools.logging | Logging abstraction | Legacy logging support |
| Kaocha | Test runner | Test execution and reporting |
| clj-kondo | Static analysis | Code linting and quality checks |
src/boundary/{module}/
├── core/
│ ├── {domain1}.clj # Pure business logic
│ ├── {domain2}.clj # Pure calculations
│ └── ui.clj # Pure UI generation (Hiccup)
├── shell/
│ ├── service.clj # Business service orchestration
│ ├── persistence.clj # Database adapter
│ ├── http.clj # REST API routes
│ ├── cli.clj # CLI commands
│ └── web_handlers.clj # Web UI handlers
├── ports.clj # Protocol definitions
└── schema.clj # Malli schemas
test/boundary/{module}/
├── core/
│ ├── {domain1}_test.clj # Unit tests (no mocks)
│ └── ui_test.clj # Pure UI tests
└── shell/
├── service_test.clj # Integration tests (mocked deps)
└── persistence_test.clj # Contract tests (real db)
src/boundary/user/
├── core/
│ ├── user.clj # User business logic
│ ├── session.clj # Session logic
│ ├── audit.clj # Audit logic
│ ├── mfa.clj # MFA business logic (Phase 4.3)
│ └── ui.clj # UI components (Hiccup)
├── shell/
│ ├── service.clj # UserService (orchestration)
│ ├── persistence.clj # DatabaseUserRepository
│ ├── auth.clj # Authentication with MFA support
│ ├── mfa.clj # MFA service (TOTP, backup codes)
│ ├── http.clj # REST routes (includes MFA endpoints)
│ ├── cli.clj # CLI commands
│ └── web_handlers.clj # Web handlers
├── ports.clj # IUserService, IUserRepository
└── schema.clj # CreateUserRequest, UserEntity, MFA schemas
MFA Features (Phase 4.3 - COMPLETE):
Boundary uses Aero for sophisticated configuration management with module-centric organization and environment-based profiles.
resources/
└── conf/
└── dev/
└── config.edn # Development configuration
# Development environment variables
export BND_ENV=development
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=boundary_dev
export DB_USERNAME=boundary_dev
export DB_PASSWORD=dev_password
# Feature flags
export BND_FEATURE_BILLING=true
export BND_FEATURE_WORKFLOW=false
# External services
export SMTP_HOST=localhost
export SMTP_PORT=1025 # MailHog for development
;; Load configuration in REPL
user=> (require '[boundary.config :as config])
user=> (def cfg (config/load-config)) ; Loads from resources/conf/dev/config.edn
user=> (get-in cfg [:active :boundary/settings :name]) ; "boundary-dev"
user=> (get-in cfg [:boundary/sqlite :db]) ; "dev-database.db"
Example (resources/conf/dev/config.edn):
{:active
{:boundary/settings
{:name "boundary-dev"
:version "0.1.0"
:date-format "yyyy-MM-dd"
:date-time-format "yyyy-MM-dd HH:mm:ss"
:currency/iso-code "EUR"}}
:boundary/sqlite
{:db "dev-database.db"}
:inactive ; Available but not currently active
{:boundary/postgresql
{:host #env "POSTGRES_HOST"
:port #env "POSTGRES_PORT"
:dbname #env "POSTGRES_DB"
:user #env "POSTGRES_USER"
:password #env "POSTGRES_PASSWORD"
:auto-commit true
:max-pool-size 15}}}
# Set environment variables for PostgreSQL
export POSTGRES_HOST=localhost
export POSTGRES_PORT=5432
export POSTGRES_DB=boundary_dev
export POSTGRES_USER=boundary_dev
export POSTGRES_PASSWORD=dev_password
# Verify configuration in REPL
clojure -M:repl-clj
user=> (require '[boundary.config :as config])
user=> (def cfg (config/load-config))
user=> (keys (:active cfg)) ; See active configuration sections
Boundary implements enterprise-grade security features with a focus on authentication, authorization, and data protection.
Status: ✅ Production Ready (Phase 4.3 - Complete)
Boundary includes comprehensive MFA support using TOTP (Time-based One-Time Password) authentication:
Features:
Quick Example:
# 1. Setup MFA (returns QR code, secret, backup codes)
curl -X POST http://localhost:3000/api/auth/mfa/setup \
-H "Authorization: Bearer <token>"
# 2. Enable MFA with verification code
curl -X POST http://localhost:3000/api/auth/mfa/enable \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"secret": "JBSWY3DPEHPK3PXP", "backupCodes": [...], "verificationCode": "123456"}'
# 3. Login with MFA
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password", "mfa-code": "123456"}'
Implementation Details:
src/boundary/user/core/mfa.clj (350 lines, pure functions)src/boundary/user/shell/mfa.clj (270 lines, I/O operations)migrations/006_add_mfa_to_users.sql (5 new columns + indexes)API Endpoints:
POST /api/auth/mfa/setup - Initialize MFA setupPOST /api/auth/mfa/enable - Enable MFA after verificationPOST /api/auth/mfa/disable - Disable MFAGET /api/auth/mfa/status - Check MFA status and remaining backup codesPOST /api/auth/login - Login with optional MFA codeDocumentation:
Architecture (FC/IS Pattern):
Core (Pure):
- should-require-mfa? (business logic)
- can-enable-mfa? (validation)
- prepare-mfa-enablement (data transformation)
- is-valid-backup-code? (validation)
Shell (I/O):
- generate-totp-secret (SecureRandom)
- verify-totp-code (TOTP verification)
- generate-backup-codes (cryptographic generation)
- create-qr-code-url (external service)
Security Considerations:
Testing:
# Run MFA tests
clojure -M:test:db/h2 --focus boundary.user.core.mfa-test --focus boundary.user.shell.mfa-test
# Check MFA status in REPL
clojure -M:repl-clj
user=> (require '[boundary.user.shell.mfa :as mfa])
user=> (mfa/verify-totp-code "123456" "JBSWY3DPEHPK3PXP")
Boundary implements a defense-in-depth approach to secrets management, progressing from basic environment variables to enterprise-grade secret vaults.
Security Principles:
.gitignore for sensitive filesDevelopment Setup:
# Required secrets (application fails without these)
export JWT_SECRET="dev-secret-minimum-32-characters-long"
export DB_PASSWORD="dev_password"
# Optional secrets (have sensible defaults)
export SMTP_PASSWORD="optional_mail_password"
export SENTRY_DSN="optional_sentry_dsn"
Reading Secrets in Code:
(ns boundary.user.shell.auth
(:require [clojure.tools.logging :as log]))
;; SECURE: Fail fast if secret not configured
(def ^:private jwt-secret
"JWT signing secret loaded from environment variable.
SECURITY: Must be set via JWT_SECRET environment variable.
Throws exception if not configured to prevent accidental use of default secret."
(or (System/getenv "JWT_SECRET")
(throw (ex-info "JWT_SECRET environment variable not configured. Set JWT_SECRET before starting the application."
{:type :configuration-error
:required-env-var "JWT_SECRET"}))))
;; Log that secret was loaded (never log the value)
(log/info "JWT secret loaded from environment variable")
Aero Integration:
;; In resources/conf/dev/config.edn
{:active
{:boundary/database
{:password #env "DB_PASSWORD"} ; Required environment variable
:boundary/smtp
{:password #or [#env "SMTP_PASSWORD" "default-dev-password"]} ; Optional with default
:boundary/auth
{:jwt-secret #env "JWT_SECRET"}}} ; Required, no default
Installation:
;; Add to deps.edn
{:deps {com.amazonaws/aws-java-sdk-secretsmanager {:mvn/version "1.12.500"}}}
Implementation:
(ns boundary.platform.shell.adapters.secrets.aws-secrets-manager
"AWS Secrets Manager adapter for retrieving secrets.
Supports:
- Secret retrieval by name
- Automatic credential handling (IAM roles)
- Caching with TTL
- Secret rotation without downtime"
(:require [clojure.tools.logging :as log]
[clojure.data.json :as json])
(:import [com.amazonaws.services.secretsmanager AWSSecretsManagerClientBuilder]
[com.amazonaws.services.secretsmanager.model GetSecretValueRequest]))
(defn create-client
"Create AWS Secrets Manager client.
Uses default credential chain:
1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
2. EC2 instance profile
3. ECS task role"
[]
(-> (AWSSecretsManagerClientBuilder/standard)
(.withRegion (or (System/getenv "AWS_REGION") "us-east-1"))
(.build)))
(defn get-secret
"Retrieve secret value from AWS Secrets Manager.
Args:
client - AWS Secrets Manager client
secret-name - Name of secret in AWS Secrets Manager
Returns:
Secret value as string (for JSON secrets, parse with json/read-str)
Example:
(get-secret client \"prod/boundary/jwt-secret\")"
[client secret-name]
(try
(log/info "Retrieving secret from AWS Secrets Manager" {:secret-name secret-name})
(let [request (-> (GetSecretValueRequest.)
(.withSecretId secret-name))
result (.getSecretValue client request)]
(.getSecretString result))
(catch Exception e
(log/error e "Failed to retrieve secret from AWS Secrets Manager"
{:secret-name secret-name})
(throw (ex-info "Secret retrieval failed"
{:type :secret-retrieval-error
:secret-name secret-name}
e)))))
(defn load-secrets-from-aws
"Load all application secrets from AWS Secrets Manager.
Returns:
Map of secret keys to values
Example:
{:jwt-secret \"abc123...\"
:db-password \"xyz789...\"}"
[]
(let [client (create-client)
secrets-json (get-secret client "prod/boundary/secrets")]
(json/read-str secrets-json :key-fn keyword)))
Usage in Application:
(ns boundary.config
(:require [boundary.platform.shell.adapters.secrets.aws-secrets-manager :as aws-secrets]))
(defn load-production-secrets
"Load secrets based on environment."
[]
(case (System/getenv "BND_ENV")
"production" (aws-secrets/load-secrets-from-aws)
"staging" (aws-secrets/load-secrets-from-aws)
{})) ; Development uses environment variables
(defn ig-config
[config]
(let [secrets (load-production-secrets)]
(merge config secrets)))
AWS Secrets Manager Setup:
# Create secret in AWS
aws secretsmanager create-secret \
--name prod/boundary/secrets \
--secret-string '{
"jwt-secret": "production-jwt-secret-32-chars",
"db-password": "production-db-password",
"smtp-password": "production-smtp-password"
}'
# Grant EC2/ECS permission to read secret
aws secretsmanager put-resource-policy \
--secret-id prod/boundary/secrets \
--resource-policy '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::ACCOUNT_ID:role/boundary-app-role"},
"Action": "secretsmanager:GetSecretValue",
"Resource": "*"
}]
}'
Installation:
;; Add to deps.edn
{:deps {vault-clj/vault-clj {:mvn/version "1.1.0"}}}
Implementation:
(ns boundary.platform.shell.adapters.secrets.vault
"HashiCorp Vault adapter for secret retrieval."
(:require [clojure.tools.logging :as log]
[vault.client.http]
[vault.core :as vault]))
(defn create-client
"Create Vault client with authentication.
Supports:
- Token authentication (VAULT_TOKEN env var)
- AppRole authentication (VAULT_ROLE_ID, VAULT_SECRET_ID)
- Kubernetes authentication (for K8s deployments)"
[]
(let [vault-addr (or (System/getenv "VAULT_ADDR")
"http://localhost:8200")
vault-token (System/getenv "VAULT_TOKEN")]
(when-not vault-token
(throw (ex-info "VAULT_TOKEN environment variable not set"
{:type :configuration-error})))
(vault/new-client vault-addr vault-token)))
(defn get-secret
"Retrieve secret from Vault.
Args:
client - Vault client
path - Secret path (e.g., 'secret/data/boundary/prod')
Returns:
Map of secret key-value pairs"
[client path]
(try
(log/info "Retrieving secret from Vault" {:path path})
(let [response (vault/read-secret client path)]
(:data response))
(catch Exception e
(log/error e "Failed to retrieve secret from Vault" {:path path})
(throw (ex-info "Vault secret retrieval failed"
{:type :secret-retrieval-error
:path path}
e)))))
(defn load-secrets-from-vault
"Load application secrets from Vault.
Returns:
Map of secret keys to values"
[]
(let [client (create-client)
env (or (System/getenv "BND_ENV") "dev")]
(get-secret client (str "secret/data/boundary/" env))))
Vault Setup:
# Enable KV v2 secrets engine
vault secrets enable -version=2 kv
# Write secrets to Vault
vault kv put secret/boundary/prod \
jwt-secret="production-jwt-secret-32-chars" \
db-password="production-db-password" \
smtp-password="production-smtp-password"
# Create policy for boundary application
vault policy write boundary-app - <<EOF
path "secret/data/boundary/*" {
capabilities = ["read"]
}
EOF
# Create token for application
vault token create -policy=boundary-app -ttl=720h
Validate Required Secrets:
(ns boundary.platform.shell.validation
"Startup configuration validation."
(:require [clojure.tools.logging :as log]))
(def required-secrets
"List of required secrets that must be present."
[:jwt-secret
:db-password])
(defn validate-secrets
"Validate that all required secrets are present.
Args:
config - Configuration map
Returns:
config if valid
Throws:
ex-info if secrets are missing"
[config]
(let [missing (remove #(get config %) required-secrets)]
(if (seq missing)
(do
(log/error "Missing required secrets" {:missing-secrets missing})
(throw (ex-info "Configuration validation failed: missing required secrets"
{:type :configuration-error
:missing-secrets missing})))
(do
(log/info "Configuration validation passed"
{:validated-secrets (count required-secrets)})
config))))
(defn validate-secret-format
"Validate secret values meet security requirements.
Example: JWT secret must be at least 32 characters"
[config]
(when-let [jwt-secret (:jwt-secret config)]
(when (< (count jwt-secret) 32)
(throw (ex-info "JWT_SECRET must be at least 32 characters"
{:type :configuration-error
:secret :jwt-secret
:length (count jwt-secret)}))))
config)
(defn validate-config
"Run all configuration validations.
Call this at application startup before starting system."
[config]
(-> config
validate-secrets
validate-secret-format))
Usage:
(ns boundary.core
(:require [boundary.config :as config]
[boundary.platform.shell.validation :as validation]
[integrant.core :as ig]))
(defn -main
[& args]
(let [config (config/load-config)
validated-config (validation/validate-config config)
system (ig/init (config/ig-config validated-config))]
;; System started with validated configuration
system))
Strategy:
Implementation:
(ns boundary.platform.shell.secrets.rotation
"Support for zero-downtime secret rotation."
(:require [clojure.tools.logging :as log]))
(defn get-jwt-secret-with-fallback
"Get JWT secret with rotation support.
During rotation period:
1. Try JWT_SECRET_NEW first
2. Fall back to JWT_SECRET
This allows gradual migration:
1. Deploy with both secrets
2. Update vault to JWT_SECRET_NEW
3. Wait for tokens issued with old secret to expire
4. Remove JWT_SECRET, rename JWT_SECRET_NEW to JWT_SECRET"
[]
(or (System/getenv "JWT_SECRET_NEW")
(System/getenv "JWT_SECRET")
(throw (ex-info "No JWT secret configured"
{:type :configuration-error}))))
(defn verify-jwt-token-with-rotation
"Verify JWT token, trying both current and previous secrets.
This allows tokens signed with old secret to remain valid
during rotation window."
[token]
(let [current-secret (System/getenv "JWT_SECRET")
previous-secret (System/getenv "JWT_SECRET_PREVIOUS")]
(or (try-verify-token token current-secret)
(when previous-secret
(log/info "Token verification with current secret failed, trying previous secret")
(try-verify-token token previous-secret))
(throw (ex-info "JWT verification failed with all secrets"
{:type :authentication-error})))))
Rotation Procedure:
# Step 1: Generate new secret
NEW_JWT_SECRET=$(openssl rand -base64 32)
# Step 2: Deploy application with both secrets
export JWT_SECRET="old-secret"
export JWT_SECRET_NEW="$NEW_JWT_SECRET"
# Deploy application (supports both secrets)
# Step 3: Update Vault/Secrets Manager with new secret
aws secretsmanager update-secret \
--secret-id prod/boundary/jwt-secret \
--secret-string "$NEW_JWT_SECRET"
# Step 4: Wait for old tokens to expire (e.g., 24 hours)
sleep 86400
# Step 5: Deploy with only new secret
export JWT_SECRET="$NEW_JWT_SECRET"
unset JWT_SECRET_NEW
# Deploy application (old secret removed)
# .env.example (commit to repository)
JWT_SECRET=dev-secret-minimum-32-characters-long
DB_PASSWORD=dev_password
SMTP_PASSWORD=optional_mail_password
# .env (DO NOT commit - add to .gitignore)
JWT_SECRET=actual-dev-secret-32-chars-min
DB_PASSWORD=actual_dev_password
AWS EC2/ECS:
# Use IAM roles, no credentials in environment
# Secrets retrieved from AWS Secrets Manager
# Application logs secret retrieval (not values)
Kubernetes:
# Use Kubernetes secrets mounted as files
apiVersion: v1
kind: Pod
metadata:
name: boundary-app
spec:
containers:
- name: app
image: boundary:latest
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: boundary-secrets
key: jwt-secret
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: boundary-secrets
key: db-password
.gitignore)[REDACTED] when logging)(defn get-secret-with-audit
"Retrieve secret with audit logging.
IMPORTANT: Never log secret values, only metadata."
[secret-name]
(log/info "Secret accessed"
{:secret-name secret-name
:user (System/getProperty "user.name")
:timestamp (java.time.Instant/now)})
(get-secret-value secret-name))
Current State → Production Ready:
Phase 1: Environment Variables ✅ (Current)
.env filesSystem/getenvPhase 2: Secret Vault Integration (Next)
Phase 3: Automated Rotation (Future)
# Start REPL
clojure -M:repl-clj
# In REPL
user=> (require '[integrant.repl :as ig-repl])
user=> (ig-repl/go) ; Start system
# Verify system is running
user=> (require '[boundary.user.ports :as ports])
user=> (ports/list-users user-service {}) ; Test service
# Make changes in editor, then:
user=> (ig-repl/reset) ; Reload and restart
Step-by-Step Process:
Define Schema (in {module}/schema.clj)
(def UpdateUserRequest
[:map
[:id :uuid]
[:name [:string {:min 1}]]
[:email :email]])
Write Core Logic (in {module}/core/{domain}.clj)
(defn prepare-user-update
"Pure function to prepare user update.
Args:
existing-user - Current user entity
update-data - New data to apply
Returns:
Updated user entity map
Pure: true"
[existing-user update-data]
(merge existing-user
(select-keys update-data [:name :email])
{:updated-at (java.time.Instant/now)}))
Write Unit Tests (in test/{module}/core/{domain}_test.clj)
(deftest prepare-user-update-test
(testing "updates user fields"
(let [existing {:id #uuid "..." :name "Old" :email "old@example.com"}
updates {:name "New" :email "new@example.com"}
result (core/prepare-user-update existing updates)]
(is (= "New" (:name result)))
(is (= "new@example.com" (:email result))))))
Define Port (in {module}/ports.clj)
(defprotocol IUserService
(update-user [this user-id update-data]))
Implement in Service (in {module}/shell/service.clj)
(defrecord UserService [user-repository]
IUserService
(update-user [this user-id update-data]
(let [existing (.find-by-id user-repository user-id)]
(when-not existing
(throw (ex-info "User not found" {:user-id user-id})))
(let [updated (user-core/prepare-user-update existing update-data)]
(.update-user user-repository updated)))))
Add HTTP Endpoint (in {module}/shell/http.clj)
["/api/users/:id" {:put {:handler (handlers/update-user user-service config)}}]
# Run tests in watch mode while developing
clojure -M:test:db/h2 --watch --focus-meta :unit
# In another terminal, run full test suite periodically
clojure -M:test:db/h2
# Before committing, run lint
clojure -M:clj-kondo --lint src test
;; Check system state
user=> (keys integrant.repl.state/system)
;; Get service instance
user=> (def user-service (::user/service integrant.repl.state/system))
;; Test service directly
user=> (user-ports/list-users user-service {:limit 10})
;; Check database connection
user=> (def db-ctx (::db/context integrant.repl.state/system))
user=> (db/execute-one! db-ctx {:select [[1 :result]]})
;; Reload specific namespace
user=> (require '[boundary.user.core.user :as user-core] :reload)
;; Full system reset
user=> (ig-repl/reset)
Boundary includes a comprehensive scaffolder that generates complete, production-ready modules following FC/IS architecture patterns.
# Generate a complete module
clojure -M -m boundary.scaffolder.shell.cli-entry generate \
--module-name product \
--entity Product \
--field name:string:required \
--field sku:string:required:unique \
--field price:decimal:required \
--field description:text
# Dry-run to preview without creating files
clojure -M -m boundary.scaffolder.shell.cli-entry generate \
--module-name product \
--entity Product \
--field name:string:required \
--dry-run
Field specs follow the pattern: name:type[:required][:unique]
Supported Types:
string - Variable length texttext - Long-form textint / integer - Integer numbersdecimal - Decimal numbersboolean - True/false valuesemail - Email addresses (validated)uuid - UUID identifiersenum - Enumeration valuesdate / datetime / inst - Timestampsjson - JSON/map dataExamples:
--field email:email:required:unique # Required unique email
--field name:string:required # Required string
--field age:int # Optional integer
--field status:enum # Optional enum
--field created-date:date:required # Required timestamp
The scaffolder generates 12 files per module:
Source Files (9):
src/boundary/{module}/schema.clj - Malli schemassrc/boundary/{module}/ports.clj - Protocol definitionssrc/boundary/{module}/core/{entity}.clj - Pure business logicsrc/boundary/{module}/core/ui.clj - Hiccup UI componentssrc/boundary/{module}/shell/service.clj - Service orchestrationsrc/boundary/{module}/shell/persistence.clj - Database operationssrc/boundary/{module}/shell/http.clj - HTTP routessrc/boundary/{module}/shell/web_handlers.clj - Web UI handlersmigrations/NNN_create_{entities}.sql - Database migrationTest Files (3):
10. test/boundary/{module}/core/{entity}_test.clj - Unit tests
11. test/boundary/{module}/shell/{entity}_repository_test.clj - Persistence tests
12. test/boundary/{module}/shell/service_test.clj - Service integration tests
After generating a module, integrate it into the system:
1. Create Module Wiring
Create src/boundary/{module}/shell/module_wiring.clj:
(ns boundary.{module}.shell.module-wiring
"Integrant wiring for the {module} module."
(:require [boundary.{module}.shell.persistence :as persistence]
[boundary.{module}.shell.service :as service]
[clojure.tools.logging :as log]
[integrant.core :as ig]))
(defmethod ig/init-key :boundary/{module}-repository
[_ {:keys [ctx]}]
(log/info "Initializing {module} repository")
(persistence/create-repository ctx))
(defmethod ig/halt-key! :boundary/{module}-repository
[_ _repo]
(log/info "{module} repository halted"))
(defmethod ig/init-key :boundary/{module}-service
[_ {:keys [repository]}]
(log/info "Initializing {module} service")
(service/create-service repository))
(defmethod ig/halt-key! :boundary/{module}-service
[_ _service]
(log/info "{module} service halted"))
(defmethod ig/init-key :boundary/{module}-routes
[_ {:keys [service config]}]
(log/info "Initializing {module} routes")
(require 'boundary.{module}.shell.http)
(let [routes-fn (ns-resolve 'boundary.{module}.shell.http 'routes)]
(routes-fn service config)))
(defmethod ig/halt-key! :boundary/{module}-routes
[_ _routes]
(log/info "{module} routes halted"))
2. Add Module Configuration
In src/boundary/config.clj, add the module config function:
(defn- {module}-module-config
"Return Integrant configuration for the {module} module."
[config]
{:boundary/{module}-repository
{:ctx (ig/ref :boundary/db-context)}
:boundary/{module}-service
{:repository (ig/ref :boundary/{module}-repository)}
:boundary/{module}-routes
{:service (ig/ref :boundary/{module}-service)
:config config}})
Then merge it into ig-config:
(defn ig-config
[config]
(merge (core-system-config config)
(user-module-config config)
({module}-module-config config))) ; Add this line
3. Wire Module into System
In src/boundary/shell/system/wiring.clj, add the module wiring to requires:
(:require ...
[boundary.{module}.shell.module-wiring] ; Add this
...)
4. Update HTTP Handler
In src/boundary/config.clj, add the module routes to the HTTP handler:
:boundary/http-handler
{:config config
:user-routes (ig/ref :boundary/user-routes)
:{module}-routes (ig/ref :boundary/{module}-routes)} ; Add this
In src/boundary/shell/system/wiring.clj, update the HTTP handler to accept and compose the new routes:
(defmethod ig/init-key :boundary/http-handler
[_ {:keys [config user-routes {module}-routes]}] ; Add {module}-routes
...
;; Extract and combine routes
(let [user-api-routes (or (:api user-routes) [])
{module}-api-routes (or (:api {module}-routes) []) ; Add this
...
all-routes (concat ...
user-api-routes
{module}-api-routes) ; Add this
...))
5. Run Database Migration
# Apply the generated migration
psql -U boundary_dev -d boundary_dev -f migrations/NNN_create_{entities}.sql
6. Verify Integration
# Lint the module
clojure -M:clj-kondo --lint src/boundary/{module}/ test/boundary/{module}/
# Run module tests
clojure -M:test:db/h2 --focus-meta :{module}
# Start the system and verify routes
clojure -M:repl-clj
user=> (require '[integrant.repl :as ig-repl])
user=> (ig-repl/go)
# Generate inventory module
clojure -M -m boundary.scaffolder.shell.cli-entry generate \
--module-name inventory \
--entity Item \
--field name:string:required \
--field sku:string:required:unique \
--field quantity:int:required \
--field location:string:required
Result:
The scaffolder generates minimal but correct implementations. Enhance them by:
core/{entity}.cljcore/ui.cljshell/service.cljshell/persistence.cljshell/http.cljThe scaffolder generates only normalized routes in shell/http.clj:
Normalized Format (framework-agnostic):
(defn normalized-api-routes [service]
[{:path "/items"
:methods {:get {:handler ...}}}]) ; Framework-agnostic format
(defn {module}-routes-normalized [service config]
{:api (normalized-api-routes service)
:web (normalized-web-routes service config)
:static []})
Benefits of Normalized Routes:
Note: Legacy Reitit-specific route functions (api-routes, web-ui-routes, user-routes, create-handler, etc.) have been removed from the codebase. Use only the normalized format going forward.
| Category | Location | Purpose | Characteristics |
|---|---|---|---|
| Unit | test/{module}/core/* | Pure core functions | No mocks, fast, deterministic |
| Integration | test/{module}/shell/* | Service orchestration | Mocked dependencies |
| Contract | test/{module}/shell/* | Adapter implementations | Real database (H2) |
# All tests (H2 database required)
clojure -M:test:db/h2
# By category
clojure -M:test:db/h2 --focus-meta :unit
clojure -M:test:db/h2 --focus-meta :integration
clojure -M:test:db/h2 --focus-meta :contract
# By module
clojure -M:test:db/h2 --focus-meta :user
clojure -M:test:db/h2 --focus-meta :billing
# Watch mode (auto-run on file changes)
clojure -M:test:db/h2 --watch --focus-meta :unit
# Single namespace
clojure -M:test:db/h2 -n boundary.user.core.user-test
# Fail fast (stop on first failure)
clojure -M:test:db/h2 --fail-fast
Unit Test (Pure Core):
(ns boundary.user.core.user-test
(:require [clojure.test :refer [deftest testing is]]
[boundary.user.core.user :as user-core]))
(deftest calculate-membership-tier-test
(testing "platinum tier for 5+ years"
(let [user {:created-at #inst "2018-01-01"}
current-date #inst "2024-01-01"
result (user-core/calculate-membership-tier user current-date)]
(is (= :platinum result)))))
Integration Test (Service with Mocks):
(ns boundary.user.shell.service-test
(:require [clojure.test :refer [deftest testing is]]
[boundary.user.shell.service :as service]
[boundary.user.ports :as ports]))
(deftest create-user-test
(testing "creates user successfully"
(let [mock-repo (reify ports/IUserRepository
(create-user [_ user] user))
svc (service/->UserService mock-repo nil)
result (ports/create-user svc valid-user-data)]
(is (some? result))
(is (= "test@example.com" (:email result))))))
Contract Test (Real Database):
(ns boundary.user.shell.persistence-test
{:kaocha.testable/meta {:contract true :user true}}
(:require [clojure.test :refer [deftest testing is use-fixtures]]
[boundary.user.shell.persistence :as persistence]))
(use-fixtures :each test-database-fixture)
(deftest find-user-by-email-test
(testing "finds existing user"
(let [repo (persistence/->DatabaseUserRepository test-db-ctx)
created (create-test-user! test-db-ctx)
found (ports/find-user-by-email repo (:email created))]
(is (some? found))
(is (= (:id created) (:id found))))))
# Start local PostgreSQL (via Docker)
docker-compose -f docker/dev-compose.yml up -d postgres
# Connect to database
psql -h localhost -U boundary_dev -d boundary_dev
# Stop PostgreSQL
docker-compose -f docker/dev-compose.yml down
# Run migrations (if migration alias exists)
clojure -M:migrate up
# Reset test database (if alias exists)
clojure -M:test:db:reset
;; Test database connection
user=> (require '[next.jdbc :as jdbc])
user=> (def ds (jdbc/get-datasource {:dbtype "h2:mem" :dbname "test"}))
user=> (jdbc/execute! ds ["SELECT 1"])
;; Check persistence layer
user=> (def repo (get-in integrant.repl.state/system [::user/repository]))
user=> (user-ports/list-users repo {:limit 1})
dev-database.db) - no additional setup requiredBoundary provides enterprise-grade pagination for REST APIs with two strategies: offset-based (simple, page-based) and cursor-based (high performance).
Offset Pagination (default):
# First page (default: 20 items)
curl "http://localhost:3000/api/users?limit=20&offset=0"
# Second page
curl "http://localhost:3000/api/users?limit=20&offset=20"
Response Format:
{
"users": [...],
"pagination": {
"type": "offset",
"total": 1000,
"offset": 0,
"limit": 20,
"hasNext": true,
"hasPrev": false,
"page": 1,
"pages": 50
}
}
RFC 5988 Link Headers:
Link: </api/users?limit=20&offset=0>; rel="first",
</api/users?limit=20&offset=20>; rel="next",
</api/users?limit=20&offset=980>; rel="last"
| Strategy | Best For | Performance | Use When |
|---|---|---|---|
| Offset | Small datasets | Degrades at high offsets | < 100K items, page jumping needed |
| Cursor | Large datasets | Consistent | > 100K items, infinite scroll |
Cursor Pagination Example:
# First page
curl "http://localhost:3000/api/users?limit=20"
# Use nextCursor from response
curl "http://localhost:3000/api/users?limit=20&cursor=eyJpZCI6MTIzfQ=="
Core Pagination Functions:
boundary.platform.core.pagination.pagination/calculate-offset-pagination - Pure pagination logicboundary.platform.core.pagination.pagination/calculate-cursor-pagination - Cursor calculationsboundary.platform.shell.pagination.link-headers/build-link-header - RFC 5988 Link headersboundary.platform.shell.pagination.cursor/encode-cursor - Base64 cursor encodingAdd Pagination to Repository:
(ns boundary.mymodule.shell.persistence
(:require [boundary.platform.core.pagination.pagination :as pagination]))
(defn find-items
[repository {:keys [limit offset] :or {limit 20 offset 0}}]
(let [count-query ["SELECT COUNT(*) AS total FROM items"]
total (:total (jdbc/execute-one! db-ctx count-query))
data-query ["SELECT * FROM items LIMIT ? OFFSET ?" limit offset]
items (jdbc/execute! db-ctx data-query)
pagination-meta (pagination/calculate-offset-pagination total offset limit)]
{:items (mapv db->item-entity items)
:pagination pagination-meta}))
Add Link Headers to HTTP Handler:
(ns boundary.mymodule.shell.http
(:require [boundary.platform.shell.pagination.link-headers :as link-headers]))
(defn list-items-handler [service]
(fn [request]
(let [limit (parse-int (get-in request [:query-params "limit"]) 20)
offset (parse-int (get-in request [:query-params "offset"]) 0)
{:keys [items pagination]} (service/find-items service {:limit limit :offset offset})
link-header (link-headers/build-link-header
(get request :uri)
pagination
(get request :query-params))]
{:status 200
:headers {"Link" link-header}
:body {:items items :pagination pagination}})))
;; resources/conf/dev/config.edn
{:boundary/pagination
{:default-limit 20
:max-limit 100
:default-type :offset
:enable-link-headers true}}
;; Test pagination in repository (integration test)
(deftest pagination-test
(testing "returns paginated results"
(let [repo (create-test-repository)
_ (create-test-items! 25) ; Create test data
{:keys [items pagination]} (repo/find-items repo {:limit 20 :offset 0})]
(is (= 20 (count items)))
(is (= 25 (:total pagination)))
(is (:hasNext pagination))
(is (not (:hasPrev pagination))))))
Boundary includes built-in observability infrastructure with logging, metrics, and error reporting capabilities following the Functional Core/Imperative Shell pattern.
Major Architecture Milestone: Boundary implements a multi-layer interceptor pattern that eliminates observability boilerplate while preserving business logic integrity:
Achievements:
Key Technical Implementations:
execute-service-operation interceptor for business servicesexecute-persistence-operation interceptor for data accessFeature modules can easily integrate observability by accepting the protocols as dependencies:
(ns my-feature.service
(:require [boundary.logging.ports :as logging]
[boundary.metrics.ports :as metrics]
[boundary.error-reporting.ports :as error-reporting]))
(defrecord MyFeatureService [logger metric-collector error-reporter]
IMyFeatureService
(process-request [this request]
(logging/info logger "Processing request" {:request-id (:id request)})
(metrics/increment metric-collector "requests.processed" {:feature "my-feature"})
(try
;; Business logic here
(let [result (do-processing request)]
(logging/info logger "Request processed successfully")
result)
(catch Exception e
(error-reporting/capture-exception error-reporter e
{:context "process-request"
:request-id (:id request)})
(throw (ex-info "Processing failed" {:request-id (:id request)} e))))))
Configure observability providers in your config.edn:
{:logging {:provider :no-op ; or :datadog
:level :info}
:metrics {:provider :no-op ; or :datadog
:namespace "boundary"}
:error-reporting {:provider :no-op ; or :sentry
:dsn "your-sentry-dsn"}}
See https://github.com/thijs-creemers/boundary-docs/tree/main/content/guides/integrate-observability.adoc for complete integration guide including custom adapters and advanced configuration.
HTTP interceptors provide bidirectional enter/leave/error semantics for Ring handlers, enabling declarative cross-cutting concerns like authentication, rate limiting, and audit logging.
Key Benefits:
{:name :my-interceptor ; Required: Keyword identifier
:enter (fn [context] ...) ; Optional: Process request
:leave (fn [context] ...) ; Optional: Process response
:error (fn [context] ...)} ; Optional: Handle exceptions
Phases:
:enter - Process request, can short-circuit with response:leave - Process response (runs in reverse order):error - Handle exceptions, produce safe responseInterceptors operate on a context map:
{:request Ring request map
:response Ring response (built during pipeline)
:route Route metadata from Reitit
:path-params Extracted parameters
:system {:logger :metrics-emitter :error-reporter}
:attrs Additional attributes
:correlation-id Unique request ID
:started-at Request timestamp}
Normalized Route Format:
[{:path "/api/admin"
:methods {:post {:handler 'my.handlers/create-resource
:interceptors ['my.auth/require-admin
'my.audit/log-action
'my.rate-limit/admin-limit]
:summary "Create admin resource"}}}]
Requirements:
:system with observability services(ns my.auth.interceptors)
(def require-admin
"Require admin role."
{:name :require-admin
:enter (fn [ctx]
(let [user (get-in ctx [:request :session :user])]
(if (= "admin" (:role user))
ctx
(assoc ctx :response
{:status 403
:body {:error "Forbidden"}}))))})
(def require-authenticated
"Require any authenticated user."
{:name :require-authenticated
:enter (fn [ctx]
(if (get-in ctx [:request :session :user])
ctx
(assoc ctx :response
{:status 401
:body {:error "Unauthorized"}})))})
(ns my.audit.interceptors)
(def log-action
"Log successful actions in leave phase."
{:name :log-action
:leave (fn [ctx]
(let [logger (get-in ctx [:system :logger])
status (get-in ctx [:response :status])]
;; Only log successful actions (2xx)
(when (and logger (< 199 status 300))
(.info logger "Action completed"
{:user-id (get-in ctx [:request :session :user :id])
:action (get-in ctx [:request :uri])
:status status}))
ctx))})
(ns my.rate-limit.interceptors
(:require [my.rate-limit.core :as rate-limit]))
(defn rate-limiter
"Rate limiting interceptor factory."
[limit-per-minute]
{:name :rate-limiter
:enter (fn [ctx]
(let [client-id (or (get-in ctx [:request :session :user :id])
(get-in ctx [:request :remote-addr]))
allowed? (rate-limit/check-limit client-id limit-per-minute)]
(if allowed?
ctx
(assoc ctx :response
{:status 429
:headers {"Retry-After" "60"}
:body {:error "Rate limit exceeded"}}))))})
(def admin-limit (rate-limiter 100))
(def public-limit (rate-limiter 30))
(ns my.validation.interceptors
(:require [malli.core :as m]))
(defn validate-body
"Validate request body against schema."
[schema]
{:name :validate-body
:enter (fn [ctx]
(let [body (get-in ctx [:request :body-params])
valid? (m/validate schema body)]
(if valid?
ctx
(assoc ctx :response
{:status 400
:body {:error "Validation failed"
:details (m/explain schema body)}}))))})
(ns my.metrics.interceptors)
(def track-timing
"Track request timing."
{:name :track-timing
:enter (fn [ctx]
(assoc-in ctx [:attrs :start-time] (System/nanoTime)))
:leave (fn [ctx]
(let [metrics (get-in ctx [:system :metrics-emitter])
start (get-in ctx [:attrs :start-time])
duration-ms (/ (- (System/nanoTime) start) 1000000.0)]
(when metrics
(.emit metrics "http.request.duration"
{:value duration-ms
:tags {:route (get-in ctx [:route :path])
:status (get-in ctx [:response :status])}}))
ctx))})
Unit Test:
(deftest require-admin-test
(testing "allows admin users"
(let [ctx {:request {:session {:user {:role "admin"}}}}
result ((:enter require-admin) ctx)]
(is (nil? (:response result)))))
(testing "rejects non-admin users"
(let [ctx {:request {:session {:user {:role "user"}}}}
result ((:enter require-admin) ctx)]
(is (= 403 (get-in result [:response :status]))))))
Integration Test:
(deftest interceptor-pipeline-test
(let [router (create-reitit-router)
system (create-mock-system)
routes [{:path "/test"
:methods {:get {:handler test-handler
:interceptors [auth audit]}}}]
config {:system system}
handler (ports/compile-routes router routes config)]
(testing "auth rejects unauthenticated"
(let [resp (handler {:request-method :get :uri "/test"})]
(is (= 401 (:status resp)))))
(testing "auth allows authenticated"
(let [resp (handler {:request-method :get
:uri "/test"
:session {:user {:role "admin"}}})]
(is (= 200 (:status resp)))))))
Combine Related Interceptors:
(defn admin-endpoint-stack
"Standard interceptor stack for admin endpoints."
[]
[require-authenticated
require-admin
log-action
track-timing])
;; Use in routes
[{:path "/api/admin/users"
:methods {:post {:handler 'my.handlers/create-user
:interceptors (admin-endpoint-stack)}}}]
Higher-Order Interceptors:
(defn with-role
"Create role-checking interceptor."
[required-role]
{:name (keyword (str "require-" required-role))
:enter (fn [ctx]
(let [user-role (get-in ctx [:request :session :user :role])]
(if (= required-role user-role)
ctx
(assoc ctx :response
{:status 403
:body {:error "Insufficient permissions"}}))))})
;; Use dynamically
[{:path "/api/manager"
:methods {:post {:handler 'my.handlers/manager-action
:interceptors [(with-role "manager")]}}}]
The framework provides default interceptors:
(require '[boundary.platform.shell.http.interceptors :as http-int])
;; Available defaults:
http-int/http-request-logging ; Log requests/responses
http-int/http-request-metrics ; Emit timing metrics
http-int/http-error-reporting ; Report exceptions
http-int/http-correlation-header ; Add X-Correlation-ID
http-int/http-error-handler ; Safe error responses
Request Flow:
enter: global-1 → global-2 → route-1 → route-2 → handler
leave: route-2 → route-1 → global-2 → global-1 → response
Tips:
Typical Overhead: ~0.25ms per request (3-5 interceptor stack)
Optimization:
Key Principles:
src/boundary/user/
├── core/
│ └── ui.clj # Pure Hiccup generation
└── shell/
├── web_handlers.clj # Web request handlers
└── http.clj # Route composition
resources/public/
├── css/
│ ├── pico.min.css # Base framework
│ └── app.css # Custom styles
└── js/
└── htmx.min.js # HTMX library
1. Form with In-Place Update:
(defn create-user-form [data errors]
[:div#create-user-form {:hx-target "#create-user-form"}
[:form {:hx-post "/web/users"}
[:label "Name"]
[:input {:name "name" :value (:name data)}]
(when-let [errs (:name errors)]
[:span.error (first errs)])
[:button {:type "submit"} "Create"]]])
2. Table with Event-Based Refresh:
(defn users-table [users]
[:div#users-table-container
{:hx-get "/web/users/table"
:hx-trigger "userCreated from:body, userUpdated from:body"
:hx-target "#users-table-container"}
[:table
[:thead ...]
[:tbody
(for [user users]
(user-row user))]]])
3. Handler with Custom Event:
(defn create-user-handler [user-service config]
(fn [request]
(let [[valid? errors data] (validate-request schema request)]
(if valid?
{:status 201
:headers {"HX-Trigger" "userCreated"} ; Triggers table refresh
:body (render-success-message)}
{:status 400
:body (render-form-with-errors data errors)}))))
;; In shell/http.clj
["/web"
["/users"
{:get {:handler (web-handlers/users-page user-service config)}}
["/new"
{:get {:handler (web-handlers/new-user-form user-service config)}}]
["/:id"
{:get {:handler (web-handlers/user-detail user-service config)}
:put {:handler (web-handlers/update-user-htmx user-service config)}
:delete {:handler (web-handlers/delete-user-htmx user-service config)}}]
["/table"
{:get {:handler (web-handlers/users-table-fragment user-service config)}}]]]
core/ui.clj (pure Hiccup)shell/web_handlers.clj(ig-repl/reset) in REPLhtmx.logAll()# Build application using build.clj
clojure -T:build clean # Clean build artifacts
clojure -T:build uber # Create standalone JAR
# The uberjar will be created in target/ directory
ls target/*.jar
# Build Docker image (if Dockerfile exists)
docker build -t boundary:latest .
# Run container
docker run -p 3000:3000 boundary:latest
# Development
export BND_ENV=development
clojure -M:repl-clj
# Testing
export BND_ENV=test
clojure -M:test:db/h2
# Staging deployment (if configured)
export BND_ENV=staging
clojure -M:build:deploy
# Format code (if formatter configured)
clojure -M:format
# Dependency analysis
clojure -M:deps:tree # Show dependency tree
clojure -M:deps:outdated # Check for updates
# Code generation (if scaffolding exists)
clojure -M:gen:module billing # Create billing module structure
clojure -M:gen:entity user profile # Add profile entity to user module
# Clean build artifacts
rm -rf .cpcache target
# Restart REPL
clojure -M:repl-clj
user=> (ig-repl/go)
# Check for errors in config
user=> (require '[boundary.config :as config])
user=> (config/load-config)
# Run specific test with verbose output
clojure -M:test:db/h2 -n boundary.user.core.user-test --reporter documentation
# Check if database is issue
clojure -M:test:db/h2 --focus-meta :unit # Should pass without DB
# Clear test database
rm -f test-database.db # If using SQLite for tests
;; System stuck, won't reset
user=> (ig-repl/halt) ; Force stop
user=> (ig-repl/go) ; Fresh start
;; defrecord changes not taking effect
user=> (ig-repl/halt)
;; Exit REPL, clear cache, restart
$ rm -rf .cpcache
$ clojure -M:repl-clj
;; Check what's in system
user=> (keys integrant.repl.state/system)
# Fix unbalanced delimiters automatically
clj-paren-repair src/boundary/user/core/user.clj
# Fix multiple files at once
clj-paren-repair src/boundary/user/core/user.clj src/boundary/user/shell/service.clj
# The tool automatically formats files with cljfmt
# NEVER try to manually fix parenthesis errors - always use this tool
;; Test database connection
user=> (require '[next.jdbc :as jdbc])
user=> (def ds (jdbc/get-datasource {:dbtype "h2:mem" :dbname "test"}))
user=> (jdbc/execute! ds ["SELECT 1"])
;; Check persistence layer
user=> (def repo (get-in integrant.repl.state/system [::user/repository]))
user=> (user-ports/list-users repo {:limit 1})
// In browser console
htmx.logAll(); // Enable HTMX debug logging
// Check for JavaScript errors
// Verify HTMX is loaded
console.log(htmx);
// Check network tab for failed requests
╔════════════════════════════════════════════════════════════════╗
║ BOUNDARY FRAMEWORK CHEAT SHEET ║
╠════════════════════════════════════════════════════════════════╣
║ TEST │ clojure -M:test:db/h2 --watch --focus-meta :unit ║
║ LINT │ clojure -M:clj-kondo --lint src test ║
║ REPL │ clojure -M:repl-clj ║
║ │ (ig-repl/go) (ig-repl/reset) (ig-repl/halt) ║
║ BUILD │ clojure -T:build clean && clojure -T:build uber ║
║ REPAIR │ clj-paren-repair <files> # Fix parentheses ║
║ EVAL │ clj-nrepl-eval -p <port> "<code>" # REPL eval ║
╠════════════════════════════════════════════════════════════════╣
║ CORE │ Pure functions only, no side effects ║
║ SHELL │ All I/O, validation, error handling ║
║ PORTS │ Protocol definitions (abstractions) ║
║ SCHEMA │ Malli schemas for validation ║
╠════════════════════════════════════════════════════════════════╣
║ PITFALL │ snake_case (DB) vs kebab-case (Clojure) ║
║ PITFALL │ defrecord changes need full REPL restart ║
║ PITFALL │ Use clj-paren-repair for delimiter errors ║
╚════════════════════════════════════════════════════════════════╝
Last Updated: 2026-01-06 Version: 1.0.0
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |