lundie.io Get In Touch

Phase 2: Architecture Design

16–30 June 2025

Shaping the Graph

A graph was the obvious shape from the start. Honestly, it would have been hard for me to land anywhere else. I'm a long-time mindmap advocate – I use them in my own thinking and have spent years walking students through the process in the classroom. Creative work branches: a single thought might inspire several prompts, an insight might connect two journal entries written months apart, a result might reference multiple prompts at once. A flat list was never going to hold that.

The real design work came in deciding the shape of the graph: what lived on it, how nodes would connect and where to draw the line before it degenerated into an arbitrary connection soup. The first cut was deliberately MVP-shaped – a functional core to test the concept end to end. The full model could come later, once theory became theorem.

The Node System

Rather than a generic "item" type, five node types were defined with specific roles and constraints:

  • ThoughtNode – user-generated content and ideas
  • PromptNode – AI prompts, with optional images
  • InsightNode – derived observations that connect other nodes
  • ResultNode – creative outputs with media attachments
  • JournalNode – reflective writing on the creative process itself

Each type had rules about what it could link to. A Thought could have Prompt children; a Prompt could spawn Insights; an Insight couldn't have a Thought child. The constraints were the point – they kept the graph semantically meaningful.

Edge Semantics

Every edge at this stage was a parent-child connection, and hierarchy was implicit in the data structure rather than marked by a flag. Edges didn't carry an explicit type field either: the semantic meaning of a connection was inferred at read time from the node types at either end – a Thought→Prompt edge meant "inspires," a Prompt→Insight meant "clarifies," and so on. The motivation was to keep edge documents as small as possible, since every extra field costs Firestore reads and writes at scale.

This didn't last. Locking one verb to each node-type pair was too restrictive once the graph had any real use – the same pair of node types sometimes needed to express different relationships. An explicit edge_type enum (includes, inspires, clarifies, expands, evolves) landed in Phase 3. An is_hierarchical flag followed in Phase 6, though every edge was still hierarchical at that point – non-hierarchical edges came later.

Tech Stack

With the shape of the data settled, the stack narrowed to concrete choices:

Backend

  • FastAPI for the async Python API with auto-generated OpenAPI docs
  • Firestore for persistence (chosen over SQLite for the hosted phase)
  • Repository pattern to keep data access isolated from business logic
  • Pydantic for validation and serialisation
  • Firebase Admin SDK for auth, deferred until later phases

Frontend

  • React + TypeScript
  • Vite for the dev server and build
  • Feature-based folder organisation rather than layer-based

Integration

  • OpenAI GPT-4o for text, DALL·E optional for images
  • Two separate repos: inpromptout-api and inpromptout-web
  • Versioned REST endpoints under /api/v1/
  • Mock mode toggle to avoid API costs during development

First Firestore Structure (24 June)

The graph model forced a rethink of the database schema. The initial structure was simple – collections for users, nodes and edges:

Firestore Collections (Initial Design)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
users/
  {user_id}/
    profile: { email, display_name, created_at }

prompts/
  {prompt_id}/
    prompt: string
    image_url: string | null
    tags: string[]
    user_id: string
    created_at: timestamp
    updated_at: timestamp

# thoughts/, insights/, results/ and journals/ each live in their
# own top-level collection. Shapes diverge meaningfully – results
# carry media arrays rather than inline content, journals act as
# containers with visibility flags, and so on.

edges/
  {edge_id}/
    from_node: string
    to_node: string
    user_id: string
    metadata: { ... }
    created_at: timestamp
    # edge_type added in Phase 3; is_hierarchical flag added in Phase 6.
    # Initially the verb was inferred from the node-type pair, and every
    # edge was a parent-child connection.
    

Architectural Decisions

Repository pattern

All Firestore access would go through repository classes. The main reason was testability – the service layer could be exercised against in-memory fakes without hitting Firestore at all. The secondary benefit was having one place to handle data transformation between Firestore documents and API models.

Service layer

Business logic would live in services, not in routers or repositories. Routers would only handle HTTP concerns; services would handle node capability rules, graph traversal, and later the AI context logic. This was a conventional three-layer separation, but committing to it early avoided the usual drift where business rules end up scattered across route handlers.

Strict typing on both sides

Pydantic on the backend, TypeScript on the frontend, no any in production code. This was partly a learning choice – I was new to React and wanted the compiler to catch as much as possible while I found my feet.

Two-Repo Setup

Backend and frontend were kept in separate repositories from the start:

Repository Structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
inpromptout/               # Root directory
├── inpromptout-api/       # FastAPI backend
│   ├── app/
│   │   ├── api/v1/        # API routers
│   │   ├── db/            # Repository classes
│   │   ├── model/         # Pydantic schemas
│   │   ├── services/      # Business logic
│   │   └── main.py        # FastAPI app
│   ├── tests/             # pytest test suite
│   └── requirements.txt
│
├── inpromptout-web/       # React frontend
│   ├── src/
│   │   ├── features/      # Feature modules
│   │   │   ├── auth/
│   │   │   ├── editor/
│   │   │   ├── nodes/
│   │   │   └── tree/
│   │   ├── shared/        # Shared utilities
│   │   └── main.tsx       # React entry point
│   ├── public/
│   └── package.json
│
└── CLAUDE.md              # Project guidance
    

Separate repos allowed for independent deployment targets, different toolchains without config clashes and a clean API contract where the backend had no implicit knowledge of frontend structure. The downside would bite later (having to coordinate changes across two repos during schema shifts), but it was a trade-off I accepted deliberately.

What Wasn't Decided Yet

  • How to handle optimistic updates in the frontend – this became critical in Phase 4
  • Concrete UI/UX patterns for the tree component
  • Auth implementation details (Firebase Auth was decided, the rest was deferred)
  • AI context building rules – ownership controls didn't arrive until Phase 9

What This Phase Established

By the end of June, the architecture was set: a graph-shaped data model, a three-layer backend with strict typing and a two-repo split. None of this had been tested against real usage yet, and several of the deferred decisions would generate significant work later. But there was enough structure to start building in earnest.

Get In Touch

Prefer using email? Say hi at hello@lundie.io