Skip to content
Documentation

Development

Written by

1. Want to see it live?

You just want to run ProjectForge? Then take the ready-to-use jar or Docker image described in Installation. The steps below are for developers who want to build and change it.

What you need:

  • a JDK 17 or newer, JDK 21 recommended (the code is compiled for Java 17, see Technologies),

  • git,

  • Node.js with npm, only for the Next.js dev server (bin/pfDev.sh dev); the Gradle build downloads its own Node.js.

Build and run it:

  1. Clone the repository: git clone github.com/micromata/projectforge.git and cd projectforge.

  2. Build it without tests: ./gradlew build -x test (gradlew.bat on Windows). There is no Maven build.

  3. Start it, either

    1. as the main instance: bin/pfDev.sh run (Spring Boot on localhost:8080). The first start runs the setup (setup wizard, then the setup page in the browser) as described in Installation, or

    2. as an independent test instance with test data: bin/pfDev.sh setup 1 (once), then bin/pfDev.sh run 1 (Spring Boot on localhost:8081). The first start fills an own HSQLDB with test data and prints the admin login (see Several instances side by side (bin/pfDev.sh)).

  4. Open localhost:8080 (or :8081). The pages are served by Spring Boot from the static export of the Next.js frontend under /next.

  5. For working on the frontend, start the Next.js dev server with hot reload: bin/pfDev.sh dev (or bin/pfDev.sh dev 1) and open localhost:3000/next (or :3001/next).

Instead of bin/pfDev.sh run you may also use ./gradlew bootRun or run java -jar projectforge-application/build/libs/projectforge-application-<version>.jar.

2. Architecture

2.1. Overview

The main functionality of ProjectForge is to display and search data lists and to modify data (CRUD - create, read, update and delete). The goal of the architecture is not to write thousands of lines of code for the same base functionality again and again: an entity declares its fields once, and access checks, full text search, history, REST endpoints, list and edit pages build on that.

ProjectForge is one Spring Boot application (projectforge-application), built with Gradle. The main modules:

Module Content

projectforge-model, projectforge-common

Shared utilities and constants (e. g. the REST paths).

projectforge-business

Entities (DO), data access objects (*Dao) with the access checks, services, Flyway migrations and the i18n bundles (I18nResources.properties).

projectforge-rest

The REST layer under /rs (and /rsPublic for the few calls without login), DTOs, the server-side UILayout of the pages not yet hand-built.

projectforge-next

The frontend: Next.js 16, React 19, TypeScript, TanStack Table/Query/Form, Tailwind CSS 4. Built as a static export and served by Spring Boot under /next — no Node.js process in production.

projectforge-webapp

The legacy React app under /react, still bundled for the pages that are not yet migrated to projectforge-next.

projectforge-jcr, projectforge-carddav, projectforge-idp

The JCR document store (Apache Jackrabbit Oak) of existing installations, CardDAV for addresses, user and group sync with an identity provider (Keycloak, authentik). Attachments are stored by the file store configured with projectforge.files.store: db (default for new installations) keeps documents in the database and DataTransfer files in the file system; jcr is only used by existing installations with JCR data, whose files are migrated to db on start-up when switched.

plugins/*

The built-in plugins, see Writing own plugins.

The Wicket UI was removed with ProjectForge 9.0.0. Old Wicket links (/wa/…​) are redirected to the corresponding Next.js pages.

2.2. The layers

2.2.1. Business layer (projectforge-business)

The business layer handles all entities including the access checks for CRUD operations as well as user services (storage of preferences etc.).

2.2.2. REST layer (projectforge-rest)

The REST layer provides the CRUD services of all entities and further services for the frontend. Every call is checked against the access rights of the logged-in user by the business layer.

There are two kinds of entity REST classes:

  • *EntityRest (base classes AbstractEntityRest, AbstractDTOEntityRest): layout-free. It serves data, filter metadata, favorites, history and the write operations; the page itself is hand-built in projectforge-next. This is the pattern for every new or migrated page, e. g. BookEntityRest.

  • *PagesRest (base classes AbstractPagesRest, AbstractDTOPagesRest): additionally generates the page layout on the server (UILayout JSON), which only the legacy React app displays. These pages are migrated step by step to the *EntityRest pattern.

2.2.3. Frontend (projectforge-next)

The Next.js app (App Router) lives in projectforge-next/; its conventions are described in projectforge-next/CLAUDE.md and projectforge-next/docs/. In short:

  • Routes in app/(authenticated)/<entity>/, the domain code in components/features/<entity>/, shared components in components/shared/ and components/data-table/.

  • All backend calls go through lib/rs/ and TanStack Query.

  • An ordinary list and edit page is declared, not programmed: one PageDef per entity (definePage()), rendered by the generic EntityListPage and EntityEditPage.

  • Field types, maximum lengths, mandatory fields and enum values are not maintained twice: they are generated from the entity’s @PropertyInfo and JPA annotations into lib/metadata/<entity>.generated.ts.

  • The menu and the redirects are decided by the backend (MenuItemDefId, NextMigration), so a page can be switched to projectforge-next independently of all others.

In development the dev server (bin/pfDev.sh dev, port 3000) proxies /rs/ and /rsPublic/ to Spring Boot (port 8080, or PF_BACKEND_URL). In production there is only Spring Boot, serving the static export.

2.3. Generated files and i18n

Some files are generated and must never be edited by hand:

  • projectforge-next/lib/metadata/*.generated.ts (field metadata of all entities),

  • projectforge-next/messages/generated.*.json (translations for the frontend),

  • projectforge-application/src/main/resources/i18nKeys.json (usage of the i18n keys),

  • the changelog pages of this website and of the app (from changelog/changelog{,.de}.json).

All of them are produced by bin/pfDev.sh gen (the Gradle task :projectforge-application:developmentMainForRelease). Tests fail if a generated file differs from what the generator produces, so run it after changing an entity or a text.

The source of truth of all texts is projectforge-business/src/main/resources/I18nResources.properties (English) and I18nResources_de.properties (German); plugins have their own bundles. New texts go there, not into messages/*.json. Keys named literally in the frontend sources or referenced by the entity metadata are exported automatically; keys built at runtime need a prefix entry in PREFIXES of GenerateNextI18nMessagesMain. The hand-written messages/de.json and messages/en.json only hold the few frontend texts without a backend counterpart.

3. Backend

Java is the language used since the very beginning of ProjectForge in 2001. Since ProjectForge 7 Kotlin is used as well, and new code is written in Kotlin.

3.1. Why Kotlin in addition to Java?

  • Smarter and less code, especially for entities and data transfer objects.

  • Smart operations on collections (map, find, sort etc.).

  • Immutable objects and values where possible.

  • Null safety.

  • Full interoperability with the existing Java code.

Java and Kotlin sources are both compiled for Java 17 (jvmTarget = 17, see buildSrc/src/main/kotlin/buildlogic.pf-module-conventions.gradle.kts); the application runs on Java 17 or newer. Micromata runs ProjectForge in production on Java 21, the Docker image is based on Eclipse Temurin 21.

3.2. Entities (BaseDO)

Entities are the objects of ProjectForge stored in the database. By default every modification of an entity is recorded in a history with the information about the modification (user, timestamp, old and new value).

BookDO.kt (excerpt)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Entity
@Indexed
@Table(name = "T_BOOK", ...)
open class BookDO : DefaultBaseDO(), DisplayNameCapable, AttachmentsInfo {

    @PropertyInfo(i18nKey = "book.title", required = true)
    @FullTextField
    @get:Column(length = 255)
    open var title: String? = null

    @PropertyInfo(i18nKey = "book.authors")
    @FullTextField
    @get:Column(length = 1000)
    open var authors: String? = null
    ...
}

Entity classes and all their properties must be declared open. Otherwise JPA/Hibernate isn’t able to proxy these objects and lazy loading isn’t supported.

@PropertyInfo

The i18nKey is used for the translated field label (in lists, forms, exports etc.). An optional additionalI18nKey adds a second label, e. g. "business" for one of several phone numbers; required = true marks a mandatory field.

@FullTextField, @GenericField, @KeywordField

Hibernate Search annotations: the field is indexed and available for the full text search and for field-specific search values.

@get:Column(length = 255)

JPA annotations. Their information (e. g. the length) is available as metadata and ends up in the generated field metadata of the frontend, e. g. as maxLength of an input field.

Property type

The property type is also part of the metadata, so the frontend picks the matching input (text, date picker, user selector, drop down for enums etc.).

Enumerations implementing I18nEnum (e. g. BookStatus) provide an i18n key per value, so they are shown as drop down fields with translated labels.

Database changes are made by Flyway migrations, see Flyway migrations.

3.3. Data access objects (BaseDao)

The BaseDao classes provide all CRUD operations for the entities and handle the access rights: no user is able to select or modify entities without the required rights.

An entity’s DAO (e. g. BookDao) extends BaseDao and defines the access rights and special functionality. The base functionality including access checks, history and search is inherited.

4. REST API

All CRUD operations and much more are available through the REST API under /rs, which is used by the frontend. The access rights of the logged-in user are checked for every call; writing calls need the CSRF token of the session (header X-PF-CSRF-Token, provided by GET /rs/userStatus).

4.1. Entity endpoints

Every *EntityRest inherits the following endpoints from AbstractEntityRest (example: books under /rs/book):

Call Description

GET rs/book/listMeta

Everything a list page needs beside its rows: the filter fields of the entity, the current filter of the user and the saved filter favorites.

POST rs/book/list

The result set for the posted filter (MagicFilter, see below).

POST rs/book/listPage

One page of the result set, for lists paged on the server.

GET rs/book/{id}

The entity with the given id.

GET rs/book/newEntry

The preset of a new entity (default values).

PUT rs/book/saveorupdate

Inserts or updates the posted entity. Validation errors are returned with HTTP status 406.

DELETE rs/book/markAsDeleted, PUT rs/book/undelete, DELETE rs/book/delete

Marks a historizable entity as deleted, restores it, or deletes a non-historizable entity.

DELETE rs/book/forceDelete

Deletes the entity irrevocably including its history (e. g. for privacy protection), if the DAO supports it.

GET rs/book/history/{id}

The history of changes of the given entity.

GET rs/book/autocomplete?property={property}&search={search}

Autocompletion of values already used for a property (e. g. locations of time sheets).

GET rs/book/autosearch?search={search}

Full text search for entities matching the search string.

rs/book/filter/…​

Creating, selecting, renaming, updating, deleting and resetting filter favorites.

GET rs/book/reindexFull, GET rs/book/reindexNewest

Rebuilds the search index of the entity.

Further services of an entity (e. g. lending out a book) are separate REST classes, such as BookServicesRest.

4.2. Magic filter

Lists are queried with a MagicFilter: a full text search string plus optional field-specific entries.

MagicFilter (example)
1
2
3
4
5
6
7
8
{
  "searchString": "kotlin",
  "entries": [
    { "field": "title", "value": { "value": "java" } },
    { "field": "yearOfPublishing", "value": { "from": "2010" } },
    { "field": "status", "value": { "values": ["PRESENT", "MISSED"] } }
  ]
}

searchString

Full text search over all indexed fields.

"value": { "value": …​ }

Search value for the given field.

"value": { "from": …​, "to": …​ }

Range search (both bounds are optional).

"value": { "values": […​] }

Entries whose field has one of the given values.

The filter last used is stored per user and list; named filters can be saved as favorites.

4.3. Entities vs. DTOs

For simple entities the entity itself may be used by the REST layer. Usually a DTO (data transfer object) is used, e. g. if the object embeds other entities (users, tasks etc.) or needs special handling (attachments, images). The base class BaseDTO copies the properties between DTO and entity; the *EntityRest implements transformForDB and transformFromDB. Examples: Book.kt, User.kt.

5. Adding a list and edit page

The books are the reference implementation of a hand-built page. What it takes:

File Purpose

BookDO.kt

The entity, including @PropertyInfo, JPA and search annotations.

BookDao.java

Access rights and special functionality.

Book.kt

The DTO.

BookEntityRest.kt

The REST endpoints (extends AbstractDTOEntityRest): defaults of a new book, DTO transformation, validation.

Flyway migration

The table of a new entity, see Flyway migrations.

I18nResources.properties, I18nResources_de.properties

The labels and texts.

projectforge-next/lib/metadata/book.generated.ts

Generated field metadata, created by bin/pfDev.sh gen.

projectforge-next/components/features/book/book.page.tsx

The page declaration (definePage()): list columns, edit sections, actions.

projectforge-next/components/features/book/edit/

The Zod schema and form values of the edit form, custom sections.

projectforge-next/app/(authenticated)/book/page.tsx, book/[id]/page.tsx

The routes of the list and the edit page (/next/book, /next/book/<id>, /next/book/new); they only render EntityListPage and EntityEditPage with the declaration.

MenuItemDefId, NextMigration (projectforge-business)

The menu entry and the routes of the page.

Steps in short:

  1. Write the entity, the DAO, the DTO and the *EntityRest, and add the Flyway migration.

  2. Add the texts to I18nResources.properties and I18nResources_de.properties.

  3. Run bin/pfDev.sh gen to generate the field metadata and the translations of the frontend.

  4. Declare the page in components/features/<entity>/<entity>.page.tsx and add the two route files.

  5. Register the menu entry and the routes in the backend.

  6. Check the frontend with bin/pfDev.sh check (TypeScript, ESLint, Prettier) and add tests (see Tests).

The declaration format is described in projectforge-next/docs/page-declarations.md. Pages that don’t fit the declaration use the hooks useEntityListPage and useEntityEditForm directly or are hand-built completely.

6. Writing own plugins

The built-in plugins live in plugins/ as Gradle modules (e. g. org.projectforge.plugins.skillmatrix or org.projectforge.plugins.licensemanagement) and are included in the application. A plugin consists of:

  • a class extending AbstractPlugin, registered in src/main/resources/META-INF/services/org.projectforge.plugins.core.AbstractPlugin (Java ServiceLoader), which registers DAOs, menu entries, access rights and i18n bundles in initialize(),

  • its entities, DAOs and REST classes, built like the core entities above,

  • its own i18n bundles (e. g. SkillMatrixI18nResources.properties with _de and _en variants),

  • its frontend pages in projectforge-next (components/features/<plugin>/ and the routes in app/(authenticated)/), if it has been migrated.

A new plugin module has to be added to settings.gradle.kts and as a dependency to projectforge-application/build.gradle.kts. Administrators activate plugins in ProjectForge under Administration → Plugins (see Installation); projectforge.plugins.active=all activates all plugins, e. g. on test instances.

7. Tests

  • Backend: JUnit Jupiter (JUnit 6), Mockito and mockito-kotlin. The tests run on HSQLDB, e. g. ./gradlew test --tests "org.projectforge.business.book.*" or ./gradlew :projectforge-business:test.

  • Frontend unit tests: Vitest, npm test in projectforge-next/.

  • End-to-end tests: Playwright, in projectforge-next/e2e/. bin/pfDev.sh e2e runs them against Spring Boot; it refreshes the static export first (:projectforge-application:processResources, a no-op if current). bin/pfDev.sh e2e --dev runs them against the dev server, bin/pfDev.sh e2e:ui in Playwright’s UI mode. A running instance in development mode (projectforge.development.mode=true) creates its test accounts itself and writes their credentials into testAccounts.txt of its home directory. Details are in projectforge-next/CLAUDE.md.

Gradle runs of the same checkout share their build directory: a build rewrites the jar of a running bootRun of that checkout, and two parallel test runs disturb each other.

8. Release workflow

8.1. Flyway migrations

Database changes are Flyway migrations in projectforge-business/src/main/resources/flyway/migrate/common (or under the same version in both postgresql and hsqldb, if the SQL differs). They are versioned by the release that ships them, always with four segments: V<release>.<n>RELEASE-<Name>.sql, e. g. V9.0.4.1RELEASE-Script_page_targets.sql. The release is the version in gradle.properties (without -SNAPSHOT) at merge time.

A migration must never be lower than one already shipped: if a branch misses its planned release, its scripts are renamed to the next release before merging.

8.2. Changelog

The changelog is maintained in changelog/changelog.json (English) and changelog/changelog.de.json (German). A change in a branch doesn’t touch these files but gets its own file changelog/unreleased/yyyyMMdd-<slug>.json with English and German text (see changelog/unreleased/README.md). bin/pfDev.sh release moves them into the release entry; bin/pfDev.sh gen generates the changelog pages of the website and of the app.

9. Misc

9.1. Code style

Most of the existing code follows the IntelliJ code style in misc/IntelliJ/CodeStyle.xml (indent 2, continuation indent 4); newer Kotlin code uses 4-space indentation. All source files carry the ProjectForge license header. The frontend is formatted with Prettier and checked with ESLint (bin/pfDev.sh check).

9.2. Working with different databases for testing

A psql client against a PostgreSQL container named projectforge-postgres (the client image only needs to be a recent PostgreSQL version, it doesn’t have to match the server’s):

docker run -e PGPASSWORD=$PGPASSWORD -it --rm --link projectforge-postgres:postgres postgres:16 psql -h postgres -U projectforge
create user pf2 password 'secret';
CREATE DATABASE pf2;
GRANT ALL PRIVILEGES ON DATABASE pf2 TO pf2;

For a quick test system without PostgreSQL, use a slot instance with HSQLDB and test data (see below).

9.3. Several instances side by side (bin/pfDev.sh)

bin/pfDev.sh builds and starts the backend, the Next.js dev server and the e2e tests from any working directory (bin/pfDev.sh help lists all commands). Further commands: gen (generated files, see Generated files and i18n), check (TypeScript, ESLint, Prettier of the frontend), build (next build), e2e:ui (e2e tests in Playwright’s UI mode), ps (list the running servers of all slots), stop [slot], changelog-fold [id] (moves changelog/unreleased/ into a release entry, done by release itself) and release/publish (release workflow).

A slot number (1–9) as the first argument after the command runs a complete, independent stack beside the main instance, e.g. one per git worktree or branch:

Slot Spring next dev Home dir Database Session cookie

none (main instance)

8080

3000

~/ProjectForge (or $PROJECTFORGE_HOME)

as configured there

JSESSIONID

n

8080+n

3000+n

~/ProjectForge-n

own HSQLDB with test data

JSESSIONID_n

Main instance, unchanged:

bin/pfDev.sh run            # build, then Spring on :8080
bin/pfDev.sh dev            # next dev on :3000, proxying to :8080
bin/pfDev.sh dev -p 3005    # next dev on :3005, proxying to :8080
bin/pfDev.sh e2e            # e2e tests against :8080 (--dev: against the dev server on :3000)

A slot instance, e.g. slot 1:

bin/pfDev.sh setup 1        # once: creates ~/ProjectForge-1 (optional 2nd arg: admin password)
bin/pfDev.sh run 1          # Spring on :8081; the first start fills the database with test data
                            # and prints the admin login
bin/pfDev.sh dev 1          # next dev on :3001, proxying to :8081
bin/pfDev.sh e2e 1 login    # e2e tests against :8081 (--dev: against :3001)
bin/pfDev.sh reset 1        # deletes ~/ProjectForge-1 (asks first), then `setup 1` again

Notes:

  • setup needs the projectforge.properties of the main home dir (~/ProjectForge). It copies projectforge.properties, config.xml (if not yet migrated), resources and styles from there and appends a block that switches to an own HSQLDB in ~/ProjectForge-n/database and turns off sending mails and the gateway push. Database, search index and JCR of the main instance are not copied.

  • Port, domain, base dir and cookie name are passed by pfDev.sh as system properties, so the main instance’s settings stay untouched. A slot start doesn’t change the remembered default home dir either.

  • The cookie name differs per slot because browsers keep cookies per host, not per port: with the same name, a login on :8081 would end the session on :8080.

  • The test accounts (testAccounts.txt, see projectforge-next/CLAUDE.md) are written into the slot’s home dir; pfDev.sh e2e n reads them from there.

  • A slot runs on HSQLDB with test data. Bugs that need real data or PostgreSQL stay with the main instance.

  • Gradle rebuilds the jar of its own checkout, which breaks a bootRun running from that checkout — one slot per worktree avoids that.

Without pfDev.sh, npm run dev (in projectforge-next/) takes the port via -p and the backend via PF_BACKEND_URL (default localhost:8080, the main instance):

npm run dev -- -p 3005                                        # :3005 against the main instance
PF_BACKEND_URL=http://localhost:8081 npm run dev -- -p 3001   # :3001 against slot 1

9.4. Knowledge graph (graphify)

Optional developer tooling: graphify builds a local knowledge graph of the code base in graphify-out/, which AI coding assistants query instead of grepping the whole tree. Both the tool and the graph are local and untracked — without them the repository behaves exactly as before.

Install it per checkout into a project-local virtual environment (never globally):

python3 -m venv venv
./venv/bin/pip install -q graphifyy
./venv/bin/graphify update .

Note the names: the PyPI package is graphifyy, the command line tool is graphify. Building the graph with graphify update . is purely AST based — no LLM, no API key, no cost. If you prefer uv or pipx, those work as well, but plain venv plus pip is the documented default.

The virtual environment must be created inside each checkout: venv/bin/graphify contains an absolute shebang path, so a venv from another ProjectForge working copy cannot be reused, and every checkout needs its own graph anyway.

Everyday use:

./venv/bin/graphify query "How does the order book edit page load its data?"
./venv/bin/graphify explain "AbstractEntityRest"
./venv/bin/graphify path "AbstractEntityRest" "UILayout"
./venv/bin/graphify update .          # after code changes

Optionally install a post-commit hook that re-extracts changed code files and rebuilds the graph after every commit. It appends to an existing post-commit hook instead of replacing it:

./venv/bin/graphify hook install

graphify-out, venv and .venv are already covered by .gitignore, and the root CLAUDE.md already contains the matching ## graphify section, so graphify claude install is not needed.