| name | openolat-dev |
| description | Use this skill when developing OpenOlat features, fixing bugs, or writing new controllers, services, forms, or templates. Provides architecture knowledge, patterns, and conventions for the OpenOlat LMS codebase. |
| allowed-tools | Read, Grep, Glob, Bash(mvn *) |
OpenOlat Developer Assistant
You are an expert OpenOlat developer. Use the architecture knowledge below and the reference files to help developers write correct, idiomatic OpenOlat code.
For compressed architecture knowledge, read .claude/openolat-architecture-knowledge.md
For detailed architecture documentation, read: doc/openolat-architecture.md
Project Basics
- Build:
mvn compile -pl :openolat-lms -q
- Java 17+, Jakarta EE, Spring 7, Hibernate 7, Apache Velocity
- Naming: Always write "OpenOlat" in prose (not "OpenOLAT"). Java classes keep original casing.
- License: Apache 2.0. Developed by frentix GmbH, Zurich, Switzerland.
Architecture Overview
OpenOlat is a server-centric web framework. All UI state lives on the server. The browser receives HTML fragments via AJAX — there is no client-side framework (no React/Angular/Vue).
Key Layers (top to bottom)
- Presentation — Controllers, Components, Velocity templates
- Modules — Feature modules (course, question pool, portfolio, groups, etc.)
- Services — Business logic (
*Manager interfaces, Spring beans)
- Persistence — Hibernate/JPA DAOs,
DB facade, VFS
Request Lifecycle
HTTP Request → Servlet → Dispatcher → Window (synchronized)
→ Find Component by ID → Fire event to Controller
→ Controller executes business logic (DB, services)
→ Controller may fire events to parent controllers
→ Controller may fire MultiUserEvents to EventBus
→ Controller updates component tree (dirty flags)
→ RENDER PHASE (no more business logic, no DB by convention)
→ Dirty components rendered → JSON/HTML response
Controller Patterns
Controller Hierarchy
DefaultController (base, dispose lifecycle)
└─ BasicController (UI controller, Velocity rendering)
└─ FormBasicController (FlexiForm support)
└─ FormLayoutContainer (pure layout)
Creating a BasicController
public class MyController extends BasicController {
@Autowired private MyService myService;
public MyController(UserRequest ureq, WindowControl wControl) {
super(ureq, wControl);
VelocityContainer vc = createVelocityContainer("my_template");
Link btn = LinkFactory.createButton("save", vc, this);
putInitialPanel(vc);
}
@Override
protected void event(UserRequest ureq, Component source, Event event) {
if (source instanceof Link link && "save".equals(link.getCommand())) {
doSave(ureq);
}
}
}
Creating a FormBasicController
public class MyFormController extends FormBasicController {
private TextElement nameEl;
public MyFormController(UserRequest ureq, WindowControl wControl) {
super(ureq, wControl);
initForm(ureq);
}
@Override
protected void initForm(FormItemContainer layout, Controller listener, UserRequest ureq) {
nameEl = uifactory.addTextElement("name", "form.name", 255, "", layout);
nameEl.setMandatory(true);
uifactory.addFormSubmitButton("save", layout);
}
@Override
protected boolean validateFormLogic(UserRequest ureq) {
boolean ok = super.validateFormLogic(ureq);
nameEl.clearError();
if (!StringHelper.containsNonWhitespace(nameEl.getValue())) {
nameEl.setErrorKey("form.mandatory");
ok = false;
}
return ok;
}
@Override
protected void formOK(UserRequest ureq) {
String name = nameEl.getValue();
fireEvent(ureq, Event.DONE_EVENT);
}
}
Child Controller Lifecycle
detailCtrl = new DetailController(ureq, getWindowControl(), item);
listenTo(detailCtrl);
removeAsListenerAndDispose(detailCtrl);
detailCtrl = new DetailController(ureq, getWindowControl(), newItem);
listenTo(detailCtrl);
Handling Child Controller Events
@Override
protected void event(UserRequest ureq, Controller source, Event event) {
if (source == detailCtrl) {
if (event == Event.DONE_EVENT) {
} else if (event == Event.CANCELLED_EVENT) {
}
}
}
Event System
| Type | Scope | Example |
|---|
| Component → Controller | Same controller | Button click, link click |
| Controller → Parent | Parent via listenTo() | fireEvent(ureq, Event.DONE_EVENT) |
| Form events | FormBasicController | formOK(), formCancelled(), formInnerEvent() |
| Multi-user (EventBus) | Cross-session, cluster-wide | coordinatorManager.getCoordinator().getEventBus() |
EventBus pattern:
OLATResourceable ores = OresHelper.createOLATResourceableInstance("CourseModule", courseId);
coordinatorManager.getCoordinator().getEventBus().registerFor(this, ureq.getIdentity(), ores);
coordinatorManager.getCoordinator().getEventBus().fireEventToListenersOf(new MultiUserEvent("changed"), ores);
coordinatorManager.getCoordinator().getEventBus().deregisterFor(this, ores);
Velocity Templates
Templates in _content/ directory, colocated with the controller's package.
## Render a child component
$r.render("myComponent")
## Translate
$r.translate("my.key")
$r.translate("greeting", $userName)
## Escape user content (CRITICAL for XSS prevention)
$r.escapeHtml($userText)
## Conditional rendering
#if($showDetails)
<div>$r.render("detailPanel")</div>
#end
## Loop
#foreach($item in $items)
<div>$r.escapeHtml($item.name)</div>
#end
Important: $r.render() is safe (components handle escaping), but $myVar from contextPut() is NOT auto-escaped — always use $r.escapeHtml($myVar) for user-provided text.
FlexiTable
FlexiTableColumnModel columnsModel = FlexiTableDataModelFactory.createFlexiTableColumnModel();
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.name));
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel(Cols.date,
new DateFlexiCellRenderer(getLocale())));
columnsModel.addFlexiColumnModel(new DefaultFlexiColumnModel("edit",
translate("action.edit"), "edit"));
tableModel = new MyTableModel(columnsModel);
tableEl = uifactory.addTableElement(getWindowControl(), "table", tableModel, getTranslator(), layout);
tableEl.setSearchEnabled(true);
tableEl.setSelectAllEnable(true);
tableEl.setEmptyTableSettings("icon", "empty.message", null, "create.button");
Database Access
public MyEntity loadByKey(Long key) {
return dbInstance.getCurrentEntityManager().find(MyEntity.class, key);
}
public List<MyEntity> findByName(String name) {
return dbInstance.getCurrentEntityManager()
.createQuery("select e from MyEntity e where e.name = :name", MyEntity.class)
.setParameter("name", name)
.getResultList();
}
public void save(MyEntity entity) {
dbInstance.getCurrentEntityManager().persist(entity);
}
- Session-per-request: framework commits after dispatch
- Background jobs: must call
dbInstance.commitAndCloseSession() explicitly
- Bulk ops: use
dbInstance.intermediateCommit() every ~100 items
- All entities registered in
src/main/resources/META-INF/persistence.xml
Spring & Services
@Service
public class MyManagerImpl implements MyManager {
@Autowired private DB dbInstance;
}
MyManager mgr = CoreSpringFactory.getImpl(MyManager.class);
- Config defaults:
src/main/resources/serviceconfig/olat.properties
- Local overrides:
olat.local.properties
- Module pattern: extend
AbstractSpringModule for feature toggles
i18n (Internationalization)
- Files:
_i18n/LocalStrings_XX.properties colocated with UI package (Java .properties format)
- Fallback chain:
de_CH__customizing → de_CH → de__customizing → de → en__customizing → en (default) → en (fallback)
- Overlay: clients customize via
{userData}/customizing/lang/overlay/{package}/_i18n/LocalStrings_XX__customizing.properties
- In templates:
$r.translate("key"), $r.translate("key", $arg1)
- In Java:
translate("key") (in controllers), translate("key", new String[]{arg})
- Cross-referencing translations:
- Same-package:
$\:other.key — references another key in the same .properties file
- Cross-package:
$org.olat.other.package:other.key or ${org.olat.other.package:other.key}
- Recursive resolution up to 10 levels deep
- Fallback bundles:
org.olat.core (core), org.olat (application) — checked when key not found in primary bundle
- Parameter substitution:
{0}, {1} etc. via MessageFormat. Escape single quotes as ''
- Gender strategy:
Benutzer{in} → converted per locale config (star *, colon :, etc.)
- Core classes:
I18nModule (config), I18nManager (resolution/caching), PackageTranslator (per-controller)
- Glossary: Product-specific term definitions live in the docs repo at
OpenOLAT-docs/sites/manual_user/docs/general/glossary.md (EN) and glossary.de.md (DE). Translator-facing canonical term mappings (EN/DE/FR/ES/IT) live in doc/i18n-translation-reference.md in this repo.
- Alphabetical key order (mandatory): Keys in
LocalStrings_XX.properties files must be sorted alphabetically. When adding new keys, insert them at the correct alphabetical position. When modifying existing keys, keep them in place. Do not reorder existing keys unless explicitly told to do so.
- Wording (mandatory): Always use the same terms as the OpenOlat application UI. The running product is the source of truth. If multiple terms exist for the same concept, ask the user.
- Translation-reference-driven translation (mandatory): When translating i18n strings between languages, always read
doc/i18n-translation-reference.md first. Every term listed there must be translated exactly as defined. If existing translations use different words, flag the inconsistency to the user and offer to fix it.
- DE is the base language. When new terms appear that are not in the translation reference, ask the user to add the term and provide the base translations before proceeding.
- Glossary sync: When a glossary term changes, update both
OpenOLAT-docs/sites/manual_user/docs/general/glossary.md and glossary.de.md, and (if the term is in scope) the canonical mapping in doc/i18n-translation-reference.md.
VFS (Virtual File System)
Never access bcroot/ directly. Always use VFS classes:
VFSContainer folder = VFSManager.olatRootContainer("/course/" + courseId + "/files");
VFSLeaf file = folder.createChildLeaf("report.pdf");
VFSManager.copyContent(inputStream, file, identity);
New File Header (mandatory)
Every new Java file must include the standard license header and a class-level Javadoc with the initial date and @author tag. The username is mandatory; the email is optional. The author for AI-generated code is AI, ai@frentix.com, https://www.frentix.com.
package org.olat.modules.example;
import ...;
public class ExampleController extends BasicController {
Code Style Rules
- No pure whitespace changes: Never change invisible characters (spaces, tabs) on lines where the only modification is the whitespace itself. Whitespace may be changed on lines where actual code is also being modified. This keeps diffs clean and avoids unnecessary merge conflicts.
- No inline fully-qualified class names: Always import classes and use the simple name. Never write
java.io.File, java.util.HashMap<>(), org.olat.core.util.vfs.VFSLeaf etc. inline in method bodies. Add the import statement and use the class name only. The only exception is when two classes from different packages have the same name — in that case, use the FQN for the less-frequently-used one.
HTTP Client Service
All outbound HTTP requests must use HttpClientService (org.olat.core.util.httpclient.HttpClientService). Never use java.net.http.HttpClient, other HTTP client libraries, or instantiate Apache HttpClient directly. The service provides centralized proxy configuration, standardized timeouts, and frees the DB connection before outbound calls.
@Autowired
private HttpClientService httpClientService;
HttpClientService httpClientService = CoreSpringFactory.getImpl(HttpClientService.class);
try (CloseableHttpClient httpClient = httpClientService.createHttpClient()) {
HttpGet request = new HttpGet("https://api.example.com/data");
try (CloseableHttpResponse response = httpClient.execute(request)) {
}
}
try (CloseableHttpClient httpClient = httpClientService.createThreadSafeHttpClient(true)) {
}
Methods: createHttpClient(), createHttpClientBuilder(), createThreadSafeHttpClient(redirect), plus variants with (host, port, user, password) for basic auth.
Security Checklist
- XSS: Use
$r.escapeHtml() for all user text in templates. $r.render() is safe.
- CSRF:
Form generates tokens automatically. Always use FormBasicController for forms.
- SQL injection: Always use JPQL parameters (
:paramName), never string concatenation.
- Sanitize HTML: Use
OWASPAntiSamyXSSFilter for rich-text content.
- Velocity SSTI: Never pass user input as Velocity template content.
Disposal Checklist
Override doDispose() when your controller:
- Registers EventBus listeners →
eventBus.deregisterFor(this, ores)
- Acquires locks →
locker.releaseLock(lockEntry)
- Holds heavy references → set to
null
You do NOT need to manually dispose:
- Child controllers registered via
listenTo() (automatic)
- Mappers registered via
registerMapper() (automatic)
- Disposable form items (automatic)
Common Patterns
- Breadcrumb navigation:
BreadcrumbedStackedPanel for drill-down views
- Modal dialogs:
wControl.pushAsModalDialog(component)
- Callouts:
wControl.pushAsCallout(component)
- Info/error messages:
showInfo("key"), showError("key")
- Module toggles:
AbstractSpringModule with isEnabled() and persisted config
- Toolbar actions:
toolbarPanel.addTool(link) for create/export/import buttons
- Security callbacks: Pass permission objects to controllers, don't check roles inline
Writing Upgrades (org.olat.upgrade)
For data migrations between versions, create an upgrade class:
public class OLATUpgrade_20_4_0 extends OLATUpgrade {
private static final String VERSION = "OLAT_20.4.0";
private static final String MIGRATE_DATA = "MIGRATE DATA";
@Autowired
private MyService myService;
@Override
public String getVersion() { return VERSION; }
@Override
public boolean doPostSystemInitUpgrade(UpgradeManager upgradeManager) {
UpgradeHistoryData uhd = upgradeManager.getUpgradesHistory(VERSION);
if (uhd == null) {
uhd = new UpgradeHistoryData();
} else if (uhd.isInstallationComplete()) {
return false;
}
boolean allOk = true;
allOk &= migrateData(upgradeManager, uhd);
uhd.setInstallationComplete(allOk);
upgradeManager.setUpgradesHistory(uhd, VERSION);
return allOk;
}
private boolean migrateData(UpgradeManager upgradeManager, UpgradeHistoryData uhd) {
if (uhd.getBooleanDataValue(MIGRATE_DATA)) {
return true;
}
uhd.setBooleanDataValue(MIGRATE_DATA, true);
upgradeManager.setUpgradesHistory(uhd, VERSION);
return true;
}
}
Register in org/olat/upgrade/_spring/upgradeContext.xml (append to the list). For SQL schema changes, add ALTER scripts to /database/mysql/ and /database/postgresql/ and register in databaseUpgradeContext.xml.
Important: Upgrades run after all modules are initialized. Changes to AbstractSpringModule configs may need module re-initialization since the module's init() has already executed.
Testing
| Level | Base Class | What it tests |
|---|
| Unit | Plain JUnit | Pure logic, no Spring |
| Integration | OlatTestCase | Spring context + DB with rollback |
| REST API | OlatRestTestCase | Full HTTP stack |
| Selenium | @RunWith(Arquillian.class) | Browser UI tests |
- Test CSS selectors: prefixed with
o_sel_ for automation
- Test helper:
JunitTestHelper for creating users, courses, fixtures
- Selenium helper:
OOGraphene for wait helpers (waitBusy(), waitElement())