Recipe app · Spring Boot 4 · PostgreSQL · Self-hosted · Single machine
ingredient.category varchar(30) replaced by ingredient.category_id FK → ingredient_category. New ingredient_category table (id, household_id, name). API responses now return "category": { "id": "...", "name": "meat" } instead of "category": "meat". Two new endpoints: GET /v1/ingredient-categories and POST /v1/ingredient-categories.Spring Framework 7 underneath. Modular starters (smaller JARs). JSpecify null-safety. Built-in API versioning. Java 17+ baseline, first-class Java 25 support. Embedded Tomcat — one executable JAR.
Same machine. 1:1 mapping to JPA entities. Flyway for migrations (Atlas's SQL directly). HikariCP connection pool built in.
Default session-based auth. HttpOnly + Secure + SameSite=Lax cookie. No tokens, no refresh flow, no extra libraries. bcrypt password hashing built in.
spring-boot-starter-web REST + Jackson · spring-boot-starter-data-jpa Hibernate + repos · spring-boot-starter-security session auth + CSRF · spring-boot-starter-validation Bean Validation · flyway-core DB migrations · postgresql JDBC driver · springdoc-openapi Swagger UI
https://yourapp.com/v1 — frontend + API on same domain Cookie: JSESSIONID=... — automatic (session auth) X-XSRF-TOKEN: ... — CSRF (POST/PUT/PATCH/DELETE) Content-Type: application/json — request bodies URLs: kebab-case /v1/week-plans/{id}/slots JSON: camelCase cookTimeMin, isChildFriendly Java: camelCase DB cols: snake_case
// Success { "status": "success", "data": { ... }, "meta": { "pagination": { ... } } } // Error { "status": "error", "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } } // Pagination & filtering ?limit=20&offset=0&sort=-cookTimeMin&effort=easy&search=pasta
Every endpoint below has a collapsible detail panel showing the exact JSON shapes. Fields marked required are validated with Bean Validation annotations. Fields marked optional can be omitted. All responses are wrapped in the standard envelope above — the examples below show the data payload only.
com.recipeapp ├── auth/ AuthController · AuthService · SecurityConfig · CustomUserDetailsService ├── household/ HouseholdController · InviteController · entities/ · dtos/ · repos/ ├── recipe/ RecipeController · IngredientController · TagController · RecipeService ├── planning/ WeekPlanController · SuggestionService · VarietyService · CookingLogController ├── shopping/ ShoppingListController · ShoppingListService ├── pantry/ PantryController ├── admin/ AdminController └── common/ ApiResponse · ApiError · GlobalExceptionHandler · HouseholdContext
{
"email": "[email protected]", // required, valid email
"password": "s3cure!Pass", // required, min 8 chars
"displayName": "Sarah" // required, 1–100 chars
}{
"id": "550e8400-...",
"email": "[email protected]",
"displayName": "Sarah",
"householdId": null, // no household yet
"householdRole": null
}
+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax{
"email": "[email protected]",
"password": "s3cure!Pass"
}{
"id": "550e8400-...",
"email": "[email protected]",
"displayName": "Sarah",
"householdId": "7c9e6679-...",
"householdRole": "planner",
"systemRole": "user"
}
+ Set-Cookie: JSESSIONID=...; HttpOnly; Secure; SameSite=Lax// empty — no body needed// empty body
+ Set-Cookie: JSESSIONID=; Max-Age=0// no body — GET request
// session cookie sent automatically{
"id": "550e8400-...",
"email": "[email protected]",
"displayName": "Sarah",
"householdId": "7c9e6679-...",
"householdName": "Smith family",
"householdRole": "planner",
"systemRole": "user"
}{
"displayName": "Sarah S.", // optional
"currentPassword": "old...", // required if changing pw
"newPassword": "new..." // optional, min 8 chars
}{
"id": "550e8400-...",
"email": "[email protected]",
"displayName": "Sarah S."
}{
"name": "Smith family" // required, 1–100 chars
}
// Seeds ~20 default staple ingredients
// Seeds default tags (protein types, dietary)
// Adds current user as planner{
"id": "7c9e6679-...",
"name": "Smith family",
"members": [
{
"userId": "550e8400-...",
"displayName": "Sarah",
"role": "planner",
"joinedAt": "2026-04-01T10:00:00Z"
}
]
}// no body — GET{
"id": "7c9e6679-...",
"name": "Smith family",
"members": [
{ "userId": "550e...", "displayName": "Sarah",
"role": "planner", "joinedAt": "..." },
{ "userId": "661f...", "displayName": "Tom",
"role": "member", "joinedAt": "..." }
]
}// empty — server generates the code{
"inviteCode": "ABC12XYZ",
"shareUrl": "https://yourapp.com/join/ABC12XYZ",
"expiresAt": "2026-04-03T10:00:00Z"
}// code is in the URL path
// no request body needed{
"householdId": "7c9e6679-...",
"householdName": "Smith family",
"role": "member"
}
// 409 if code already used
// 422 if code expired
// 409 if user already in a household// no body — GET[
{ "userId": "550e...", "displayName": "Sarah",
"role": "planner", "joinedAt": "..." },
{ "userId": "661f...", "displayName": "Tom",
"role": "member", "joinedAt": "..." }
]?search=pasta // ILIKE on name ?effort=easy // exact match ?isChildFriendly=true // boolean ?cookTimeMin.lte=30 // ≤ 30 minutes ?sort=-cookTimeMin // descending ?limit=20&offset=0 // pagination
{
"data": [
{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"serves": 4,
"cookTimeMin": 45,
"effort": "medium",
"isChildFriendly": true,
"heroImageUrl": "/uploads/recipes/a1b2.jpg"
}
],
"meta": {
"pagination": {
"total": 47, "limit": 20,
"offset": 0, "hasMore": true
}
}
}// no body — GET
// {id} = recipe UUID{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"serves": 4,
"cookTimeMin": 45,
"effort": "medium",
"isChildFriendly": true,
"heroImageUrl": "/uploads/recipes/a1b2.jpg",
"ingredients": [
{ "ingredientId": "f1e2-...",
"name": "spaghetti",
"category": { "id": "cat-01-...", "name": "pasta" },
"quantity": 400, "unit": "g", "sortOrder": 1 },
{ "ingredientId": "d3c4-...",
"name": "ground beef",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g", "sortOrder": 2 }
],
"steps": [
{ "stepNumber": 1,
"instruction": "Boil water and cook pasta." },
{ "stepNumber": 2,
"instruction": "Brown the beef in a pan." }
],
"tags": [
{ "id": "t1-...", "name": "beef", "tagType": "protein" },
{ "id": "t2-...", "name": "Italian", "tagType": "cuisine" }
]
}{
"name": "Spaghetti Bolognese",
"serves": 4, // 1–20
"cookTimeMin": 45, // ≥ 0
"effort": "medium", // easy|medium|hard
"isChildFriendly": true, // default false
"heroImageUrl": null,
"ingredients": [
{ "ingredientId": "f1e2-...", // existing id
"quantity": 400,
"unit": "g",
"sortOrder": 1 },
{ "newIngredientName": "pancetta",// OR create new
"quantity": 100,
"unit": "g",
"sortOrder": 2 }
],
"steps": [
{ "stepNumber": 1,
"instruction": "Boil water..." }
],
"tagIds": ["t1-...", "t2-..."] // ≥ 2 (effort + 1 cat)
}// full RecipeDetail (same shape as GET /recipes/{id}) { "id": "a1b2c3d4-...", "name": "Spaghetti Bolognese", ... } + Location: /v1/recipes/a1b2c3d4-...
// identical shape to POST /v1/recipes
// sends the complete new state
// server deletes old children, inserts new
{
"name": "Spaghetti Bolognese (updated)",
"serves": 4,
"cookTimeMin": 40,
"effort": "medium",
"ingredients": [ ... ],
"steps": [ ... ],
"tagIds": [ ... ]
}// full RecipeDetail with updated data
{
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese (updated)",
...
}// no body — DELETE// empty body?search=chick // ILIKE '%chick%' ?isStaple=true // filter staples (A3/D3)
[
{ "id": "f1e2-...", "name": "chicken breast",
"category": { "id": "cat-02-...", "name": "meat" },
"isStaple": false },
{ "id": "g3h4-...", "name": "chickpeas",
"category": { "id": "cat-05-...", "name": "legumes" },
"isStaple": false }
]{
"isStaple": true,
"name": "olive oil",
"categoryId": "cat-03-..." // FK → ingredient_category
}{ "id": "f1e2-...", "name": "olive oil",
"category": { "id": "cat-03-...", "name": "oil" },
"isStaple": true }// no body — GET[
{ "id": "t1-...", "name": "chicken", "tagType": "protein" },
{ "id": "t2-...", "name": "beef", "tagType": "protein" },
{ "id": "t3-...", "name": "vegetarian", "tagType": "dietary" },
{ "id": "t4-...", "name": "Italian", "tagType": "cuisine" }
]{
"name": "Thai",
"tagType": "cuisine" // protein|dietary|cuisine
}{ "id": "t9-...", "name": "Thai",
"tagType": "cuisine" }// no body — GET
// scoped to user's household automatically[
{ "id": "cat-01-...", "name": "pasta" },
{ "id": "cat-02-...", "name": "meat" },
{ "id": "cat-03-...", "name": "oil" },
{ "id": "cat-04-...", "name": "dairy" },
{ "id": "cat-05-...", "name": "legumes" },
{ "id": "cat-06-...", "name": "vegetable" },
{ "id": "cat-07-...", "name": "spice" }
]
// seeded on household creation
// ordered alphabetically by name{
"name": "frozen" // required, 1–50 chars
}
// 409 if name already exists in household{
"id": "cat-08-...",
"name": "frozen"
}?weekStart=2026-04-06 // ISO date, must be Monday{
"id": "wp-1234-...",
"weekStart": "2026-04-06",
"status": "draft",
"confirmedAt": null,
"slots": [
{ "id": "sl-01-...",
"slotDate": "2026-04-06",
"recipe": {
"id": "a1b2-...",
"name": "Spaghetti Bolognese",
"effort": "medium",
"cookTimeMin": 45,
"heroImageUrl": "/uploads/recipes/a1b2.jpg"
}
},
{ "id": "sl-02-...",
"slotDate": "2026-04-07",
"recipe": null // empty day
}
]
}
// 404 if no plan exists for that week yet{
"weekStart": "2026-04-06" // must be a Monday
}{
"id": "wp-1234-...",
"weekStart": "2026-04-06",
"status": "draft",
"slots": []
}
// 409 if plan already exists for that week{
"slotDate": "2026-04-07", // within plan week
"recipeId": "a1b2c3d4-..."
}{
"id": "sl-03-...",
"slotDate": "2026-04-07",
"recipe": {
"id": "a1b2c3d4-...",
"name": "Spaghetti Bolognese",
"effort": "medium",
"cookTimeMin": 45,
"heroImageUrl": "..."
}
}{
"recipeId": "x9y8z7-..." // new recipe
}{
"id": "sl-03-...",
"slotDate": "2026-04-07",
"recipe": {
"id": "x9y8z7-...",
"name": "Quick Stir Fry",
"effort": "easy", "cookTimeMin": 15, ...
}
}// no body — DELETE// empty body// empty — action endpoint{
"id": "wp-1234-...",
"status": "confirmed",
"confirmedAt": "2026-04-05T18:30:00Z"
}
// 422 if no slots filled
// 422 if already confirmed?slotDate=2026-04-08 // target day{
"suggestions": [
{
"recipe": {
"id": "r1-...", "name": "Quick Stir Fry",
"effort": "easy", "cookTimeMin": 15,
"heroImageUrl": "..."
},
"fitReasons": [
"not_cooked_recently",
"effort_balance",
"no_protein_repeat"
],
"warnings": []
},
{
"recipe": { "id": "r2-...", "name": "Fish Tacos", ... },
"fitReasons": ["effort_balance"],
"warnings": ["shares_ingredient_with_yesterday"]
}
]
}// no body — GET{
"score": 7.5,
"ingredientOverlaps": [
{ "ingredientName": "onion",
"days": ["2026-04-06", "2026-04-07"] }
],
"proteinRepeats": [],
"effortBalance": {
"easy": 2, "medium": 3, "hard": 2
}
}{
"recipeId": "a1b2c3d4-...",
"cookedOn": "2026-04-07" // default: today
}{
"id": "cl-01-...",
"recipeId": "a1b2c3d4-...",
"cookedOn": "2026-04-07",
"cookedBy": "550e8400-..."
}?limit=30 // default 30
?offset=0[
{ "id": "cl-01-...", "recipeId": "a1b2-...",
"recipeName": "Spaghetti Bolognese",
"cookedOn": "2026-04-07",
"cookedBy": "550e8400-..." }
]// empty — generated from the week plan
// server merges ingredients across meals,
// sums quantities, filters staples{
"id": "shl-01-...",
"weekPlanId": "wp-1234-...",
"status": "draft",
"items": [
{ "id": "si-01-...",
"ingredientId": "f1e2-...",
"name": "spaghetti",
"category": { "id": "cat-01-...", "name": "pasta" },
"quantity": 800, "unit": "g",
"isChecked": false,
"sourceRecipes": ["a1b2-...", "c3d4-..."] },
{ "id": "si-02-...",
"ingredientId": "d3c4-...",
"name": "ground beef",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g",
"isChecked": false,
"sourceRecipes": ["a1b2-..."] }
]
}// no body — GET
// this is the refresh action:
// pull-to-refresh calls this endpoint// same shape as POST response above
{
"id": "shl-01-...",
"status": "published",
"items": [ ... ]
}// empty — action endpoint{
"id": "shl-01-...",
"status": "published",
"publishedAt": "2026-04-06T09:00:00Z"
}
// 422 if already published{
"isChecked": true
}{
"id": "si-01-...",
"name": "spaghetti",
"isChecked": true,
"checkedBy": "661f-..." // who checked it
}
// other members see this on next refresh{
"ingredientId": null, // or existing id
"customName": "Paper towels", // if no ingredientId
"quantity": 1,
"unit": "" // blank for countable
}{
"id": "si-10-...",
"ingredientId": null,
"name": "Paper towels",
"quantity": 1, "unit": "",
"isChecked": false,
"sourceRecipes": []
}// no body — DELETE// 422 if list is already published// no body — GET[
{ "id": "pi-01-...",
"ingredientId": "f1e2-...",
"name": "chicken breast",
"category": { "id": "cat-02-...", "name": "meat" },
"quantity": 500, "unit": "g",
"bestBefore": "2026-04-10",
"openedOn": null }
]{
"ingredientId": "f1e2-...", // or null
"customName": null, // if no ingredientId
"quantity": 500,
"unit": "g",
"bestBefore": "2026-04-10",
"openedOn": null
}{ "id": "pi-02-...",
"ingredientId": "f1e2-...",
"name": "chicken breast",
"quantity": 500, "unit": "g",
"bestBefore": "2026-04-10",
"openedOn": null }{
"quantity": 250,
"openedOn": "2026-04-07"
}{ "id": "pi-02-...", "quantity": 250,
"openedOn": "2026-04-07", ... }// no body// empty?limit=50&offset=0
?search=jane // by email or name
?isActive=true{
"data": [
{ "id": "550e-...", "email": "[email protected]",
"displayName": "Sarah", "systemRole": "user",
"isActive": true, "createdAt": "..." }
],
"meta": { "pagination": { "total": 24, ... } }
}{
"email": "[email protected]",
"displayName": "New User",
"tempPassword": "Change1Me!",
"systemRole": "user" // default "user"
}{
"id": "new-uuid-...",
"email": "[email protected]",
"displayName": "New User",
"systemRole": "user",
"isActive": true,
"mustChangePassword": true
}{
"displayName": "Jane Smith",
"email": "[email protected]",
"systemRole": "admin",
"isActive": false // deactivate
}{ "id": "...", "email": "[email protected]",
"displayName": "Jane Smith",
"systemRole": "admin", "isActive": false }{
"tempPassword": "Reset1Me!",
"reason": "user requested via support"
}{
"message": "Password reset successfully",
"mustChangePassword": true
}?limit=50&offset=0
?targetUserId=550e-... // filter by user[
{ "id": "al-01-...",
"adminId": "adm-...",
"adminEmail": "[email protected]",
"targetUserId": "550e-...",
"targetEmail": "[email protected]",
"action": "reset_password",
"detail": { "reason": "user requested" },
"performedAt": "2026-04-01T10:05:00Z" }
]1. Authentication: Spring Security 7 session. HttpOnly + Secure + SameSite=Lax cookie. 24h expiry.
2. Role authorization: @PreAuthorize on systemRole (admin) and householdRole (planner vs member). 403 on mismatch.
3. Household isolation: HouseholdContext resolves householdId from session. Every query includes AND household_id = ?. Wrong household → 404.
Role │ Recipes │ Plan │ Shopping list │ Pantry │ Admin ────────────┼──────────┼──────────┼───────────────────┼──────────┼────── Planner │ CRUD │ CRUD │ generate,publish │ CRUD │ — Member │ — │ READ │ read,check,add │ — │ — Admin │ — │ — │ — │ — │ CRUD + audit Unauth │ — │ — │ — │ — │ —
password_hash (@JsonIgnore) · sequential IDs (UUIDs only) · JSESSIONID (HttpOnly) · cross-household data (404, not 403) · audit_log.detail (admin-only)
Spring Initializr (Boot 4.0, Java 21). Flyway migrations. JPA entities. SecurityConfig with session auth + CSRF. Auth endpoints. Recipe CRUD + autocomplete + tags. Heavily AI-generatable.
Household creation + invite flow (J6). Week plan + slot CRUD (J2). Cooking log (J3). Household scoping verified.
SuggestionService · VarietyService · ShoppingListService. The 3 services that need real thinking.