The post has been translated automatically. Original language: Russian
What is this article about?
We are developing an enterprise Learning Management System (LMS), a platform where managers download training courses in the SCORM format, and employees take them through the browser. It sounds simple, but this phrase hides several non-trivial architectural solutions.
There are three parts to this article and we'll talk about three things.:
1. What is SCORM and why did we decide to support it ourselves rather than buy a ready-made solution?
2. How we organized the package structure in Spring Boot — by features, not by layers
3. How the project was divided into backoffice and frontoffice inside one mono-repo.
Stack: Java 21, Spring Boot 3.5, PostgreSQL, Minio (S3), Keycloak, Gradle.
SCORM — what is it and why raise it yourself
SCORM (Sharable Content Object Reference Model) is a standard for packaging educational content. In short: the course is a ZIP archive with HTML, JS, media files and a special manifesto `imsmanifest.xml `. This manifesto describes the structure of the course: which modules are available, in what order to complete them, which file to run first.
When the user opens the course, the browser downloads HTML from the archive. Inside this HTML is JavaScript that communicates with the LMS via a special API (`SCORM 1.2` uses an `API` object in the parent window, `SCORM 2004` is `API_1484_11`). Through this API, the course informs the LMS:
- that the lesson has started ('LMSInitialize')
- what is the user's progress ('cmi.core.lesson_location')
- is the lesson completed (`cmi.core.lesson_status')
- what score was obtained ('cmi.core.score.raw`)
In other words, LMS is not just a file storage. This is an active participant in the learning process, who must accept events from the course and save the user's state.
Why not SaaS
Ready-made LMS platforms (Moodle, iSpring Learn, TalentLMS) support SCORM out of the box. Why didn't we take one of them?
-Integration with our ecosystem. We already have Keycloak, our own user system, and our own access rights model. Embedding a third—party LMS in this environment is a pain.
-Data control. All user progress data should be stored in our infrastructure.
-Custom UX. We want to fully control the interface: how the catalog looks, how the course is launched, and how progress is displayed.
- The cost. SaaS solutions with the level of customization we need are expensive to scale.
How did we implement it?
Course upload (backoffice):
The manager downloads the ZIP archive via the UI. Backend:
1. Accepts the file via `multipart/form-data`
2. Unpacks the archive and parses the imsmanifest.xml`
3. Uploads all files to Minio (S3-compatible storage) in a folder like `courses/{CourseID}/`
4. Saves course metadata in PostgreSQL: name, entry point (`index.html ` or as specified in the manifest), the structure of the modules
@Service
public class ScormImportService {
private final S3Client s3Client;
private final CourseRepository courseRepository;
private final ScormManifestParser manifestParser;
public Course importScormPackage(MultipartFile zipFile) {
Path tempDir = extractZip(zipFile);
ScormManifest manifest = manifestParser.parse(tempDir.resolve("imsmanifest.xml"));
uploadToS3(tempDir, manifest.getCourseId());
Course course = new Course();
course.setTitle(manifest.getTitle());
course.setEntryPoint(manifest.getLaunchUrl());
course.setScormVersion(manifest.getVersion());
return courseRepository.save(course);
}
}Course launch (frontoffice):
When the user opens the course, the frontend receives a pressigned URL to the entry point from Minio and uploads the course to an `iframe`. In the parent window, we register the SCORM API object.:
// Simplified
window.API = {
LMSInitialize: () => { /* POST /api/v1/front-office/scorm-sessions/start */ },
LMSSetValue: (element, value) => { /* POST /api/v1/front-office/scorm-sessions/set-value */ },
LMSGetValue: (element) => { /* GET /api/v1/front-office/scorm-sessions/get-value */ },
LMSFinish: () => { /* POST /api/v1/front-office/scorm-sessions/finish */ }
}The backend accepts these calls and saves the user's session status — progress, status, scores — to a separate `scorm_interactions` table.
Which turned out to be more difficult than expected:
-Versions of SCORM. 1.2 and 2004 are two different APIs with different method names and data structure. I had to make an adapter.
-Presigned URLs and CORS. The files in Minio must be accessible to the browser directly, and CORS must be configured correctly, otherwise <iframe> will not load the resources.
- The state between sessions. The user closed the tab in the middle of the course, and the data should be saved. SCORM does not always cause `LMS Finish' (especially when the tabs are painted), so we added a heartbeat mechanism.
О чём эта статья
Мы разрабатываем корпоративную систему управления обучением (LMS) — платформу, где менеджеры загружают учебные курсы в формате SCORM, а сотрудники проходят их в браузере. Звучит просто, но за этой фразой скрывается несколько нетривиальных архитектурных решений.
В этой статье есть три части и расскажем о трёх вещах:
1. Что такое SCORM и почему мы решили поддержать его сами, а не купить готовое решение
2. Как мы организовали структуру пакетов в Spring Boot — по фичам, а не по слоям
3. Как разделили проект на backoffice и frontoffice внутри одного монорепо.
Стек: Java 21, Spring Boot 3.5, PostgreSQL, Minio (S3), Keycloak, Gradle.
SCORM — что это и зачем его поднимать самому
SCORM (Sharable Content Object Reference Model) — это стандарт упаковки учебного контента. Если коротко: курс — это ZIP-архив с HTML, JS, медиафайлами и специальным манифестом `imsmanifest.xml`. Этот манифест описывает структуру курса: какие модули есть, в каком порядке их проходить, какой файл запускать первым.
Когда пользователь открывает курс, браузер загружает HTML из архива. Внутри этого HTML — JavaScript, который общается с LMS через специальный API (`SCORM 1.2` использует `API` объект в родительском окне, `SCORM 2004` — `API_1484_11`). Через этот API курс сообщает LMS:
- что урок начат (`LMSInitialize`)
- какой прогресс у пользователя (`cmi.core.lesson_location`)
- завершён ли урок (`cmi.core.lesson_status`)
- какой получен балл (`cmi.core.score.raw`)
Иными словами, LMS — это не просто хранилище файлов. Это активный участник процесса обучения, который должен принимать события от курса и сохранять состояние пользователя.
Почему не SaaS
Готовые LMS-платформы (Moodle, iSpring Learn, TalentLMS) поддерживают SCORM из коробки. Почему мы не взяли одну из них?
-Интеграция с нашей экосистемой. У нас уже есть Keycloak, своя система пользователей, своя модель прав доступа. Встраивать стороннюю LMS в это окружение — боль.
-Контроль над данными. Все данные о прогрессе пользователей должны храниться в нашей инфраструктуре.
-Кастомный UX. Мы хотим полностью контролировать интерфейс: как выглядит каталог, как запускается курс, как отображается прогресс.
-Стоимость. SaaS-решения с нужным нам уровнем кастомизации стоят дорого при масштабировании.
Как мы это реализовали
Загрузка курса (backoffice):
Менеджер загружает ZIP-архив через UI. Бэкенд:
1. Принимает файл через `multipart/form-data`
2. Распаковывает архив и парсит `imsmanifest.xml`
3. Загружает все файлы в Minio (S3-совместимое хранилище) в папку вида `courses/{courseId}/`
4. Сохраняет метаданные курса в PostgreSQL: название, точку входа (`index.html` или что указано в манифесте), структуру модулей
@Service
public class ScormImportService {
private final S3Client s3Client;
private final CourseRepository courseRepository;
private final ScormManifestParser manifestParser;
public Course importScormPackage(MultipartFile zipFile) {
Path tempDir = extractZip(zipFile);
ScormManifest manifest = manifestParser.parse(tempDir.resolve("imsmanifest.xml"));
uploadToS3(tempDir, manifest.getCourseId());
Course course = new Course();
course.setTitle(manifest.getTitle());
course.setEntryPoint(manifest.getLaunchUrl());
course.setScormVersion(manifest.getVersion());
return courseRepository.save(course);
}
}Запуск курса (frontoffice):
Когда пользователь открывает курс, фронтенд получает presigned URL на точку входа из Minio и загружает курс в `<iframe>`. В родительском окне мы регистрируем SCORM API объект:
// Упрощённо
window.API = {
LMSInitialize: () => { /* POST /api/v1/front-office/scorm-sessions/start */ },
LMSSetValue: (element, value) => { /* POST /api/v1/front-office/scorm-sessions/set-value */ },
LMSGetValue: (element) => { /* GET /api/v1/front-office/scorm-sessions/get-value */ },
LMSFinish: () => { /* POST /api/v1/front-office/scorm-sessions/finish */ }
}Бэкенд принимает эти вызовы и сохраняет состояние сессии пользователя — прогресс, статус, баллы — в отдельную таблицу `scorm_interactions`.
Что оказалось сложнее, чем ожидалось:
-Версии SCORM. 1.2 и 2004 — это два разных API с разными именами методов и структурой данных. Пришлось делать адаптер.
-Presigned URLs и CORS. Файлы в Minio должны быть доступны браузеру напрямую, при этом CORS должен быть настроен правильно, иначе `<iframe>` не загрузит ресурсы.
- Состояние между сессиями. Пользователь закрыл вкладку на середине курса — данные должны сохраниться. SCORM вызывает `LMSFinish` не всегда (особенно при краше вкладки), поэтому добавили heartbeat-механизм.