5.0 KiB
Penpot Backend – Agent Instructions
Clojure backend (RPC) service running on the JVM.
Uses Integrant for dependency injection, PostgreSQL for storage, and Redis for messaging/caching.
General Guidelines
This is a golden rule for backend development standards. To ensure consistency across the Penpot JVM stack, all contributions must adhere to these criteria:
1. Testing & Validation
-
Coverage: If code is added or modified in
src/, corresponding tests intest/backend_tests/must be added or updated. -
Execution:
- Isolated: Run
clojure -M:dev:test --focus backend-tests.my-ns-testfor the specific task. - Regression: Run
clojure -M:dev:testfor ensure the suite passes without regressions in related functional areas.
- Isolated: Run
2. Code Quality & Formatting
- Linting: All code must pass
clj-kondochecks (runpnpm run lint:clj) - Formatting: All the code must pass the formatting check (run
pnpm run check-fmt). Use thepnpm run fmtfix the formatting issues. Avoid "dirty" diffs caused by unrelated whitespace changes. - Type Hinting: Use explicit JVM type hints (e.g.,
^String,^long) in performance-critical paths to avoid reflection overhead.
Code Conventions
Namespace Overview
The source is located under src directory and this is a general overview of
namespaces structure:
app.rpc.commands.*– RPC command implementations (auth,files,teams, etc.)app.http.*– HTTP routes and middlewareapp.db.*– Database layerapp.tasks.*– Background job tasksapp.main– Integrant system setup and entrypointapp.loggers– Internal loggers (auditlog, mattermost, etc) (do not be confused withapp.common.loggin)
RPC
The PRC methods are implement in a some kind of multimethod structure using
app.util.serivices namespace. The main RPC methods are collected under
app.rpc.commands namespace and exposed under /api/rpc/command/<cmd-name>.
The RPC method accepts POST and GET requests indistinctly and uses Accept
header for negotiate the response encoding (which can be transit, the defaut or
plain json). It also accepts transit (defaut) or json as input, which should be
indicated using Content-Type header.
The main convention is: use get- prefix on RPC name when we want READ
operation.
Example of RPC method definition:
(sv/defmethod ::my-command
{::rpc/auth true ;; requires auth
::doc/added "1.18"
::sm/params [:map ...] ;; malli input schema
::sm/result [:map ...]} ;; malli output schema
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
;; return a plain map or throw
{:id (uuid/next)})
Look under src/app/rpc/commands/*.clj to see more examples.
Tests
Test namespaces match .*-test$ under test/. Config is in tests.edn.
Integrant System
The src/app/main.clj declares the system map. Each key is a component; values
are config maps with ::ig/ref for dependencies. Components implement
ig/init-key / ig/halt-key!.
Database Access
app.db wraps next.jdbc. Queries use a SQL builder that auto-converts kebab-case ↔ snake_case.
;; Query helpers
(db/get cfg-or-pool :table {:id id}) ; fetch one row (throws if missing)
(db/get* cfg-or-pool :table {:id id}) ; fetch one row (returns nil)
(db/query cfg-or-pool :table {:team-id team-id}) ; fetch multiple rows
(db/insert! cfg-or-pool :table {:name "x" :team-id id}) ; insert
(db/update! cfg-or-pool :table {:name "y"} {:id id}) ; update
(db/delete! cfg-or-pool :table {:id id}) ; delete
;; Run multiple statements/queries on single connection
(db/run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row1)
(db/insert! conn :table row2))
;; Transactions
(db/tx-run! cfg (fn [{:keys [::db/conn]}]
(db/insert! conn :table row)))
Almost all methods on app.db namespace accepts pool, conn or
cfg as params.
Migrations live in src/app/migrations/ as numbered SQL files. They run automatically on startup.
Error Handling
The exception helpers are defined on Common module, and are available under
app.commin.exceptions namespace.
Example of raising an exception:
(ex/raise :type :not-found
:code :object-not-found
:hint "File does not exist"
:file-id id)
Common types: :not-found, :validation, :authorization, :conflict, :internal.
Performance Macros (app.common.data.macros)
Always prefer these macros over their clojure.core equivalents — they compile to faster JavaScript:
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
(dm/str "a" "b" "c") ;; string concatenation
Configuration
src/app/config.clj reads PENPOT_* environment variables, validated with
Malli. Access anywhere via (cf/get :smtp-host). Feature flags: (cf/flags :enable-smtp).