Table of Contents
- 03 — System Architecture
- 1. Architecture Overview
- 2. Multi-Tenancy Strategy
- Decision: Schema-Per-Tenant
- Tenant Provisioning
- Tenant Resolution
- Tenant Resolution
- Code Pattern — Schema Routing DataSource
- 3. Authentication & Authorization
- JWT Token Flow
- Roles
- Staff Permission Model
- Service-Layer Authorization Example
- Member Login Sequence
- 4. Data Model (JPA Entities)
- 5. API Layer Design
- 6. Compliance Engine
- 7. Infrastructure (Hetzner)
- 8. Key Design Decisions
03 — System Architecture
Project: CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
Phase: 2 of 5 — Architecture & Data Model
Stack: Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · React/Vite (MVP) → Next.js v2
Last updated: 2026-04-06
1. Architecture Overview
graph TD
AdminBrowser["🖥️ Browser — Admin Portal"]
MemberBrowser["🖥️ Browser — Member Portal"]
Frontend["React/Vite Frontend\n(SPA — served by Nginx)"]
AdminBrowser -->|HTTPS| Frontend
MemberBrowser -->|HTTPS| Frontend
Frontend -->|REST/JSON| Backend
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
REST["REST API Layer\n/api/v1/"]
Service["Service Layer\n(ComplianceService, ReportService…)"]
JPA["JPA / Hibernate\nRepositories"]
Security["Spring Security + JWT\nTenant Interceptor"]
REST --> Service
Service --> JPA
Security --> REST
end
JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")]
Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"]
Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"]
Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"]
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"]
Backend
PG
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
end
Frontend --> Nginx
Nginx --> Backend
Component Responsibilities
| Component | Technology | Role |
|---|---|---|
| Admin Portal | React/Vite SPA (→ Next.js v2) | Club management UI |
| Member Portal | React/Vite SPA (→ Next.js v2) | Member quota & history UI |
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
| Database | PostgreSQL 16 | Primary data store (multi-tenant) |
| Migrations | Flyway | Versioned schema management |
| Payments | Stripe Java SDK | Club subscription billing |
| Jakarta Mail / Spring Mail | Welcome emails, recall alerts | |
| iText 7 | Compliance report generation | |
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
2. Multi-Tenancy Strategy
Decision: Schema-Per-Tenant
Each club gets its own PostgreSQL schema (e.g. tenant_abc123). A platform-level public schema holds only the tenants registry. Flyway runs per-schema migrations on onboarding.
Why schema-per-tenant, not shared schema?
A shared-schema approach (single table with tenant_id on every row) is operationally convenient in the short term but creates serious problems at scale:
| Concern | Shared Schema | Schema-Per-Tenant |
|---|---|---|
| Data isolation | Application-layer only — one missing filter = data leak | Enforced at DB level — schemas are hard boundaries |
| DSGVO compliance | Harder to prove isolation; one backup contains all clubs' data | Per-tenant pg_dump; each club's data is cleanly separable |
| Deletion / right to erasure | Must DELETE WHERE tenant_id = ? across every table |
DROP SCHEMA tenant_abc123 CASCADE — clean and auditable |
| Migrations | One migration path for all | Per-schema migration via Flyway schemas config; adds ~100ms per onboard |
| Query performance | Cross-tenant index bloat on large shared tables | Smaller per-tenant tables; no cross-tenant contention |
| Future per-club DB isolation | Requires full re-architecture | Trivial: move schema to dedicated DB server |
| Operational overhead | Lower — one connection pool | Slightly higher — one pool per tenant (managed by HikariCP with pool-per-schema) |
Conclusion: The shared-schema "MVP convenience" argument only holds for throwaway prototypes. For a compliance SaaS handling personal health-adjacent data (cannabis consumption records), schema-per-tenant is the correct design from Day 1. The migration complexity is manageable; the data isolation benefit is permanent.
Tenant Provisioning
When a new club onboards:
POST /api/v1/admin/bootstrap
→ TenantProvisioningService.provisionTenant(tenantId)
→ CREATE SCHEMA tenant_{tenantId}
→ Flyway.migrate(schema=tenant_{tenantId}) // applies all V*.sql
→ INSERT INTO public.tenants (id, schema_name, onboarded_at, status)
Tenant Resolution
HTTP Request
└─ Spring Security Filter: extract JWT → resolve tenant_id
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
└─ DataSource routes to schema: SET search_path = tenant_{tenantId}
└─ All queries execute in tenant's private schema
Tenant Resolution
HTTP Request
└─ Spring Security Filter: extract JWT → resolve tenant_id
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
└─ JPA @Where filter applied on every entity query
Code Pattern — Schema Routing DataSource
// TenantRoutingDataSource.java (pseudocode)
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant(); // returns tenant schema name
}
}
// TenantInterceptor.java (pseudocode)
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, ...) {
String tenantId = JwtUtils.extractTenantId(req);
TenantContext.setCurrentTenant("tenant_" + tenantId);
return true;
}
}
Invariants enforced:
- Every incoming request resolves its schema before any query runs
- No entity has a
tenant_idcolumn — schema isolation replaces row-level filtering - Raw JDBC queries must be avoided; all access goes through JPA repositories with schema routing
- The
publicschema contains only the tenants registry and platform-level config
3. Authentication & Authorization
JWT Token Flow
- Access token: 8-hour expiry, signed HS256, contains
sub(userId),role,tenantId - Refresh token: 30-day expiry, stored in
users.refresh_token_hash(hashed) - Stateless: No server-side session. JWT is verified on every request by
JwtAuthFilter
Roles
| Role | Description | Access |
|---|---|---|
ROLE_CLUB_ADMIN |
Club administrator | Full club management, all members, reports, distributions, staff management |
ROLE_STAFF |
Club staff member | Configurable subset of admin permissions — defined per staff account by the admin |
ROLE_MEMBER |
Club member | Own quota, own distribution history (read-only) |
ROLE_PREVENTION_OFFICER |
Designated prevention officer | Member under-21 reports, prevention data |
Staff is a core feature, not an add-on. Real clubs have multiple staff members (front desk, cultivation responsible, prevention officer designate) with different operational responsibilities. DSGVO requires that each staff member can only access data they need for their specific role. The
ROLE_STAFFwith configurable permission grants from the admin is designed from Phase 0 — retrofitting it later would require schema and API changes.
Staff Permission Model
Admins configure staff permissions at account creation. Permissions are stored as a JSONB column granted_permissions on the staff_accounts table within the tenant schema.
// Configurable staff permissions (granted by admin per staff account)
public enum StaffPermission {
RECORD_DISTRIBUTION, // can record distributions
VIEW_MEMBER_LIST, // can view member roster
VIEW_MEMBER_QUOTA, // can view individual member quota
ADD_MEMBER, // can register new members
VIEW_STOCK, // can view batch/strain inventory
RECORD_STOCK_IN, // can add new batches
VIEW_COMPLIANCE_REPORT, // can generate/download reports
MANAGE_GROW_CALENDAR // can manage cultivation calendar entries
}
Pre-created role templates (configurable by admin):
- Ausgabe (Distribution desk):
RECORD_DISTRIBUTION,VIEW_MEMBER_LIST,VIEW_MEMBER_QUOTA - Lager (Stock/cultivation):
VIEW_STOCK,RECORD_STOCK_IN,MANAGE_GROW_CALENDAR - Vorstand (Board member): all permissions except staff management
Service-Layer Authorization Example
@Service
public class DistributionService {
@PreAuthorize("hasRole('CLUB_ADMIN')")
public Distribution recordDistribution(RecordDistributionRequest req) { ... }
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
public QuotaStatus getMyQuota(UUID memberId) { ... }
@PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')")
public List<Member> getUnder21Members() { ... }
}
Member Login Sequence
sequenceDiagram
participant B as Browser
participant API as Spring Boot /api/v1/auth/login
participant DB as PostgreSQL (users table)
participant JWT as JwtService
B->>API: POST /api/v1/auth/login {email, password}
API->>DB: SELECT * FROM users WHERE email = ? AND active = true
DB-->>API: UserEntity (password_hash, role, tenant_id, member_id)
API->>API: BCrypt.verify(password, password_hash)
alt Invalid credentials
API-->>B: 401 Unauthorized
else Valid
API->>JWT: generateAccessToken(userId, role, tenantId) → 8h
API->>JWT: generateRefreshToken(userId) → 30d
API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW()
DB-->>API: OK
JWT-->>API: accessToken, refreshToken
API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 }
end
4. Data Model (JPA Entities)
Entity-Relationship Diagram
erDiagram
Club {
UUID id PK
UUID tenant_id
string name
string address
string license_number
int max_members
timestamp created_at
enum status
}
Member {
UUID id PK
UUID tenant_id
UUID club_id FK
string first_name
string last_name
string email
date date_of_birth
date membership_date
string membership_number
enum status
boolean is_under_21
boolean prevention_officer
}
Strain {
UUID id PK
UUID tenant_id
string name
decimal thc_percentage
decimal cbd_percentage
string description
}
Batch {
UUID id PK
UUID tenant_id
UUID strain_id FK
decimal quantity_grams
date harvest_date
string batch_code
enum status
boolean contamination_flag
}
Distribution {
UUID id PK
UUID tenant_id
UUID member_id FK
UUID batch_id FK
decimal quantity_grams
timestamp distributed_at
UUID recorded_by FK
string notes
boolean immutable
}
MonthlyQuota {
UUID id PK
UUID tenant_id
UUID member_id FK
int year
int month
decimal total_distributed
decimal max_allowed
}
StockMovement {
UUID id PK
UUID tenant_id
UUID batch_id FK
enum movement_type
decimal quantity_grams
string reason
timestamp created_at
}
User {
UUID id PK
UUID tenant_id
UUID member_id FK
string email
string password_hash
enum role
timestamp last_login
boolean active
}
Club ||--o{ Member : "has members"
Member ||--o{ Distribution : "receives"
Member ||--o{ MonthlyQuota : "has quota per month"
Member ||--o| User : "may have login"
Strain ||--o{ Batch : "cultivated as"
Batch ||--o{ Distribution : "distributed via"
Batch ||--o{ StockMovement : "tracked in"
Member ||--o{ Distribution : "recorded_by (admin)"
Relationship Notes
| Relationship | Cardinality | Notes |
|---|---|---|
| Club → Member | 1:N | member.club_id FK; max enforced by Club.max_members |
| Member → Distribution | 1:N | Each distribution targets one member |
| Member → MonthlyQuota | 1:N | One row per (member_id, year, month) — UNIQUE constraint |
| Member → User | 1:0..1 | A member may have a portal login; admins may have no member_id |
| Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches |
| Batch → Distribution | 1:N | A batch can supply many distributions |
| Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled |
| Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) |
Key Constraints
Distribution.immutable = trueby default — records are append-only; no UPDATE/DELETE allowed via APIMonthlyQuotahasUNIQUE(member_id, year, month)— enforced at DB levelBatch.contamination_flagtriggers recall workflow;Batch.status = RECALLEDis the final state- All
tenant_idcolumns:NOT NULL,updatable = false, set via@PrePersist Member.is_under_21is derived fromdate_of_birthat registration and re-evaluated on birthday (scheduled job)
5. API Layer Design
Base Path: /api/v1/
All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except /auth/**.
| Controller | Base Path | Key Endpoints |
|---|---|---|
AuthController |
/api/v1/auth |
POST /login, POST /refresh, POST /logout |
ClubController |
/api/v1/clubs |
GET /, POST /, GET /{id}, PUT /{id} |
MemberController |
/api/v1/members |
GET /, POST /, GET /{id}, PUT /{id}/status, GET /me/quota |
DistributionController |
/api/v1/distributions |
POST /, GET /?memberId=&month=&year= |
StockController |
/api/v1/stock |
GET /batches, POST /batches, POST /batches/{id}/recall |
ReportController |
/api/v1/reports |
GET /monthly?month=&year= (PDF + CSV), GET /members/{id}/history |
QuotaController |
/api/v1/quota |
GET /members/{id}/current, GET /members/{id}/history |
Standard HTTP conventions
201 Created+Locationheader on resource creation400 Bad Requestwith{ error, message, field? }on validation failure403 Forbiddenwhen role/tenant check fails422 Unprocessable Entitywhen compliance limits are breached (quota exceeded)- Pagination:
?page=0&size=20&sort=field,asc
6. Compliance Engine
The ComplianceService is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single @Transactional block with optimistic locking to prevent race conditions under concurrent distribution recording.
@Service
@Transactional
public class ComplianceService {
/**
* Validates whether a distribution is legally permitted.
*
* Checks:
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
* where max_allowed = 30g (under-21) or 50g (adult)
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
* 5. Batch has sufficient stock
*
* @throws ComplianceLimitExceededException with remaining quota details
* @throws MemberIneligibleException if member is not ACTIVE
* @throws BatchUnavailableException if batch is recalled or exhausted
*/
public ComplianceCheckResult checkDistributionAllowed(
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
/**
* Returns remaining quota for the current calendar month.
* Creates a MonthlyQuota row if none exists (lazy initialization).
*
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
*/
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
/**
* Flags a batch as RECALLED.
* Returns all members who received distributions from this batch
* so the caller can trigger notifications.
* Writes a StockMovement(RECALL) entry.
*
* @return List<AffectedMember> { memberId, name, email, totalReceived }
*/
public List<AffectedMember> recallBatch(UUID batchId) { ... }
}
Race Condition Prevention
MonthlyQuota rows carry a JPA @Version column (optimistic locking). If two concurrent requests attempt to increment total_distributed simultaneously, one will receive an OptimisticLockException and must retry. The retry logic is handled by a @Retryable annotation (Spring Retry, max 3 attempts, 50ms backoff).
@Entity
public class MonthlyQuota extends AbstractTenantEntity {
@Version
private Long version; // optimistic lock
// ... other fields
}
7. Infrastructure (Hetzner)
graph TD
Dev["👨💻 Developer (Fedora Workstation)"]
Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"]
Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"]
Dev -->|git push| Gitea
Gitea -->|"SSH deploy script\n(webhook → deploy.sh)"| Hetzner
subgraph Hetzner
Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"]
App["☕ cannamanage-app\n(Spring Boot JAR)"]
DB["🐘 cannamanage-db\n(PostgreSQL 16)"]
Nginx -->|proxy_pass :8080| App
App -->|JDBC :5432| DB
end
Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx
Docker Compose Services
# docker-compose.yml (abbreviated)
services:
cannamanage-app:
image: cannamanage:latest
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage
JWT_SECRET: ${JWT_SECRET}
STRIPE_API_KEY: ${STRIPE_API_KEY}
depends_on: [cannamanage-db]
ports: ["127.0.0.1:8080:8080"]
cannamanage-db:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: cannamanage
POSTGRES_PASSWORD: ${DB_PASSWORD}
cannamanage-nginx:
image: nginx:alpine
ports: ["443:443", "80:80"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
Hetzner Sizing
| Resource | Spec | Rationale |
|---|---|---|
| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP |
| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups |
| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days |
| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany |
| TLS | Let's Encrypt via Certbot | Auto-renew via cron |
Deployment Workflow
git push origin main
→ Gitea webhook fires
→ deploy.sh on Hetzner:
docker pull cannamanage:latest
docker compose up -d --no-deps cannamanage-app
# zero-downtime: Nginx buffers requests during restart
Flyway migrations run automatically on application startup (spring.flyway.enabled=true). Migration files live at src/main/resources/db/migration/V*.sql.
8. Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Multi-tenancy | Schema-per-tenant | Hard data isolation, DSGVO-clean deletion, no cross-tenant query risk |
| Frontend MVP | React/Vite SPA | Modern stack; no JSF/PrimeFaces lock-in; easier to hire for; mobile-friendly from day 1 |
| Frontend v2 | Next.js | SSR/ISR for SEO on marketing pages; same React codebase |
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB UNIQUE prevents duplicates |
| Distribution immutability | immutable = true, no DELETE API |
Audit trail integrity for regulatory compliance |
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
| Staff roles | Core feature from Phase 0 | DSGVO requires least-privilege access; retrofitting post-MVP too costly |
🔧 pi_mcps Wiki
Overview
MCP Servers
Java Projects
🌿 CannaManage
- 🏠 Overview
- 📋 Project Charter
- 📖 User Stories
- 🏗️ Architecture
- 🔄 Flow Charts
- 🔌 API Spec
- 🎨 Wireframes
- 📏 Coding Standards
- 🧪 Test Plan
- 🚀 Deployment
- 🔍 Retrospective