Welcome to the comprehensive, hands-on tutorial for the Boundary Framework. In this tutorial, you will build a production-ready Task Management API from scratch.
Time: 1-2 hours Level: Beginner to Intermediate
We're going to build a "TaskMaster" API that allows users to:
By the end of this tutorial, you'll have a deep understanding of the Functional Core / Imperative Shell (FC/IS) architecture and how to build scalable, maintainable Clojure applications with Boundary.
Boundary is designed around the Functional Core / Imperative Shell paradigm. This means:
Before we begin, ensure you have the following installed:
Verify your environment:
java -version # Should show 11 or higher
clojure -version
curl --version
In Boundary, everything is organized into modules. A typical module looks like this:
src/boundary/task/
├── core/
│ ├── task.clj # Pure business logic (FC)
│ └── ui.clj # UI components (Hiccup)
├── shell/
│ ├── service.clj # Orchestration layer (IS)
│ ├── http.clj # HTTP handlers & routes
│ └── persistence.clj # Database implementation
├── ports.clj # Protocol definitions
└── schema.clj # Malli validation schemas
For this tutorial, we will use the main Boundary repository as our starting point.
git clone https://github.com/thijs-creemers/boundary.git task-master
cd task-master
Take a moment to explore the directory structure. Boundary is a monorepo with several libraries in the libs/ directory.
.
├── AGENTS.md # Quick reference for development
├── deps.edn # Project dependencies
├── libs/ # Boundary library components
│ ├── core/ # Foundation: validation, utilities, interceptors
│ ├── observability/ # Logging, metrics, error reporting
│ ├── platform/ # HTTP, database, CLI infrastructure
│ ├── user/ # Authentication, authorization, MFA
│ ├── admin/ # Auto-CRUD admin interface
│ └── scaffolder/ # Module code generator
├── resources/
│ └── conf/ # Configuration files (Aero)
└── migrations/ # Database migrations (SQL)
Boundary uses Aero for configuration. By default, it's set up to use SQLite in development, which requires no extra setup.
Check your resources/conf/dev/config.edn:
{:boundary/db-context
{:jdbc-url "jdbc:sqlite:dev-database.db"}}
Aero allows you to use tags like #env, #include, and #merge to create flexible, environment-aware configurations.
The user module comes with built-in migrations for user management and sessions. Let's apply them.
# Initialize migration system (creates the migrations table if it doesn't exist)
clojure -M:migrate init
# Run pending migrations (applies all .up.sql files)
clojure -M:migrate up
# Verify status
clojure -M:migrate status
Expected Output:
Applied migrations:
- 20240101000000-create-users-table
- 20240101000001-create-sessions-table
The REPL (Read-Eval-Print Loop) is the heart of Clojure development.
clojure -M:repl-clj
Once the REPL starts (usually on port 7888), you can connect your editor to it. This allows you to evaluate code instantly without restarting the application.
At this point, you should have a dev-database.db file in your project root, and migrations should be successfully applied. Your REPL should be running and ready for input.
In your REPL, start the Integrant system:
(require '[integrant.repl :as ig-repl])
(require '[boundary.system])
;; Tell Integrant how to find the config
(ig-repl/set-prep! #(boundary.system/system-config))
;; Start the system
(ig-repl/go)
Output:
INFO boundary.server - Starting HTTP server on port 3000
INFO boundary.server - Swagger UI available at http://localhost:3000/api-docs/
INFO boundary.server - Server started successfully
We'll use curl to create our first user account. This uses the boundary/user module.
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"email": "tutorial@boundary.dev",
"name": "Tutorial User",
"password": "Password123!",
"role": "admin"
}'
Expected Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"email": "tutorial@boundary.dev",
"name": "Tutorial User",
"role": "admin",
"active": true,
"createdAt": "2026-01-26T10:00:00Z"
}
Now, let's authenticate to get a JWT (JSON Web Token).
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "tutorial@boundary.dev",
"password": "Password123!"
}'
Expected Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600,
"userId": "a1b2c3d4-e5f6-7890-abcd-1234567890ab"
}
Save that accessToken! You'll need it as a Bearer token in the next steps.
Boundary uses interceptors for cross-cutting concerns like authentication. In boundary/user/shell/http.clj, you'll find an interceptor that validates the JWT:
(def auth-interceptor
{:name :auth
:enter (fn [ctx]
(let [token (get-in ctx [:request :headers "authorization"])]
(if (valid-token? token)
(assoc-in ctx [:request :identity] (decode-token token))
(throw (ex-info "Unauthorized" {:type :unauthorized})))))})
By adding this interceptor to a route, you ensure that only authenticated users can access it.
Boundary's scaffolder generates all the boilerplate for a new module. It's not just a simple template; it generates a complete, functional module following best practices.
Run the following command:
clojure -M:dev -m boundary.scaffolder.shell.cli-entry generate \
--module-name task \
--entity Task \
--field title:string:required \
--field description:string \
--field priority:string:required \
--field due-date:instant \
--field completed:boolean:required
What happened? The scaffolder created 12 files and generated 473 tests!
Let's look at the key files in libs/task/src/boundary/task/:
schema.cljThis file defines the data structure of your entity using Malli.
(def task-schema
[:map
[:id :uuid]
[:title [:string {:min 1 :max 255}]]
[:description {:optional true} [:maybe :string]]
[:priority [:string {:min 1}]]
[:due-date {:optional true} [:maybe inst?]]
[:completed :boolean]
[:created-at inst?]
[:updated-at inst?]])
core/task.cljThis is your Functional Core. It contains pure functions for transforming task data.
(defn prepare-task [task-data]
(let [now (java.time.Instant/now)]
(merge task-data
{:id (random-uuid)
:created-at now
:updated-at now})))
shell/service.cljThe Imperative Shell. It orchestrates the process of creating a task:
The scaffolder generated a SQL migration in resources/migrations/.
clojure -M:migrate up
Currently, the system doesn't know about our new module. We need to add it to the system configuration.
Open src/boundary/system.clj and add the task module to the Integrant configuration and routes.
In src/boundary/system.clj:
(require '[boundary.task.shell.http :as task-http]
'[boundary.task.shell.module-wiring])
;; Add to routes:
(defn all-routes [config]
(concat
(user-http/user-routes config)
(task-http/task-routes config))) ;; Add this line
Go back to your REPL and reload the system:
(ig-repl/reset)
Now let's use our API to create a task. Replace YOUR_TOKEN with the accessToken you received in Chapter 3.
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"title": "Complete Boundary Tutorial",
"description": "Build the TaskMaster API",
"priority": "high",
"completed": false
}'
Response (201 Created):
{
"id": "f8d7e6d5-...",
"title": "Complete Boundary Tutorial",
"description": "Build the TaskMaster API",
"priority": "high",
"completed": false,
"createdAt": "2026-01-26T11:00:00Z"
}
Boundary supports built-in pagination.
curl -H "Authorization: Bearer YOUR_TOKEN" \
"http://localhost:3000/api/v1/tasks?limit=10&offset=0"
Expected Response:
{
"items": [...],
"total": 1,
"limit": 10,
"offset": 0
}
Use the id from the previous response:
curl -H "Authorization: Bearer YOUR_TOKEN" \
http://localhost:3000/api/v1/tasks/TASK_ID
Let's mark it as completed! Notice how we only send the fields we want to change.
curl -X PUT http://localhost:3000/api/v1/tasks/TASK_ID \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"completed": true
}'
curl -X DELETE http://localhost:3000/api/v1/tasks/TASK_ID \
-H "Authorization: Bearer YOUR_TOKEN"
Verify that your changes are actually saved by checking the list of tasks after an update or delete.
Let's try to create a task without a title, which we marked as required.
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"description": "Missing title",
"priority": "low",
"completed": false
}'
Expected Response (400 Bad Request):
{
"type": "validation-error",
"message": "Validation failed",
"errors": {
"title": ["is required"]
}
}
Boundary's error interceptor automatically catches validation exceptions and formats them into a clean JSON response.
Open libs/task/src/boundary/task/schema.clj. Let's add a rule that the priority must be one of: low, medium, or high.
(def task-priority-schema
[:enum "low" "medium" "high"])
;; Update task-schema:
(def task-schema
[:map
;; ... other fields
[:priority task-priority-schema]])
After updating the schema and refreshing the REPL ((ig-repl/reset)), try sending an invalid priority:
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"title": "Invalid Priority",
"priority": "super-high",
"completed": false
}'
Sometimes validation depends on multiple fields. For example, a due-date must be in the future.
(def create-task-schema
[:and
[:map
[:title :string]
[:due-date {:optional true} inst?]]
[:fn {:error/message "Due date must be in the future"}
(fn [{:keys [due-date]}]
(if due-date
(.isAfter due-date (java.time.Instant/now))
true))]])
Imagine we have a rule that when a task is marked as completed, we also want to record the completion date. This is business logic and belongs in core/task.clj.
(defn complete-task [task]
(-> task
(assoc :completed true)
(assoc :completed-at (java.time.Instant/now))
(assoc :updated-at (java.time.Instant/now))))
In shell/service.clj, we update the update-task method to use this new core function:
(defn update-task [this id updates]
(let [existing (ports/get-task repository id)]
(if (and (:completed updates) (not (:completed existing)))
(let [completed-task (task-core/complete-task existing)]
(ports/save-task repository completed-task))
;; ... normal update logic
)))
By keeping complete-task in the core, we can test it with simple Clojure maps, without needing a database, an HTTP server, or even the Integrant system. This isolation is what makes Boundary applications so robust and maintainable over time.
Boundary uses a specific testing strategy to maximize coverage and speed:
clojure -M:test:db/h2
These are the tests you'll run most frequently during development.
clojure -M:test:db/h2 --focus-meta :unit
Open libs/task/test/boundary/task/core/task_test.clj.
(deftest complete-task-test
(testing "marks task as completed and sets timestamp"
(let [task {:id "123" :completed false}
result (task-core/complete-task task)]
(is (= true (:completed result)))
(is (inst? (:completed-at result))))))
You can also run tests directly from your REPL:
(require '[clojure.test :refer [run-tests]])
(require '[boundary.task.core.task-test])
(run-tests 'boundary.task.core.task-test)
boundary/observabilityBoundary doesn't just print strings; it logs data. This makes logs searchable and indexable.
(require '[boundary.observability.ports :as obs])
(obs/info logger "Task created" {:task-id id :user-id user-id})
These logs are automatically enriched with correlation IDs, timestamps, and environment info.
You can easily track performance or business metrics.
(obs/increment-counter metrics "tasks.created" 1 {:priority "high"})
In production, these metrics can be pushed to Prometheus, Datadog, or CloudWatch.
When an exception occurs, Boundary's interceptor automatically reports it to your error tracking service (like Sentry).
(try
(do-something-risky)
(catch Exception e
(obs/report-error error-reporter e {:context "risk-calculation"})))
In this chapter, we'll dive deep into real-world requirements that often come up after the basic CRUD is in place. We'll implement soft deletes and a custom audit log interceptor.
Soft deletes allow you to "delete" a record by marking it with a timestamp instead of removing it from the database. This is vital for data recovery and auditability.
First, open libs/task/src/boundary/task/schema.clj and add the deleted-at field.
(def task-schema
[:map
[:id :uuid]
[:title [:string {:min 1 :max 255}]]
[:description {:optional true} [:maybe :string]]
[:priority [:string {:min 1}]]
[:due-date {:optional true} [:maybe inst?]]
[:completed :boolean]
[:deleted-at {:optional true} [:maybe inst?]] ;; Add this
[:created-at inst?]
[:updated-at inst?]])
In libs/task/src/boundary/task/core/task.clj, add the logic to "delete" a task.
(defn mark-as-deleted [task]
(assoc task :deleted-at (java.time.Instant/now)))
In libs/task/src/boundary/task/shell/persistence.clj, we need to update two things:
;; In SqliteTaskRepository record:
(delete-task [this id]
(let [existing (ports/get-task this id)]
(when existing
(let [deleted (task-core/mark-as-deleted existing)]
(jdbc/execute-one! ds
["UPDATE tasks SET deleted_at = ? WHERE id = ?"
(str (:deleted-at deleted))
(str id)])))))
(list-tasks [this params]
(jdbc/execute! ds
["SELECT * FROM tasks WHERE deleted_at IS NULL LIMIT ? OFFSET ?"
(:limit params)
(:offset params)]))
Audit logs track who did what and when. Instead of sprinkling logging code everywhere, we can use an interceptor to handle this automatically for specific routes.
Create a new file (or add to your shell namespace) for the interceptor:
(defn audit-log-interceptor [action]
{:name :audit-log
:leave (fn [ctx]
(let [user-id (get-in ctx [:request :identity :user-id])
status (get-in ctx [:response :status])
body (get-in ctx [:response :body])]
;; Only log successful operations
(when (and user-id (<= 200 status 299))
(println "AUDIT LOG:"
{:action action
:user-id user-id
:timestamp (java.time.Instant/now)
:payload (select-keys body [:id :title])}))
ctx))})
In libs/task/src/boundary/task/shell/http.clj, wrap your handlers with the interceptor:
(def task-routes
["/tasks"
{:interceptors [auth-interceptor]}
["" {:post {:handler create-handler
:interceptors [(audit-log-interceptor :create-task)]}}]
["/:id" {:put {:handler update-handler
:interceptors [(audit-log-interceptor :update-task)]}
:delete {:handler delete-handler
:interceptors [(audit-log-interceptor :delete-task)]}}]])
Now, every time a task is created or updated, a structured audit log entry will be generated!
Boundary isn't just for APIs; it's a full-stack framework. We use Hiccup for server-side HTML and HTMX for dynamic interactions without writing complex JavaScript.
In libs/task/src/boundary/task/core/ui.clj, define a task list component.
(ns boundary.task.core.ui
(:require [boundary.shared.ui.core.icons :as icons]))
(defn task-item [task]
[:li {:id (str "task-" (:id task))}
[:span {:style (when (:completed task) "text-decoration: line-through")}
(:title task)]
[:button {:hx-post (str "/web/tasks/" (:id task) "/toggle")
:hx-target (str "#task-" (:id task))
:hx-swap "outerHTML"}
(if (:completed task) "Undo" "Complete")]])
(defn task-list [tasks]
[:div.container
[:h1 "Your Tasks"]
[:ul#task-list
(for [t tasks]
(task-item t))]
[:form {:hx-post "/web/tasks" :hx-target "#task-list" :hx-swap "beforeend"}
[:input {:name "title" :placeholder "New task..."}]
[:button "Add Task"]]])
In libs/task/src/boundary/task/shell/http.clj, add a handler that returns HTML instead of JSON.
(defn render-tasks-handler [request]
(let [tasks (ports/list-tasks repository {:limit 100 :offset 0})]
{:status 200
:headers {"Content-Type" "text/html"}
:body (ui/task-list tasks)}))
(defn toggle-task-handler [request]
(let [id (-> request :path-params :id)
task (ports/get-task repository id)
updated (task-core/toggle-complete task)]
(ports/save-task repository updated)
{:status 200
:headers {"Content-Type" "text/html"}
:body (ui/task-item updated)}))
Notice the :hx-post and :hx-target attributes. These tell HTMX to:
This gives you a "Single Page App" feel with 100% server-side code. No React, no build step, no pain.
Boundary uses a "Design Token" approach for styling, centralized in resources/public/css/tokens.css. This ensures consistency across the app.
Using Tokens in CSS:
.task-item {
padding: var(--spacing-md);
background-color: var(--color-surface);
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-sm);
}
Using Icons: Instead of emojis, use the built-in Lucide icon library.
[:button
(icons/icon :trash {:size 18})
" Delete"]
We've covered unit tests, but production systems need more. Let's look at Contract Tests and Property-Based Testing.
Contract tests ensure your adapters (like SQLite) correctly implement the protocols. They run against a real database instance, verifying that your SQL queries behave as expected.
(ns boundary.task.shell.persistence-contract-test
(:require [clojure.test :refer :all]
[boundary.task.ports :as ports]
[boundary.task.shell.persistence :as persistence]
[next.jdbc :as jdbc]))
(defn get-test-datasource []
(jdbc/get-datasource "jdbc:sqlite::memory:"))
(deftest sqlite-contract-test
(let [ds (get-test-datasource)
_ (run-migrations! ds) ;; Ensure schema is present
repo (persistence/->SqliteTaskRepository ds)]
(testing "can save and retrieve a task"
(let [task {:id (random-uuid)
:title "Contract Test"
:completed false
:created-at (java.time.Instant/now)
:updated-at (java.time.Instant/now)}]
(ports/save-task repo task)
(let [retrieved (ports/get-task repo (:id task))]
(is (= "Contract Test" (:title retrieved)))
(is (false? (:completed retrieved))))))))
Using test.check, we can test that our code works for any valid input, not just the ones we thought of. This is especially useful for complex validation logic or mathematical calculations.
Generators describe how to produce random data that follows your schema.
(require '[clojure.test.check.generators :as gen])
(def task-gen
(gen/hash-map
:id gen/uuid
:title (gen/not-empty gen/string-alphanumeric)
:priority (gen/elements ["low" "medium" "high"])
:completed (gen/return false)))
A property is a claim that should hold true for all generated values.
(require '[clojure.test.check.properties :as prop])
(def complete-task-property
(prop/for-all [task task-gen]
(let [result (task-core/complete-task task)]
(and (:completed result)
(inst? (:completed-at result))
(= (:id task) (:id result))))))
(require '[clojure.test.check :as tc])
(tc/quick-check 100 complete-task-property)
;; => {:result true, :num-tests 100, :seed 173788...}
Moving from "it works on my machine" to "it's live" requires a few extra steps.
Clojure apps are typically deployed as an Uberjar—a single .jar file containing your code and all its dependencies.
clojure -T:build clean
clojure -T:build uber
Use Aero's #profile tag to vary configuration by environment.
;; resources/conf/config.edn
{:boundary/db-context
{:jdbc-url #profile {:dev "jdbc:sqlite:dev.db"
:prod #env DATABASE_URL}}}
# Set environment
export BND_ENV=prod
export DATABASE_URL="jdbc:postgresql://db.example.com:5432/myapp"
# Start the app
java -jar target/boundary-standalone.jar server
Q: I changed a file but the REPL doesn't see the change.
A: Use (ig-repl/reset). This stops the system, reloads all changed namespaces, and restarts the system. If you changed a defrecord, you might need to (ig-repl/halt) and (ig-repl/go) to ensure the new record definition is used.
Q: "Database is locked" (SQLite).
A: This usually happens when multiple processes are trying to write to SQLite at once. Ensure you don't have multiple REPLs or servers running against the same .db file.
Q: Migration failed midway.
A: Boundary's migration tool is transactional. Check the migrations table to see what was applied. You can manually fix the state with clojure -M:migrate rollback if necessary.
Q: My token is rejected with "Expired".
A: Check your system clock. If you're running in a VM or Docker, the time might have drifted. Also, ensure your JWT_SECRET is at least 32 characters long.
| Term | Definition |
|---|---|
| Functional Core (FC) | The part of your code that contains pure functions and business logic. |
| Imperative Shell (IS) | The part of your code that handles side effects (I/O, DB, HTTP). |
| Integrant | A micro-framework for data-driven system configuration and lifecycle management. |
| Aero | A small library for configuring Clojure applications using EDN files. |
| Malli | A high-performance data validation and specification library for Clojure. |
| Port | A Clojure Protocol defining an interface (the "what"). |
| Adapter | A record implementing a Protocol (the "how" for a specific technology). |
| Interceptor | A function that can intercept the request/response flow (middleware). |
| Uberjar | A single JAR file containing an application and all its dependencies. |
| Hiccup | A library for representing HTML as Clojure data structures (vectors and maps). |
| HTMX | A library that allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML. |
As you grow with Boundary, keep an eye out for these common "gotchas" that can lead to messy codebases.
The Mistake:
;; ❌ BAD - Core function calling the database
(ns boundary.task.core.task
(:require [boundary.task.shell.persistence :as db]))
(defn create-if-valid [data]
(if (valid? data)
(db/save data)
(throw ...)))
The Fix: Core should only return data. Let the Shell decide what to do with that data (e.g., save it).
The Mistake:
;; ❌ BAD - Using snake_case for keys
{:user_id "123" :first_name "John"}
The Fix:
Always use kebab-case internally. Convert to snake_case only at the database boundary or camelCase at the API boundary using Boundary's built-in utilities.
The Mistake:
Making changes and then running clojure -M:test or restarting the whole app to see them.
The Fix:
Keep your REPL connected. Evaluate individual functions (Cmd+Enter in Calva). Use (ig-repl/reset) to pick up configuration changes. This is the "Clojure Way" and will make you 10x faster.
The Mistake:
Putting everything into a single task module—including user management, billing, and notifications.
The Fix:
Follow the Single Responsibility Principle. If it's a different domain entity with different business rules, it probably belongs in its own module.
Boundary is an evolving framework, and we welcome your input!
You are now equipped to build sophisticated, high-performance web applications with Clojure. Remember: Keep your core pure, your shell thin, and your boundaries clean.
Happy Coding with Boundary! 🚀
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 |