How learn-dev is structured and how a request flows through it. This file answers "how do the pieces fit together, and why is it built this way?" For the list of tools and versions see docs/tech-stacks.md; for term definitions see GLOSSARY.md; for individual decisions and their trade-offs see the ADRs.
learn-dev is a server-rendered, layered Spring Boot monolith. The browser talks to Spring MVC controllers; controllers delegate to services; services use Spring Data JPA repositories over a PostgreSQL relational core. HTML is produced server-side by Thymeleaf. The app is a monolith today, with a longer-term intent to split selected concerns into microservices (which is why service-to-service auth is already being considered in ADR-0002).
-
Server-rendered MVC. Controllers return logical view names, not HTML or JSON; a Thymeleaf
ViewResolverrenders the corresponding template. -
Layered. Each layer depends only on the one below it:
Browser │ HTTP (form posts, GETs) ▼ Controller (web layer: @Controller, request mapping, validation) │ calls ▼ Service (business logic, @Transactional boundaries) │ calls ▼ Repository (Spring Data JPA interfaces) │ SQL via Hibernate ▼ PostgreSQL (relational core)
Packages are organised by feature, not by technical layer, so a feature's controller, service, entity, and repository live together:
com.ericbouchut.learndev
├── audit # AuditService, entity/AuditLog: security audit trail (audit_logs)
├── auth # AuthController, RegistrationService, CustomUserDetailsService,
│ # PasswordResetController/Service/Mailer, entity/PasswordResetToken,
│ # dto/*Form, exception/Duplicate*Exception
├── course # entity/Course, Lesson, Enrollment (+EnrollmentId,
│ # EnrollmentStatus, PublicationStatus), repository/*,
│ # MarkdownRenderer (lesson Markdown to sanitized, cached HTML)
├── legal # LegalController (privacy policy page)
├── user # entity/User, repository/UserRepository
├── role # entity/Role, repository/RoleRepository
├── common
│ └── config # SecurityConfig (filter chain, PasswordEncoder)
└── (test) support # AbstractPostgresIT (shared Testcontainers base)
- A controller method (for example
AuthController.home()) returns a view name such as"home"(a lookup key, not HTML). - The Thymeleaf
ViewResolvermaps the name tosrc/main/resources/templates/home.html. - The template engine renders the HTML, evaluating
th:*attributes, escaping output (XSS defence), and injecting the CSRF token into forms that useth:action. - The
DispatcherServletwrites the HTML as the response body with the appropriate headers and status.
A method may instead return a redirect: prefix (for example
redirect:/auth/login?registered), which produces a 302 rather than rendering a view.
- Session-based form login. Credentials are verified once; the session is kept
server-side and referenced by the
JSESSIONIDcookie (see ADR-0001). JWT was rejected for the browser flow. - Filter chain.
SecurityConfigdefines theSecurityFilterChain: public paths (/,/auth/**, static assets) are permitted; everything else requires authentication. Auth endpoints are grouped under the/auth/URL prefix. - User loading.
CustomUserDetailsServiceloads aUserby username and maps eachRoleto a Spring Security authority prefixed withROLE_(soADMINbecomesROLE_ADMIN). Account flags map todisabled(is_active) andaccountLocked(is_locked). - Passwords. Hashed with BCrypt; the raw password is never persisted.
- CSRF. Enabled by default; Thymeleaf injects a per-form token.
SameSite=Laxon the session cookie adds browser-level defence in depth. - Session cookie hardening.
HttpOnlyandSameSite=Laxare set in the base config;Secureis enabled once served over HTTPS.
RegisterForm (validated with Bean Validation) → AuthController → RegistrationService
(checks unique username/email, hashes the password, assigns the default STUDENT
role, saves) → redirect to the login page. Duplicate username/email surface as
field errors on the re-rendered form.
ForgotPasswordForm → PasswordResetController → PasswordResetService. The
service answers with the same neutral confirmation whether or not the email
exists (no account enumeration), rate-limits requests per user and per IP,
stores only the SHA-256 hash of a 32-byte random token (single active
token per user, 30 minute TTL, configurable under learndev.password-reset.*),
and emails the raw link via PasswordResetMailer over SMTP — Mailpit in
development (see ADR-0004).
Consuming the link stores the new BCrypt hash, marks the token used,
invalidates any others, and records the outcome in audit_logs through
AuditService. The sequence diagram lives in
CONTRIBUTING.md.
- Relational core (PostgreSQL). Users, roles, password-reset tokens, the
audit trail, courses, lessons, and enrollments. Users use a UUID primary key
to avoid enumeration; other tables use
BIGINTidentity (see ADR-0003). - Document store (MongoDB). Provisioned and configured for future content storage; not yet used by any feature.
- Schema evolution. Managed by Liquibase, run at startup. Migrations are hand-written formatted-SQL files, one atomic changeset per file, append-only (see ADR-0005).
- Modelling. The schema is designed in Merise (MCD → MLD → MPD) and cross-checked against the migrations by a schema-drift CI job.
- Profiles.
application.yamlholds base config;application-dev.yamlholds dev overrides.SPRING_PROFILES_ACTIVE=devselects the profile and the Liquibasedevcontext. - Secrets. Loaded from
./.env(a FIFO filled by an external secrets manager) via spring-dotenv at startup, so the working directory must be the project root.
Tests form a pyramid, all run under Surefire in mvn test
(see ADR-0009):
- Unit tests — mock collaborators (Mockito), no I/O
(
RegistrationServiceTest,CustomUserDetailsServiceTest). - Slice tests —
@DataJpaTestagainst a real Postgres container (UserRepositoryTest,RoleRepositoryTest). - Integration test — full context + MockMvc for the end-to-end auth journey
(
AuthFlowTest). - Smoke test — verifies the context starts (
LearnDevApplicationTests).
Tests use a real PostgreSQL via Testcontainers rather than H2 (see ADR-0006), shared as a static singleton container (see ADR-0008).
make test— run the suite (Podman-aware Testcontainers wiring).make run— start the databases and run the app (http://localhost:8080/).docker compose up -d— start Postgres, Mongo, and Mailpit (dockeris Podman here). Mailpit's web UI (caught emails) is athttp://localhost:8025.
- The course web layer on top of the shipped domain: student catalogue, enrollment and lesson reading, instructor authoring and rosters, admin account and content lifecycle (see docs/plans/2026-07-12-course-management-by-role.md).
- Possible extraction of microservices, with service-to-service authentication (ADR-0002).
- A
SUPERADMINrole (deferred under YAGNI; issue #65).