This project is a monolithic web application for notifications and delivery management. The system provides:
- Notification submission (POST) and management
- Delivery history (GET) and tracking
- Modern web interface for interaction
The system was developed as a technical challenge, focusing on clean architecture, extensibility, and development best practices.
Architecture: Monolith with clear separation between backend (Clojure/Pedestal) and frontend (ClojureScript/Reagent) within src/challenge/. Both are managed by a single project.clj file.
The system follows a Hexagonal Architecture inspired by Nubank practices, with clear separation between backend and frontend:
The backend is organized into two main layers:
-
Domain Layer
logic/: Pure business logic (pure functions, no side effects, no I/O)models/: Data models and validation schemas (strict schemas)controllers/: Flow orchestration (Logic Sandwich pattern: Queries → Logic → Effects)
-
External Layer
infrastructure/: Concrete implementations (database, HTTP server handlers)interceptors/: Pedestal interceptors (validation, logging, component injection)adapters/: Data transformation between wire schemas and modelswire/: Schemas for external communication (in/out/persistency)
The frontend is a ClojureScript/Reagent application organized under src/challenge/frontend/challenge/ui/:
- Components: Reagent components organized by feature
- State Management: Reactive state with
reagent/atom - HTTP Client: Communication with backend API via
fetch - Models & Logic: Frontend-specific data models and business logic
The challenge/ui/ path under the frontend source root ensures the namespace challenge.ui.core (and challenge.ui.*) matches the ClojureScript compiler’s file-to-namespace convention.
- Notifications: Submit via POST; backend validates and persists.
- Delivery: Query delivery history via GET; backend returns enriched data.
The frontend is built with ClojureScript and Reagent (React wrapper), located in src/challenge/frontend/challenge/ui/:
- Reactive State: Managed with
reagent/atomfor component state - Functional Components: Organized by responsibility
- HTTP Communication: Via
fetchAPI to backend REST endpoints - Modern UI: Styled with Tailwind CSS
- Build System: Compiled with
lein-cljsbuildtoresources/public/js/app.jsandresources/public/js/out/ - Dependencies: React 18 (cljsjs/react, cljsjs/react-dom) for Reagent
The architecture clearly separates business logic from technical implementations:
-
Domain Layer:
logic/: Pure, testable functions, no external dependencies or I/Omodels/: Strict schemas for domain entitiescontrollers/: Orchestration following the Logic Sandwich pattern
-
External Layer:
infrastructure/: Concrete implementations (database, HTTP handlers)interceptors/: Pedestal interceptors for validation, logging, and component injectionadapters/: Transformation between wire schemas (loose/strict) and modelswire/: Schemas for external communication (in: loose, out: strict, persistency: strict)
Uses com.stuartsierra/component for lifecycle management:
- Dependency Injection: Components receive dependencies via
using - Lifecycle Management:
startandstopfor initialization and cleanup - Testability: Allows injection of mocked components in tests
Pattern used in controllers:
Query (Infrastructure) → Logic (Domain) → Effect (Infrastructure)
Example:
;; Query: fetch data from database
(data (persistency/query ...))
;; Logic: process and enrich
(result (logic/process data))
;; Effect: format response
(response (adapters/model->wire result))The logic/ layer contains only pure functions:
- No side effects
- Deterministic
- Easy to test
- Reusable
The system uses Prismatic Schema with automatic validation:
- Models: Strict schemas (all fields validated)
- Wire.in: Loose schemas (tolerant to extra fields for forward compatibility)
- Wire.out: Strict schemas (explicit control of what is sent)
- Wire.persistency: Strict schemas with namespaced keywords
- Unit Tests: Test pure functions in isolation with
clojure.test - Integration Tests: Test complete flows with
state-flowand mocked components - Schema Validation: Automatically enabled in tests via
schema.test/validate-schemas - Mock Components: Mocked components for persistency in integration tests
- Auto-initialization: Test dependencies are automatically loaded when loading namespaces
# Install dependencies (includes ClojureScript and Reagent)
lein deps
# Run application (backend only - compile frontend separately)
lein run-dev
# For full development (backend + frontend):
# Terminal 1: Backend (REPL or server)
lein repl :repl-auto
# or
lein run-dev
# Terminal 2: Frontend watch mode (auto-recompiles on file changes)
lein cljs-watch
# or: lein cljsbuild auto app
# Compile ClojureScript once (without watch)
lein cljs-once
# or: lein cljsbuild once app
# Run application locally (after compiling frontend)
lein run
# Start REPL
lein repl
# Run REPL with dev profile (recommended)
lein repl :dev
# Run REPL with auto-start system
lein repl :repl-auto# Run all tests
lein test
# Run unit tests only
lein test challenge.logic.notification-test
# Run integration tests
lein test challenge.integrationTests can be executed directly in the REPL. Dependencies are automatically initialized:
;; Load test namespace
(require 'challenge.logic.notification-test :reload)
;; Run tests from namespace
(require 'clojure.test)
(clojure.test/run-tests 'challenge.logic.notification-test)
;; Run all tests
(clojure.test/run-all-tests)# Run full lint (clean-ns, format, diagnostics, cljfmt, nsorg, kondo)
lein lint
# Fix namespace organization
lein clean-ns-fix
# Fix formatting
lein format-fix
# Check formatting (dry run)
lein format
# Check namespace organization (dry run)
lein nsorg-check
# Static analysis (clj-kondo)
lein kondo# Full build (compiles ClojureScript + creates uberjar)
lein build
# Production build (optimized ClojureScript with advanced optimizations)
lein build-prod
# Manual build steps:
# 1. Compile ClojureScript for development
lein cljs-once
# or: lein cljsbuild once app
# 2. Compile ClojureScript for production (optimized)
lein cljsbuild once prod
# 3. Create uberjar (includes compiled frontend assets and all resources)
lein uberjar
# Resources (config/, migrations/, public/, logback.xml) are copied to target/classes
# before packaging via the lein-resource plugin, so they are always included in the JAR.
# Clean build artifacts (removes target/ and resources/public/js/)
lein clean
# Useful ClojureScript aliases:
lein cljs-watch # Watch mode (auto-recompile on file changes)
lein cljs-once # Compile once (development, :optimizations :none)The Docker build process automatically compiles ClojureScript before creating the uberjar:
# Build and start services (PostgreSQL + App)
cd docker
docker-compose up --build
# Run in background
docker-compose up -d
# Stop services
docker-compose down
# View logs
docker-compose logs -f app
# Run database only
docker-compose up postgresNote: The Dockerfile compiles ClojureScript (lein cljsbuild once app) before creating the uberjar, ensuring the frontend is included in the final artifact.
# Run migrations
lein migratus migrate
# Rollback last migration
lein migratus rollback
# Check migration status
lein migratus pending# Required environment variables (optional, has defaults)
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/challenge"
export DB_HOST="localhost"
export DB_PORT="5432"
export DB_NAME="challenge"
export DB_USER="postgres"
export DB_PASSWORD="postgres"Integration tests do not require a running PostgreSQL database. They use mocked components:
- Mock Persistency: In-memory storage for tests
- Random Port: Server uses port 0 (random) to avoid conflicts
- Auto-initialization: Dependencies are automatically loaded when loading test namespaces
- State-flow: Framework for integration tests with state management
# Run integration tests (no database needed)
lein test challenge.integration
# In REPL, tests work automatically:
(require 'challenge.logic.notification-test :reload)
(require 'clojure.test)
(clojure.test/run-tests 'challenge.logic.notification-test)notification-challenge/
├── src/
│ └── challenge/
│ ├── backend/ # Backend: Clojure code (.clj files)
│ │ └── challenge/ # Backend code (namespaces challenge.*)
│ │ ├── adapters/ # Wire ↔ model transformation
│ │ ├── components/ # System components (Pedestal, Logger, DB, etc)
│ │ ├── config/ # Configuration reading
│ │ ├── controllers/ # Orchestration (Logic Sandwich)
│ │ ├── handlers/ # HTTP route definitions
│ │ ├── infrastructure/ # External implementations
│ │ │ ├── http_server/ # HTTP handlers
│ │ │ └── persistency/ # Database operations
│ │ ├── interceptors/ # Pedestal interceptors (validation, logging)
│ │ ├── interface/ # HTTP interfaces (response helpers)
│ │ │ └── http/
│ │ ├── logic/ # Pure business logic (domain layer)
│ │ ├── models/ # Domain models (strict schemas)
│ │ ├── schema/ # Schema creation helpers
│ │ ├── wire/ # External communication schemas
│ │ │ ├── in/ # Input schemas (loose)
│ │ │ ├── out/ # Output schemas (strict)
│ │ │ └── persistency/ # Database schemas (strict, namespaced)
│ │ ├── main.clj # Application entry point
│ │ ├── migrate.clj # Migration utilities
│ │ ├── repl.clj # REPL development utilities
│ │ └── system.clj # Component system definition
│ └── frontend/ # Frontend: ClojureScript/Reagent application
│ └── challenge/ # Namespace prefix (challenge.ui.*)
│ └── ui/
│ ├── adapters.cljs
│ ├── components/ # Reagent components
│ │ ├── filters.cljs
│ ├── core.cljs # Main application entry (challenge.ui.core)
│ ├── http_client.cljs
│ ├── logic.cljs
│ └── models.cljs
├── test/
│ ├── integration/ # Integration tests
│ │ └── challenge/
│ │ └── integration/
│ │ ├── aux/ # Test helpers and setup
│ │ │ ├── init.clj # Automatic dependency setup
│ │ │ ├── http-helpers.clj # HTTP request helpers
│ │ │ └── mock-persistency.clj # Persistency mock
│ │ └── *_test.clj # Integration tests
│ └── unit/ # Unit tests
│ └── challenge/
│ └── *_test.clj # Pure function tests
├── resources/
│ ├── migrations/ # SQL migrations
│ ├── config/ # Application configuration
│ │ └── application.edn
│ └── public/ # Static assets
│ ├── index.html
│ ├── swagger-ui.html
│ └── js/ # Compiled ClojureScript (lein cljs-once / cljs-watch)
│ ├── app.js # Bootstrap (loads from /js/out/)
│ └── out/ # Development build output (:optimizations :none)
├── project.clj # Single project.clj (monolith)
└── docker/ # Docker configuration
;; In REPL, after loading challenge.repl:
(require 'challenge.repl)
;; Start system
(challenge.repl/start!)
;; Stop system
(challenge.repl/stop!)
;; Restart system
(challenge.repl/restart!)
;; Reload namespaces and restart
(challenge.repl/reload!)
;; Run tests in REPL
(require 'challenge.logic.notification-test :reload)
(require 'clojure.test)
(clojure.test/run-tests 'challenge.logic.notification-test)# Clear Leiningen cache
rm -rf ~/.m2/repository
rm -rf ~/.lein
# Reinstall dependencies
lein deps
# Check Java version (requires Java 21+)
java -version
# Check Leiningen version
lein version
# View application logs
tail -f logs/pedrepl-*.log
# Check if port 3000 is in use (may cause conflicts in tests)
lsof -i :3000
# Kill process on port 3000 (if needed)
kill -9 $(lsof -t -i:3000)- Monolith Structure: Single
project.cljmanages both backend (Clojure) and frontend (ClojureScript). Backend code is insrc/challenge/backend/challenge/and frontend code is insrc/challenge/frontend/challenge/ui/. The backend uses:source-paths ["src/challenge/backend"]; the frontend uses:cljsbuildwith:source-paths ["src/challenge/frontend"]and:main challenge.ui.core, so the pathchallenge/ui/core.cljsunder that root is required for the compiler to find the entry namespace. - Build Order: For production builds, ClojureScript must be compiled before creating the uberjar. Use
lein buildorlein build-prodto handle this automatically. - Development Workflow: For full development, run backend and frontend watch mode in separate terminals. Frontend watch mode (
lein cljs-watchorlein cljsbuild auto app) automatically recompiles on file changes. - Port 0 in Tests: Integration tests use port 0 (random) to avoid conflicts. The system preserves this configuration even when there is a configuration file.
- Auto-initialization: When loading test namespaces in the REPL, dependencies (such as
schema.test) are automatically initialized. - Mock Components: Integration tests use mocked components, do not require a real database.
- Language: Clojure 1.12.2
- HTTP Server: Pedestal 0.5.8 (with Jetty)
- Database: PostgreSQL (via next.jdbc)
- Component System: Component (Stuart Sierra) 1.1.0
- Schema Validation: Prismatic Schema 1.4.1 + clj-schema 0.5.1
- Migrations: Migratus 1.4.5
- JSON: Cheshire 5.11.0
- Logging: Logback Classic 1.2.3
- Language: ClojureScript 1.11.60
- UI Framework: Reagent 1.2.0 (React wrapper)
- React: cljsjs/react 18.2.0-0, cljsjs/react-dom 18.2.0-0
- Build Tool: lein-cljsbuild 1.1.8
- Compiler: Google Closure Compiler (via ClojureScript)
- Output:
resources/public/js/app.js(bootstrap) andresources/public/js/out/(dev build)
- Unit Tests:
clojure.test - Integration Tests:
state-flow5.20.0 - Assertions:
matcher-combinators3.8.3 - Mocking:
mockfn0.7.0