Project Architecture

This document gives a high-level overview of the app: what it is, the technologies it's built with, and how the codebase is organized. It covers the user-facing product only.

Overview

This is a personal portfolio app built with Next.js. It has two goals:

  • Present Alejandro's professional experience as an online CV (roles, skills, education, testimonials).
  • Let visitors go deeper through an AI Assistant chat that answers questions about that experience, grounded in a knowledge base, so a recruiter or visitor can explore beyond what's written on the page.

Technologies

TechnologyPurpose
Next.js (App Router)Core framework — routing, server rendering, and API routes
ReactUI library
TypeScriptStatic typing across the codebase
Tailwind CSSStyling
shadcn/ui + Base UIAccessible UI component primitives
MotionAnimations and page/element transitions
Vercel AI SDKStreaming chat UI and model interaction
MastraAI agent framework powering the assistant (agent orchestration, memory, tools)
Mastra ObservabilityTracing and structured logging for AI agent runs, with sensitive data redacted before it's persisted; built to also export to the hosted Mastra Platform for centralized trace review
Drizzle ORM + PostgreSQLDatabase access and migrations
Better AuthAuthentication, including anonymous recruiter sessions
Upstash RedisRate limiting for chat usage
ZodSchema validation
TanStack MarkdownParses knowledge-base documents and renders them to HTML (e.g. this architecture page)

Folder structure

At the root, the project is organized as:

alepalmer-cv/
├─ src/           # Application source code
├─ drizzle/        # Database migrations
├─ docs/           # Project documentation
└─ public/         # Static assets

Inside src/:

FolderRole
app/Next.js routes, layouts, and API handlers
features/Product capabilities (e.g. chat, cv), each owning its own domain, UI, and data access
services/Shared infrastructure used across features (database, auth, AI runtime, CMS, rate limiting)
components/Shared, feature-agnostic UI components
lib/ / utils/Small shared helpers

Feature structure

Each feature under features/<name>/ is organized by concern rather than by file type, typically including:

  • model/ — domain types
  • repository/ — data access and persistence
  • services/ — data access for Server Components, cached where needed (React cache, Next "use cache")
  • components/ — feature-specific UI
  • actions/ — server-side operations the UI triggers
  • util/ — feature-specific helpers

Not every feature includes every slice — only what that feature needs.

Rendering strategy

The main site routes use different rendering strategies depending on how static their content is:

RouteStrategyWhat it means
/ (CV)ISR (Incremental Static Regeneration)The CV content is a document stored in the database — the same one that powers the AI assistant's knowledge. The page is built once and served from cache, and only regenerated when that document changes
/architectureISR (Incremental Static Regeneration)Same idea: this document is also stored in the database, and the page is only regenerated when it changes
/chat, /chat/[threadId]PPR (Partial Prerendering)A static shell is served instantly, while the parts that depend on live data — the initial chat messages and personalized recommendations — stream in as soon as they're ready

Data Modeling

The database is a single PostgreSQL instance, but data is split across three Postgres schemas by ownership:

  • App schema — first-party tables the product owns directly (via Drizzle ORM), covering the CV content, the knowledge-base documents (including this page), and the recruiter job-application flow.
  • Better Auth schema — authentication tables managed by the Better Auth library (users, sessions, and related auth records), also modeled through Drizzle so the app can read/relate against them, but whose shape and migrations are owned by Better Auth.
  • Mastra schema — storage for the AI agent framework, managed entirely by Mastra's own Postgres storage adapter (memory, conversation threads and messages, and observability traces). The app doesn't define these tables itself — it just points Mastra at this schema.

Below is the shape of each table in the app schema. Internal database and column identifiers are intentionally omitted — tables and fields are described by role and shape only.

Two of these tables are worth calling out specifically: the CV document and the knowledge-base document that describes this project's own architecture (this page) are each a single source of truth — the same stored information render the public pages (cv, architecture) and is what the AI assistant reads from when answering questions on that subject, so there's no separate copy for either side to drift out of sync with.

CV Document — the structured content behind the public CV page.

  • Identifier (unique ID, primary key)
  • Version number (integer) — increments on each update
  • Content (JSON) — the structured CV content itself
  • Created-at / updated-at timestamps

Knowledge Base Document — long-form Markdown documents (e.g. this architecture page) that the AI assistant can ground its answers in.

  • Identifier (unique ID, primary key)
  • Title (text)
  • Content (text, Markdown)
  • Summary (text, optional) — used for retrieval/grounding
  • Created-at / updated-at timestamps

Document Category — a tag that documents can be grouped under (e.g. to mark "this is the architecture doc").

  • Name (text, primary key)
  • Created-at timestamp

Document Category Assignment — join table linking documents to categories, many-to-many.

  • Reference to a Knowledge Base Document (cascades on delete)
  • Reference to a Document Category (cascades on delete)
  • Composite primary key across both references

Job Application — a recruiter-facing job posting that a chat session can be scoped to.

  • Identifier (unique ID, primary key)
  • Company name (text)
  • Job position (text)
  • Job description (text)
  • Optional instructions (text) — extra context for the assistant when discussing this role

Clean Architecture & Dependency Inversion

Within a feature, model/, repository/, and the UI-facing layers (actions/, Server Components) don't depend on each other directly — they depend on an abstraction, following the Dependency Inversion Principle at the heart of Clean Architecture. Concretely, this is a model/interface + repository/implementation split, wired together by a small composition root, rather than a runtime dependency-injection container.

The job-application feature as a worked example (a recruiter-facing job posting, and the resource a chat access link is scoped to):

  • model/ owns the domain types (JobApplication, CreateJobApplicationInput, UpdateJobApplicationInput) and the abstraction itself: a JobApplicationRepository interface declaring only the operations the domain needs (create, findById, findByAccessCode, findAll, update, delete), with no mention of SQL, Drizzle, or any storage detail.
  • repository/ owns the concrete implementation: DrizzleJobApplicationRepository implements JobApplicationRepository using Drizzle ORM and Postgres. This class depends inward on the interface defined by model/ — not the other way around.
  • repository/index.ts acts as the feature's composition root. It's the one place that knows a concrete Drizzle... class exists at all:
    export const jobApplicationRepository: JobApplicationRepository =
      new DrizzleJobApplicationRepository();
    The exported constant is deliberately typed as the interface, not the class.
  • Consumers — Server Actions and Server Components — depend only on the abstraction: they import the pre-wired singleton from the composition root, but every reference to it is typed as JobApplicationRepository. Nothing outside repository/ ever names DrizzleJobApplicationRepository directly.

The effect is an inverted dependency graph: the low-level detail (Drizzle, Postgres) depends on the abstraction owned by the domain layer, and the UI/action layer is coupled only to that same abstraction — never to the database technology behind it. Swapping storage engines, or substituting a mock in tests, means writing a new class that satisfies the interface; nothing in model/ or actions/ has to change.

This same shape — interface in model/, implementation in repository/, a one-line composition root in repository/index.ts — repeats across the other features that persist data, just with different names. It's a lightweight, per-feature form of dependency inversion — one interface, one implementation, one hand-written composition root — rather than full hexagonal ports-and-adapters with a runtime DI container. It gets the main practical benefit (domain and UI code stay decoupled from infrastructure) without the ceremony of a framework, which fits a codebase of this size.

AI Agent

The AI assistant that the CV chat is built on is implemented with Mastra (@mastra/core), a TypeScript agent framework, running on top of Vercel's AI SDK for the underlying model plumbing. It's a single agent for user-facing conversations, plus a small internal agent used only at content-authoring time.

Two agents, two purposes

  • The assistant agent is the conversational one. It's equipped with tools, given persistent memory, and instructed by a fixed system prompt to answer questions about Alejandro's professional background — and to decline anything outside that scope (general coding help, opinions, unrelated topics).
  • A summarizer agent exists purely as an authoring-time utility: when a knowledge-base document is created or edited, this tool-less agent is invoked once to produce a short, fixed-format two-line synopsis of it, which is stored alongside the document and later used as the retrieval index (see below). It never participates in a live conversation.

Model selection is designed to go through the Vercel AI Gateway, letting the app choose between models from different providers rather than being locked to one. Regardless of provider, the model is referenced through Mastra's provider-agnostic model resolver rather than a directly-imported provider SDK.

Tools

The assistant agent's knowledge of Alejandro is entirely tool-mediated — nothing about his CV, work history, or a given job application is baked into the system prompt. Instead, the agent decides at conversation time which of the following it needs to call, with each tool's inputs and outputs validated against a schema (Zod):

  • Get CV — returns the current CV document: profile, skills, work history, education, languages, testimonials. This is the "headline" layer — enough breadth to answer most factual questions without going deeper.
  • Get job application — when the conversation is scoped to a specific job application, returns that role's company, position, job description, and any extra instructions left for the assistant. Returns nothing found when the conversation isn't tied to one.
  • Get knowledge-base summaries — returns the full list of knowledge-base documents as (id, two-line summary) pairs. This is the entire "index" the agent has to work with — nothing is filtered or ranked before it reaches the model.
  • Get knowledge base — given a list of document IDs, returns each document's full title, content, and categories, fetched by primary key.

A simplified, LLM-driven retrieval system

The knowledge base is grounded through retrieval, but deliberately not through the machinery usually associated with "RAG": there's no embedding model, no vector database, no chunking, and no similarity search anywhere in the stack. Instead, retrieval is split into two stages:

  1. Indexing, done once per document at authoring time. When a document is saved, the summarizer agent reads its full text and produces a short, structured synopsis — a one-line "situation" and a one-line "themes" — which is persisted as the document's summary. This is the only preprocessing a knowledge-base document ever gets; there's no chunking of long documents into passages.
  2. Retrieval, done live by the conversational agent's own reasoning. The assistant agent first pulls the entire summary index into its context via the summaries tool. It reads all the summaries itself and — by semantic judgment, guided by instructions in its system prompt — decides which document IDs, if any, are relevant to the question at hand. It then calls the knowledge-base tool with just those IDs, which does a straightforward filter-by-primary-key lookup, and the full text of the selected documents comes back as tool results for the agent to ground its answer in.

In short: the LLM itself performs the "search" step, reasoning over hand/LLM-written blurbs rather than vector embeddings, and the result is whole documents rather than ranked passages. It's a retrieval scheme sized for a knowledge base of a personal CV project — a handful of documents — rather than an index built to scale to a large corpus.

System prompt and orchestration

The assistant's instructions are a single static block of text, not assembled or interpolated per request. They define a strict scope gate around what topics the agent will engage with, a description of what each tool is for and when to reach for it, a decision procedure for routing a question to the CV, the job application, or the knowledge base, rules against inferring or hallucinating beyond what a source actually says, and style constraints (e.g. never revealing that answers come from tool calls). Anything that does vary by conversation — like which job application it's scoped to — flows in separately, through a small per-request context object threaded into the relevant tool, rather than being spliced into the prompt text itself; this keeps the prompt fixed regardless of conversation.

Multi-step tool use is handled by Mastra's agent runtime, not by hand-written orchestration code: the framework runs the standard model → tool call → tool result → model loop automatically, including the two-step retrieval flow above (summaries, then selected documents) within a single conversational turn. Conversation state is persisted through Mastra's own memory system, backed by the same Postgres database as the rest of the app, so multi-turn context and thread history survive across requests without any custom persistence logic in the application layer.

Evaluation

Currently there is no evaluation pipeline for the agent but it will be implemented soon.

Quotas

The system relies on a few coarser guardrails: an upper bound on how long an individual message can be, and a cumulative token budget enforced per job application scope, so usage stays bounded without needing to reason about context-window size directly. Every agent run is also traced, with sensitive data stripped from the recorded spans before anything is persisted (see Mastra Observability above).

Security

The AI assistant chat isn't open to the public — access is scoped per job application, and every job application gets its own isolated slice of chat resources. Two concepts matter here, and they're deliberately kept separate: the anonymous session (one visitor's individual identity) and the job application (the shared resource that visitor's session is scoped to).

Anonymous sessions: identifying a visitor

Each job application has a unique, unguessable access link. Visiting the link is read-only — it looks up which job application the link belongs to, but doesn't create any session or grant access by itself. Only once a visitor explicitly continues past that landing step does the app establish an anonymous session (via Better Auth) for that visitor. Creating the session is a server-side decision — the app verifies the link itself and stamps the resulting session with the job application it belongs to; a client can't set or influence that association directly.

An anonymous session belongs to one visitor's browser: if the same visitor returns using the same link, their existing session is reused rather than a new one being minted. Different visitors opening the same link, though, each get their own distinct anonymous session — the link identifies which job application they're accessing, not who they are.

Job applications: the shared resource

Chat history and usage quota are not owned by an individual anonymous session — they're owned by the job application. Every anonymous session that was created from a given job application's link draws on that same job application's chat history and quota, regardless of how many separate visitors/sessions that is. This is an intentional trade-off: the job application, not the individual visitor, is the unit of access and isolation.

Two different job applications are fully isolated from each other — one job application's conversation history, context, and usage never overlaps with another's. Anything the assistant needs to know about a specific role (company, position, job description, extra instructions) is only ever pulled in based on the job application tied to the caller's session — a session can't cause the assistant to surface another job application's data.

Rate limiting

Chat usage is rate-limited per job application using Upstash Redis, budgeting the tokens available to the assistant over a rolling window rather than just capping the number of messages — so the limit is shared across every anonymous session using that job application, consistent with the job application being the resource boundary. Separately, the step where a visitor's link converts into a session is rate-limited by IP, which caps how many sessions a single source can create in a short period — a guard against automated abuse of a link rather than normal recruiter usage.

Other safeguards

  • Route protection: chat routes require a valid session associated with a job application. This check happens centrally, before a request reaches any page or API handler.
  • Input validation: incoming chat messages and the inputs/outputs of every tool the AI assistant can call are validated against schemas (via Zod), and messages have a bounded maximum length.
  • Observability with redaction: AI agent runs are traced and logged (see Mastra Observability above) with sensitive data stripped from spans before anything is persisted.