After nearly 20 years leading teams in hospitality and logistics, I made a career change into software development. When it came time to build my portfolio, I made a deliberate choice: it would not be just a collection of screenshots and technology lists. It would be a working demonstration of how I think about software architecture.
This is the story of how I built it, the architectural decisions I made, and why I believe a portfolio should show process, not just results.
The Core Architecture: Separation of Concerns Done Right
The project follows a strict three-layer architecture with unidirectional dependencies:
Presentation (React components) -> Logic (pure functions) -> Data (typed static arrays)
No layer knows about the layers above it. Data is data. Logic is logic. Presentation is presentation.
The Data Layer: Typed and Explicit
Every piece of data in the portfolio has an explicit TypeScript interface. Here is the Project interface:
export interface Project {
name: string;
description: "string;"
tech: string[];
github: string;
live: string | null;
impact?: string;
role?: string;
enName?: string;
enDescription?: string;
challenge?: string;
enChallenge?: string;
solution?: string;
enSolution?: string;
architecture?: string;
enArchitecture?: string;
slug: string;
erdPath?: string;
snippetPaths?: string[];
}
Every field is typed. Every optional field is marked with ?. No any anywhere. The data files in src/data/ contain nothing but arrays and interfaces. No logic, no side effects, no React imports.
The Logic Layer: Pure Functions
All business logic lives in src/lib/ as pure functions. Here is an example: a function that takes raw TimelineItem data and calculates normalized percentage positions (0-100%) for the visual timeline layout:
export function calculateTimelinePositions(items: TimelineItem[]): TimelineItem[] {
// Parse dates from Spanish month names
const sortedTimelineData = items
.map(item => ({
...item,
sortDate: parseSpanishDate(item.startDateStr)
}))
.sort((a, b) => {
if (!a.sortDate || !b.sortDate) return 0;
if (a.sortDate.year !== b.sortDate.year) {
return a.sortDate.year - b.sortDate.year;
}
return a.sortDate.month - b.sortDate.month;
})
.map(item => {
const { sortDate, ...rest } = item;
return rest;
});
// Find the global min/max dates to normalize
const allDatesRaw = timelineData.flatMap(item => [
parseSpanishDate(item.startDateStr),
parseSpanishDate(item.endDateStr)
]);
const allDates = allDatesRaw.filter(
(d): d is { year: number; month: number } => d != null
);
// ... calculates percentage positions
// startPos = (startMonthsFromMin / totalMonths) * 100
}
Key details:
- The function takes data in, returns data out. No side effects.
- It uses a TypeScript type guard
(d): d is { year: number; month: number }instead of type assertions. - The percentage calculation is pure math: position = (time since min date) / (total timespan) * 100.
- This is trivially testable without mocks.
The Presentation Layer: Smart Containers, Dumb Components
Components follow the container-presentational pattern. Containers manage state and pass props down. Presentational components just render.
// Timeline.tsx (container)
"use client";
import { rawTimelineData } from "@/data/timelineData";
import TimelineDesktop from "@/components/timeline/TimelineDesktop";
import TimelineMobile from "@/components/timeline/TimelineMobile";
import { useGlobalLang } from '@/hooks/useGlobalLang'
export default function Timeline() {
const { lang } = useGlobalLang()
return (
<section className="py-20 bg-slate-900 overflow-hidden">
<div className="w-full">
<h2 className="text-3xl font-bold mb-16 text-white text-center">
{t(lang, 'home.timelineTitle')}
</h2>
<TimelineDesktop items={rawTimelineData} />
<TimelineMobile items={rawTimelineData} />
</div>
</section>
);
}
The container decides which sub-component to render based on screen size. The sub-components (TimelineDesktop, TimelineMobile) receive data via props and just render it. No logic, no data fetching, no side effects.
The Bilingual System: CustomEvent Instead of Context
Instead of using React Context for language state (which causes unnecessary re-renders across the entire tree), I used a lightweight approach with localStorage and a global CustomEvent:
"use client";
export function useGlobalLang(): { lang: Lang; setLang: (l: Lang) => void } {
const [lang, setLangState] = useState<Lang>('es')
const setLang = (l: Lang) => {
if (l !== 'es' && l !== 'en') return // runtime type guard
setLangState(l)
localStorage.setItem('lang', l)
}
useEffect(() => {
const onLangChanged = () => {
const v = localStorage.getItem('lang') as Lang | null
if (v === 'es' || v === 'en') setLangState(v)
}
window.addEventListener('langChanged', onLangChanged)
return () => window.removeEventListener('langChanged', onLangChanged)
}, [])
return { lang, setLang }
}
Why this approach?
- No Context means no tree re-renders when language changes.
- The
CustomEventallows any component to trigger a language sync from anywhere. - The inline
if (l !== 'es' && l !== 'en')guard catches invalid values at runtime, not just compile time. - Components opt into language reactivity only when they use the hook.
The Modal: Loading Code Snippets Asynchronously
Each project has associated code snippets stored in public/snippets/. The modal fetches them using Promise.all on mount:
"use client";
export default function Modal({ project, onClose }: ModalProps) {
const { lang } = useGlobalLang();
const [activeTab, setActiveTab] = useState<
'challenge' | 'solution' | 'architecture' | 'snippets'
>('challenge');
const [snippetsContent, setSnippetsContent] = useState<
{path: string, content: string}[]
>([]);
// Escape key handling and async snippet loading
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Snippets are fetched asynchronously when tab switches
// using Promise.all for parallel loading
}
The modal uses createPortal from React DOM to render outside the component tree, and Framer Motion for enter/exit animations. Focus management and keyboard navigation are handled with useEffect.
Visual Documentation: ERD Diagrams in SVG
Every project includes an Entity-Relationship Diagram as an SVG file. These are not third-party screenshots. They are hand-drawn SVGs designed to match the portfolio's dark theme (#0f172a background).
The SVG files live in public/erd/ and are referenced by each project's erdPath field. The modal renders them inline, allowing zoom and pan without external dependencies.
Currently, five ERDs are available:
- Security Header Scanner (7 parallel checkers architecture)
- Bolsa de Empleo (NestJS + PostgreSQL job platform)
- FoodBites (Spring Boot food truck management)
- Gestor de Huertos (urban garden management)
- Portfolio itself (showing how all data layers connect)
The Technology Stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | Hybrid static/server rendering, auto code splitting |
| UI | React 19 with React Compiler | Automatic optimization, no manual useMemo |
| Language | TypeScript (strict: true) | Full type safety, no escape hatches |
| Styling | Tailwind CSS v4 | Utility-first, zero unused CSS in production |
| Animations | Framer Motion | Accessible, performant animation primitives |
| Deployment | Vercel | Optimized for Next.js, global CDN |
The tsconfig.json is configured with strict: true, moduleResolution: "bundler", and path aliases (@/* to ./src/*).
Lessons Learned
Start with architecture, not code. Before writing a single component, I planned the three-layer structure. This saved me from the "spaghetti refactor" that hits most side projects around month two.
Pure functions are a superpower. Every function in
src/lib/is testable without mocks. When I add features, I don't break existing logic. When I refactor components, the logic stays intact.TypeScript strict mode is not optional. The type guard pattern (
(d): d is T) is far safer than type assertions (as T). The compiler becomes your best code reviewer.Documentation belongs alongside code. The ERD diagrams are not in a separate wiki. They are in
public/erd/, referenced from the project data, and rendered in the same UI as the code. If the data model changes, the diagram is right there to update.Bilingual content adds complexity, but not with the right abstraction. The
useGlobalLanghook is 37 lines. The data layer uses optionalen*fields. Thet()function is a lookup. The whole i18n system is under 100 lines total.
What About the Career Change?
I spent 20 years managing teams in hospitality and logistics before moving into tech. That experience taught me things no bootcamp can teach: leading under pressure, communicating across roles, and understanding that code serves a business objective, not the other way around.
My portfolio is a reflection of that approach. It is not just a collection of projects. It is an argument for a certain way of thinking about software: layered, typed, documented, and built to last.
Links
- Portfolio: andres-caso-iglesias.vercel.app
- GitHub: Andres-Caso-Iglesias/portafolio
- LinkedIn: andrescasoiglesias
Top comments (0)