DEV Community

Cover image for Building a Portfolio That Actually Demonstrates Software Engineering
Andrés Caso Iglesias
Andrés Caso Iglesias

Posted on

Building a Portfolio That Actually Demonstrates Software Engineering


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)
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

Why this approach?

  • No Context means no tree re-renders when language changes.
  • The CustomEvent allows 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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Bilingual content adds complexity, but not with the right abstraction. The useGlobalLang hook is 37 lines. The data layer uses optional en* fields. The t() 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


Top comments (0)