ARCHITECTURE.md
6,895 bytes
| 1 | # Tasteprint architecture |
|---|---|
| 2 | |
| 3 | ## Decision |
| 4 | |
| 5 | Tasteprint starts as a modular monolith because its current domains share consistency rules and its expected traffic does not justify independent infrastructure. A tasting updates the journal, trip mission, personal progress, comparison data, and challenge progress. Keeping one transactional database makes those results immediate and predictable. |
| 6 | |
| 7 | The code still has explicit module boundaries. Spring Modulith and an architecture test prevent accidental package coupling. Each module owns its entities, repositories, service logic, and HTTP entry points. |
| 8 | |
| 9 | Microservices would add network failure modes, distributed tracing, message delivery, separate migrations, and eventual consistency before the product has a scaling need. The current design keeps an extraction path without paying that cost now. |
| 10 | |
| 11 | ## Runtime view |
| 12 | |
| 13 | ```mermaid |
| 14 | flowchart LR |
| 15 | U[Browser] --> N[Nginx] |
| 16 | N --> R[React application] |
| 17 | N --> A[Spring Boot API] |
| 18 | A --> P[(PostgreSQL)] |
| 19 | A --> M[(Photo volume)] |
| 20 | A --> H[Health and OpenAPI] |
| 21 | ``` |
| 22 | |
| 23 | Nginx serves immutable frontend assets, applies a request-size limit and basic authentication rate limiting, proxies API requests, and returns the React entry page for client routes. |
| 24 | |
| 25 | The backend is stateless at the HTTP layer. A session is a random opaque token. Only its SHA-256 hash is stored, so a database read does not reveal usable sessions. |
| 26 | |
| 27 | ## Backend modules |
| 28 | |
| 29 | | Module | Owns | Depends on | |
| 30 | | --- | --- | --- | |
| 31 | | `account` | Users, password hashes, session tokens, profile privacy | `shared` | |
| 32 | | `catalog` | Destinations, dishes, categories, cultural importance | `shared` | |
| 33 | | `tasting` | Tasting journal and ownership rules | `account`, `catalog`, `media`, `shared` | |
| 34 | | `journey` | Trips and persisted five-bite missions | `catalog`, `tasting`, `shared` | |
| 35 | | `progress` | Weighted coverage, important gaps, dashboard projection | `account`, `catalog`, `tasting`, `journey` | |
| 36 | | `social` | Public Tasteprints, comparisons, collaborative challenges | `account`, `catalog`, `tasting`, `progress`, `shared` | |
| 37 | | `media` | Validated local photo storage and public file route | `account`, `shared` | |
| 38 | | `demo` | Optional local sample accounts and activity | Application modules through public services | |
| 39 | | `shared` | Clock, API errors, OpenAPI configuration | None | |
| 40 | |
| 41 | Repositories and JPA entities are package-private where another module does not need them. Modules communicate through public service methods and immutable record views. |
| 42 | |
| 43 | ## Main flows |
| 44 | |
| 45 | ### Record a tasting |
| 46 | |
| 47 | ```text |
| 48 | Authenticated request |
| 49 | -> validate dish, date, rating, country, coordinates, and photo URL |
| 50 | -> verify the catalog dish exists |
| 51 | -> save a user-owned tasting |
| 52 | -> publish TastingRecorded |
| 53 | -> invalidate frontend queries |
| 54 | -> recalculate projections when they are read |
| 55 | ``` |
| 56 | |
| 57 | Progress is currently calculated on read. The catalog has 72 dishes and this keeps writes simple. If the dataset or traffic grows, the `TastingRecorded` event is the boundary for introducing cached projections without changing the API. |
| 58 | |
| 59 | ### Build a trip mission |
| 60 | |
| 61 | ```text |
| 62 | Create trip |
| 63 | -> load destination dishes |
| 64 | -> sort untried dishes before tried dishes |
| 65 | -> prefer higher cultural importance |
| 66 | -> select distinct categories first |
| 67 | -> persist five mission items |
| 68 | ``` |
| 69 | |
| 70 | A trip mission does not change after creation. This gives the user a stable plan. Completion requires both a date inside the trip and a tasting country equal to the destination code. |
| 71 | |
| 72 | ### Update a group challenge |
| 73 | |
| 74 | Challenge scores are derived from all participant tastings within the challenge dates. Dish slugs are restricted to the challenge destination before weighted coverage is calculated. A member can leave. The owner can delete the challenge but cannot leave an ownerless record. |
| 75 | |
| 76 | ## Data and consistency |
| 77 | |
| 78 | Flyway owns the schema and curated catalog. Hibernate uses `validate`, never automatic schema creation or mutation. Foreign keys and check constraints protect ownership relationships, date ordering, ratings, coordinates, category uniqueness, mission position, and challenge membership. |
| 79 | |
| 80 | H2 in PostgreSQL compatibility mode keeps local startup and most tests fast. A Testcontainers test starts PostgreSQL when Docker is available to verify migrations and the core write flow against the production database engine. |
| 81 | |
| 82 | Photo bytes are deliberately outside the relational database. The database stores ownership and file metadata, a tasting stores a relative upload URL, and a mounted volume stores bytes. `MediaStorage` is an interface so object storage can replace local disk without changing tasting logic. Removing a tasting removes its local photo. Account deletion removes unattached uploads as well. |
| 83 | |
| 84 | ## Security and privacy |
| 85 | |
| 86 | - Passwords use BCrypt with cost 12. |
| 87 | - Session tokens contain 256 random bits, expire after 30 days, and are stored only as hashes. |
| 88 | - The API is stateless and protected by bearer authentication. |
| 89 | - Public profiles are opt-in and return a DTO without email or private account fields. |
| 90 | - Public tasting DTOs omit restaurant details, exact coordinates, and internal timestamps. |
| 91 | - User-owned tasting and trip lookups include the authenticated user ID. |
| 92 | - Challenge reads require membership. Destructive actions distinguish owner and member permissions. |
| 93 | - CORS is limited to configured origins. |
| 94 | - Photo upload accepts JPEG, PNG, or WebP, checks magic bytes, generates filenames, records ownership, rejects cross-account reuse, and prevents path traversal. |
| 95 | - Nginx blocks framing, disables MIME sniffing, sets a strict referrer policy, limits upload size, and rate limits login and registration in the supplied deployment. |
| 96 | |
| 97 | For a larger public deployment, put TLS and a managed rate limiter at the edge, move photos to object storage, rotate database credentials through a secret manager, and add automated backups. |
| 98 | |
| 99 | ## API design |
| 100 | |
| 101 | The API is versioned under `/api/v1`. Validation and domain errors use RFC 9457 problem details. Successful create requests return `201`, deletes and logout return `204`, private missing resources return `404` where revealing ownership would leak information, and permission failures return `403`. |
| 102 | |
| 103 | OpenAPI is available at `/docs` in development and can be disabled through Springdoc configuration in a restricted production environment. |
| 104 | |
| 105 | ## Extraction criteria |
| 106 | |
| 107 | A module should become a separate service only after evidence shows an independent need. Likely candidates are: |
| 108 | |
| 109 | 1. Media, when object processing or moderation needs its own workers. |
| 110 | 2. Progress, when precomputed projections require independent scaling. |
| 111 | 3. Notifications, when challenge activity adds asynchronous email or push delivery. |
| 112 | |
| 113 | Extraction would use the existing public service and domain event boundaries. Account, catalog, tasting, and journey should remain together until independent scaling or ownership becomes more valuable than transactional simplicity. |
| 114 | |