Files
familienarchiv/docs/ARCHITECTURE.md
Marcel fc53038af2 docs(legibility): write docs/ARCHITECTURE.md
Human-targeted architecture doc: high-level diagram, 7 Tier-1 + 2
Tier-2 domains, cross-cutting layer, stack-symmetry principle, 6 ADR
summaries, layering rule, permission system, and two data-flow
walkthroughs (document upload, transcription block autosave).

Closes #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00

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
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, 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

  1. User submits the edit form (file + metadata) from the browser.
  2. SvelteKit server action sends PUT /api/documents/{id} as multipart/form-data with the Authorization header.
  3. PermissionAspect intercepts the controller method, verifies the user has WRITE_ALL, and proceeds.
  4. DocumentController delegates to DocumentService.updateDocument().
  5. DocumentService resolves the Person sender by ID (via PersonService), resolves or creates Tags (via TagService), then calls FileService.uploadFile().
  6. FileService generates a key (documents/{UUID}_{filename}), streams the file to MinIO via the AWS SDK v2 S3Client.
  7. DocumentService persists the S3 key, sets status = UPLOADED, and saves to PostgreSQL.
  8. AuditService writes an UPLOADED event to audit_log in the same transaction.
  9. Backend returns the updated Document JSON; SvelteKit refreshes the document detail page.

Transcription block autosave

  1. The transcriber pauses typing; the frontend's useBlockAutoSave hook fires after a debounce interval.
  2. SvelteKit sends PATCH /api/transcription/blocks/{id} with the new text and the block's current version (optimistic lock).
  3. TranscriptionService.saveBlock() loads the block, checks the @Version field for concurrent edits, updates block.text and any @mention sidecars, and calls saveAndFlush.
  4. If a concurrent save collides (version mismatch), the backend returns 409 Conflict; the frontend's saveBlockWithConflictRetry helper re-fetches and retries.
  5. On success, AuditService logs a BLOCK_SAVED event.
  6. If the block text contains a new @PersonName mention, NotificationService creates a Notification row for the mentioned person's AppUser.
  7. SseEmitterRegistry broadcasts the notification over the open SSE connection to that user's browser in real time.