Skip to content

Architecture

RecipeBox follows a Domain-Driven Design (DDD) layered architecture. Dependencies point inward — outer layers depend on inner layers, never the reverse.

Layer Diagram

graph TB
    A[Interface Layer] --> B[Application Layer]
    B --> C[Domain Layer]
    A --> D[Infrastructure Layer]
    B --> D
    D -.->|implements| C

Project Structure

recipebox/
├── main.go                          # Entry point, Cobra root command
├── cmd/                             # CLI subcommands
│   ├── register.go                  # Registers all commands
│   ├── serve.go                     # Start web server
│   ├── list.go                      # List recipes
│   ├── search.go                    # Search recipes
│   └── show.go                      # Show recipe details
├── internal/
│   ├── domain/                      # Core business logic (no dependencies)
│   │   ├── entity/                  # Recipe, Ingredient, Instruction
│   │   ├── repository/              # Repository interface
│   │   └── service/                 # Scraper interface
│   ├── application/                 # Use cases & orchestration
│   │   ├── command/                 # ImportRecipe, DeleteRecipe
│   │   ├── query/                   # GetRecipe, ListRecipes, SearchRecipes
│   │   ├── dto/                     # Data transfer objects
│   │   ├── mapper/                  # Entity <-> DTO mapping
│   │   └── service/                 # RecipeService orchestrator
│   ├── infrastructure/              # External implementations
│   │   ├── database/                # SQLite connection & migrations
│   │   ├── repository/              # SQLite repository implementation
│   │   └── scraper/                 # HTTP fetcher, JSON-LD & WPRM extractors
│   └── interface/web/               # HTTP layer
│       ├── server.go                # Echo setup & routes
│       ├── handler/                 # Request handlers
│       └── template/                # templ templates
├── Dockerfile                       # Multi-stage production build
├── docker-compose.yml               # App + docs services
├── .air.toml                        # Air hot-reload config
├── .github/workflows/docs.yml       # GitHub Pages deployment
├── zensical.toml                    # Documentation config
└── docs/                            # Documentation source

Domain Layer

The innermost layer contains the core business entities and interfaces with zero external dependencies.

Entities

  • Recipe — The aggregate root with fields for title, description, times, servings, cuisine, course, source URL, and more
  • Ingredient — Value object with amount, unit, name, notes, and raw text fallback
  • Instruction — Value object with step number and text
  • ValidatedRecipe — Wrapper ensuring domain invariants are satisfied before persistence

Interfaces (Ports)

  • RecipeRepository — Defines persistence operations (Save, FindByID, FindAll, Search, Delete)
  • RecipeScraper — Defines the scraping contract (ScrapeRecipe)

Application Layer

Orchestrates use cases using CQRS-style separation:

  • CommandsImportRecipeCommand, DeleteRecipeCommand
  • QueriesGetRecipeByIDQuery, ListRecipesQuery, SearchRecipesQuery
  • RecipeService — Coordinates between the scraper and repository

Infrastructure Layer

Implements the domain interfaces:

  • SQLite Repository — Full CRUD with FTS5 search, transactions, and cascade deletes
  • Recipe Scraper — Orchestrates JSON-LD (primary) and WPRM (fallback) extractors
  • HTTP Fetcher — Retrieves web pages for scraping

Interface Layer

Handles HTTP concerns:

  • Echo Server — Route registration and middleware
  • Handlers — Map HTTP requests to application service calls
  • templ Templates — Type-safe HTML generation with Bulma CSS styling

Database

SQLite with three tables (recipes, ingredients, instructions) and two FTS5 virtual tables (recipes_fts, ingredients_fts) kept in sync via triggers. Ingredients and instructions are stored as separate rows rather than JSON to enable full-text search on individual fields.

ER Diagram

erDiagram
    recipes {
        TEXT id PK "UUID v7"
        TEXT title "NOT NULL"
        TEXT description
        TEXT prep_time
        TEXT cook_time
        TEXT total_time
        TEXT servings
        TEXT cuisine
        TEXT course
        TEXT image_url
        TEXT source_url UK "UNIQUE"
        TEXT author
        TEXT nutrition
        DATETIME created_at "NOT NULL"
        DATETIME updated_at "NOT NULL"
    }

    ingredients {
        INTEGER id PK "AUTOINCREMENT"
        TEXT recipe_id FK "NOT NULL"
        TEXT amount
        TEXT unit
        TEXT name
        TEXT notes
        TEXT raw "Original text"
        INTEGER sort_order
    }

    instructions {
        INTEGER id PK "AUTOINCREMENT"
        TEXT recipe_id FK "NOT NULL"
        INTEGER step_number "NOT NULL"
        TEXT text
    }

    recipes_fts {
        TEXT title
        TEXT description
        TEXT cuisine
        TEXT course
        TEXT author
    }

    ingredients_fts {
        TEXT name
        TEXT raw
    }

    recipes ||--o{ ingredients : "has"
    recipes ||--o{ instructions : "has"
    recipes ||..|| recipes_fts : "synced via triggers"
    ingredients ||..|| ingredients_fts : "synced via triggers"

All foreign keys use ON DELETE CASCADE so deleting a recipe removes its ingredients and instructions automatically. The FTS5 virtual tables are kept in sync via AFTER INSERT, AFTER UPDATE, and AFTER DELETE triggers.