11 KiB
Familienarchiv — Architecture
Target reader: a PM-with-CS background who has read the README. Goal: accurate mental model after one read — enough to sketch the system on a whiteboard.
For domain terminology, see docs/GLOSSARY.md. For security policies and hardening, see docs/security-guide.md. For low-level ADR details, see docs/adr/.
1. High-level diagram
The updated container diagram below shows all six deployable units and their communication paths.
See docs/architecture/c4-diagrams.md for the full C4 L1/L2/L3 diagrams (Mermaid, Gitea-rendered).
Key points not visible in the diagram:
- OCR network boundary: the OCR service has no external port — it is reachable only on the internal Docker Compose network. Only the backend calls it. The OCR service fetches PDF files from MinIO using a presigned URL that the backend generates and passes in the request body; the PDF bytes never pass through the backend.
- SSE path: server-sent event notifications go directly from the backend to the user's browser (not via the SvelteKit SSR layer) over a long-lived HTTP connection managed by
SseEmitterRegistry.
2. Domain set
Both stacks are organised package-by-domain: each domain owns its entities, service, controller, repository, and DTOs. Domain names are identical across backend/src/main/java/.../ and frontend/src/lib/.
Tier-1 domains — have entities and user-facing CRUD
document — the archive's core concept. Owns Document, DocumentVersion, TranscriptionBlock, DocumentAnnotation, DocumentComment. Does NOT own persons or tags (references them by ID). Cross-domain deps: person (sender/receivers), tag (labels), ocr (HTR pipeline), notification (comment mentions), audit (every mutation).
person — historical individuals referenced by documents. Owns Person, PersonNameAlias, PersonRelationship. Does NOT own AppUser (login accounts are a separate domain). Cross-domain deps: document (relationship queries).
tag — hierarchical document categories. Owns Tag (self-referencing parent_id tree). Does NOT own documents; the join is document-side. No cross-domain deps.
user — login accounts and permission groups. Owns AppUser, UserGroup, invite tokens. Does NOT own Person records. Cross-domain deps: audit (user management events).
geschichte — family stories. Owns Geschichte (DRAFT → PUBLISHED lifecycle). Cross-domain deps: person, document (linked entities in the story body).
notification — in-app messages. Owns Notification. Delivers via SseEmitterRegistry (live) and persisted rows (bell dropdown). Cross-domain deps: user (recipient), document (context).
ocr — OCR/HTR pipeline orchestration. Owns OcrJob, OcrJobDocument, SenderModel. Calls the Python OCR service; maps streamed transcription blocks back to document. Cross-domain deps: document (target), filestorage (presigned URLs).
Tier-2 domains — derived (UI without dedicated tables)
A derived domain has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains.
conversation (route: /briefwechsel) — bilateral letter timeline between two Persons. Derived from Document sender/receiver relationships. The DocumentRepository bidirectional query is the only data source.
activity (route: /aktivitaeten) — family activity feed. Derived from audit_log, notifications, and document events. No aggregation table; computed on-the-fly by DashboardService and composed in the SvelteKit load function.
3. Cross-cutting layer
Members of the cross-cutting layer have no entity of their own, no user-facing CRUD, and are consumed by two or more domains — or are framework infrastructure that every domain depends on.
| Member (backend package) | Purpose | Admission criteria |
|---|---|---|
audit |
Append-only event store (audit_log) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. |
Consumed by 5+ domains; no user-facing CRUD of its own |
config |
Infrastructure bean definitions: MinioConfig, AsyncConfig, WebConfig |
Framework infra; no business logic |
dashboard |
Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
exception |
DomainException, ErrorCode enum, GlobalExceptionHandler |
Framework infra; consumed by every controller and service. Adding a new ErrorCode requires matching updates in frontend/src/lib/shared/errors.ts and all three messages/*.json locale files. |
filestorage |
FileService — MinIO/S3 upload, download, presigned-URL generation |
Generic service; consumed by document and ocr |
importing |
MassImportService — async ODS/Excel batch import |
Orchestrates across person, tag, document |
security |
SecurityConfig, Permission enum, @RequirePermission annotation, PermissionAspect (AOP) |
Framework infra; enforced globally across all controllers |
Frontend shared/ follows the same admission criteria. Key members: api.server.ts (typed openapi-fetch client factory), errors.ts (backend ErrorCode → i18n mapping), shared/primitives/ (generic UI components used across ≥2 domains), shared/discussion/ (comment/mention editor used by document and geschichte), shared/utils/ (pure date/sort/debounce utilities).
4. Stack-symmetry principle
Rule: a domain has the same name on both stacks.
| Backend | Frontend |
|---|---|
backend/src/main/java/.../document/ |
frontend/src/lib/document/ |
backend/src/main/java/.../person/ |
frontend/src/lib/person/ |
| … | … |
Adding a new Tier-1 domain means creating a package on both sides under the same name. Adding only a backend package without a corresponding frontend folder (or vice versa) is a red flag in code review.
The backend has been domain-first since the project started. The frontend src/lib/ was restructured from flat-by-type to domain-first in issue #408 (May 2026).
5. Key architectural decisions
ADR-001 — OCR as a Python microservice
The two OCR engines required (Surya for typewritten text, Kraken for Kurrent/Sütterlin HTR) exist only in the Python ecosystem. A separate ocr-service Python container exposes a simple HTTP API; the Spring Boot backend calls it via RestClient. All job tracking and business logic remain in Spring Boot. See ADR-001.
ADR-002 — Polygon JSONB storage for annotations
Kraken outputs polygon boundaries for historical handwriting; axis-aligned bounding boxes approximate them poorly. Annotation and transcription-block positions are stored as polygon JSONB columns. Display-only — server-side geometry continues to use the AABB fields. See ADR-002.
ADR-003 — Unified activity feed (Chronik/Aktivität)
Personal notifications and ambient activity (uploads, transcriptions, comments) are merged into one /aktivitaeten page. The SvelteKit load function composes data from /api/dashboard/activity and /api/notifications — no new backend orchestrator endpoint. See ADR-003.
ADR-004 — In-process PDFBox thumbnails
Thumbnails are rendered in Spring Boot using Apache PDFBox (already a dependency) rather than delegating to the OCR service. A dedicated thumbnailExecutor pool isolates the work. See ADR-004.
ADR-005 — thumbnailAspect + pageCount
Aspect ratio (PORTRAIT / LANDSCAPE) and page count are persisted alongside the thumbnail JPEG at generation time — cheap to derive then, expensive to re-derive later. See ADR-005.
ADR-006 — Synchronous domain events inside the publisher's transaction
When a Person display name changes, all TranscriptionBlock @mention text must be rewritten atomically. This is done via Spring ApplicationEventPublisher + @EventListener @Transactional to avoid a circular dependency between PersonService and TranscriptionBlockService. See ADR-006.
Layering rule
Controller → Service → Repository → DB
Controllers never call repositories directly. Services never reach into another domain's repository — they call the other domain's service. This keeps domain boundaries clear and business logic testable without a running database.
Permission system
Permissions are enforced via @RequirePermission(Permission.X) on controller methods, checked at runtime by PermissionAspect (Spring AOP). The Permission enum defines the available capabilities (READ_ALL, WRITE_ALL, ADMIN, ADMIN_USER, ADMIN_TAG, ADMIN_PERMISSION, ANNOTATE_ALL, BLOG_WRITE). This is not Spring Security's @PreAuthorize — do not mix the two mechanisms.
Sessions use a Base64-encoded Basic Auth token stored in an httpOnly, SameSite=strict cookie (auth_token, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See docs/security-guide.md for the full security reference.
6. Data flow walkthroughs
Document upload
- User submits the edit form (file + metadata) from the browser.
- The SvelteKit server action sends
PUT /api/documents/{id}asmultipart/form-data.hooks.server.ts(handleFetch) transparently injects theAuthorizationheader from theauth_tokencookie — the action itself is unaware of auth. PermissionAspectintercepts the controller method, verifies the user hasWRITE_ALL, and proceeds.DocumentControllerdelegates toDocumentService.updateDocument().DocumentServiceresolves thePersonsender by ID (viaPersonService), resolves or createsTags (viaTagService), then callsFileService.uploadFile().FileServicegenerates a key (documents/{UUID}_{filename}), streams the file to MinIO via the AWS SDK v2 S3Client.DocumentServicepersists the S3 key, setsstatus = UPLOADED, and saves to PostgreSQL.AuditServicewrites anUPLOADEDevent toaudit_login the same transaction.- Backend returns the updated
DocumentJSON; SvelteKit refreshes the document detail page.
Transcription block autosave
- The transcriber pauses typing; the frontend's
useBlockAutoSavefactory fires after a debounce interval. - The browser sends
PUT /api/documents/{documentId}/transcription-blocks/{blockId}with the new text and the block's currentversion(optimistic lock).hooks.server.ts(handleFetch) injects theAuthorizationheader from the cookie. TranscriptionService.saveBlock()loads the block, checks the@Versionfield for concurrent edits, updatesblock.textand any@mentionsidecars, and callssaveAndFlush.- If a concurrent save collides (version mismatch), the backend returns
409 Conflict; the frontend'ssaveBlockWithConflictRetryhelper re-fetches and retries. - On success,
AuditServicelogs aBLOCK_SAVEDevent. - If the block text contains a new
@PersonNamemention,NotificationServicecreates aNotificationrow for the mentioned person'sAppUser. SseEmitterRegistrybroadcasts the notification over the open SSE connection to that user's browser in real time.