Skip to content

How I Rebuilt My Programming Fundamentals After Graduating (Practical Guide)

I stared at the job posting. “Strong fundamentals required.” Four words that made my stomach tighten. I had the degree, the projects, the GitHub profile. But somewhere deep down, I knew the truth: I had survived university without truly building the foundation.

My friends who aced their courses? They already had jobs. Me? I was still applying, still interviewing, still getting rejected with the same feedback: “You seem to lack depth in your understanding.”

The Gap I Didn’t Want to Admit

Let me be honest about what “survival mode” in CS actually looks like:

  • Copying Stack Overflow answers without understanding why they worked
  • Using AI to generate code I couldn’t explain in an interview
  • Passing assignments by brute-forcing solutions, not by designing them
  • Knowing what a hash table is, but not when to use one

The worst part? This isn’t unique to me. I’ve talked to dozens of recent graduates who feel the same way. We have the vocabulary, but not the fluency. We can read code, but writing it from scratch? That’s where the confidence cracks.

The turning point came when I tried to build a simple authentication system and realized I couldn’t explain the difference between encrypting and hashing a password. Not in a way that would survive a technical interview, anyway.

The Approach That Actually Worked

I tried re-reading textbooks. I tried taking online courses. None of it stuck because none of it required me to struggle.

What finally worked was deceptively simple: build small, complete things without AI, without tutorials, without copy-paste.

Here’s the framework I followed:

Tier 1: Authentication Fundamentals (Weeks 1-2)

I decided to build a complete auth system from scratch. No Passport.js, no Devise, no Firebase Auth. Just me, the language docs, and the bcrypt library.

auth.js
// The goal: understand EVERY line of this flow
import bcrypt from 'bcrypt';
import crypto from 'crypto';
// First thing I got wrong: I tried to "encrypt" passwords
// Wrong approach - encryption is reversible
// Correct approach: hashing is one-way
async function hashPassword(plainPassword) {
// Why 12 rounds? Each +1 doubles the time
// I learned this when my login took 5 seconds on 15 rounds
const salt = await bcrypt.genSalt(12);
return bcrypt.hash(plainPassword, salt);
}
// The "a-ha" moment: salt isn't just random characters
// It's unique per password, stored WITH the hash
// This is why bcrypt.hash() includes the salt in the output

The struggle point that taught me the most: timing attacks. I didn’t even know this was a thing until I read the bcrypt docs more carefully.

timing-safe-compare.js
// WRONG: I initially wrote this
function comparePasswords(input, stored) {
return input === stored; // Exits early on first mismatch!
}
// RIGHT: Constant-time comparison
function comparePasswords(input, stored) {
return crypto.timingSafeEqual(
Buffer.from(input),
Buffer.from(stored)
);
}
// This took me 3 hours to understand WHY it matters:
// Attackers can measure response time to guess password length
// Constant-time prevents this information leak

I could have just used bcrypt.compare() and moved on. But forcing myself to understand the timing attack vulnerability made me realize how much I didn’t know about security fundamentals.

Tier 2: REST API with Proper Error Handling (Weeks 3-4)

Next, I built a task management API. I thought this would be easy. I was wrong.

tasks-controller.js
// My first attempt at error handling
async function getTask(req, res) {
const task = await db.findTask(req.params.id);
if (!task) {
// I returned 404, but didn't handle the case where id is invalid
// Like... what if req.params.id is "undefined" or "{}"?
return res.status(404).json({ error: 'Not found' });
}
return res.json(task);
}

The edge cases I didn’t anticipate:

test-output.txt
GET /tasks/undefined → 404 (but should be 400)
GET /tasks/1;DROP TABLE → SQL error (I wasn't sanitizing)
POST /tasks → 201 (good)
POST /tasks (missing fields) → 500 (should be 400)

I had to rebuild my error handling three times before I got it right:

error-middleware.js
// Final version - handles validation, not found, and server errors
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Distinguishes expected vs unexpected errors
}
}
function handleErrors(err, req, res, next) {
// 400: Bad Request - invalid input
// 404: Not Found - valid format but doesn't exist
// 409: Conflict - duplicate resource
// 500: Internal Server Error - something I didn't catch
const statusCode = err.statusCode || 500;
const message = err.isOperational ? err.message : 'Something went wrong';
// Log unexpected errors, but don't expose to users
if (!err.isOperational) {
console.error('Unexpected error:', err);
}
return res.status(statusCode).json({ error: message });
}

The key insight: status codes are a language. Learning when to use 400 vs 404 vs 409 vs 500 taught me more about HTTP than any tutorial ever did.

Tier 3: Database Fundamentals (Weeks 5-6)

This is where things got real. I had used ORMs for everything. Writing raw SQL? Terrifying.

schema.sql
-- My first attempt at a tasks table
CREATE TABLE tasks (
id INTEGER,
title TEXT,
completed BOOLEAN,
user_id INTEGER
);
-- What I got wrong:
-- 1. No PRIMARY KEY (how would I update a specific task?)
-- 2. No NOT NULL constraints
-- 3. No foreign key relationship
-- 4. No indexes

When I tried to query this, I realized my mistakes:

queries.sql
-- This query was SLOW on 10,000 tasks
SELECT * FROM tasks WHERE user_id = 5;
-- Why? No index on user_id
-- Database had to scan every row
-- The fix:
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
-- Now the query uses the index instead of scanning
-- I verified this with EXPLAIN ANALYZE

The exercise that taught me the most: implementing a many-to-many relationship for task tags.

many-to-many.sql
-- WRONG: I tried storing tags as a comma-separated string
-- "frontend,urgent,bug" in a TEXT column
-- This violates first normal form
-- Can't efficiently query for "all tasks with 'urgent' tag"
-- RIGHT: Proper junction table
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
user_id INTEGER REFERENCES users(id)
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE task_tags (
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
-- Now I can query efficiently:
SELECT t.* FROM tasks t
JOIN task_tags tt ON t.id = tt.task_id
JOIN tags tag ON tt.tag_id = tag.id
WHERE tag.name = 'urgent';

This forced me to understand JOINs at a fundamental level. Not just “how to write a JOIN,” but “what the database is actually doing when I JOIN.”

Tier 4: Integration (Weeks 7-8)

The final step was combining everything into a single application:

  • Authentication with proper password hashing
  • REST API with consistent error handling
  • Database with proper relationships and indexes

The integration phase revealed gaps I didn’t know existed. For example: how do I handle database connection errors in the middle of a user registration?

registration-with-errors.js
async function registerUser(email, password) {
// What if the database is temporarily down?
// What if the email already exists?
// What if password hashing takes too long?
try {
const hashedPassword = await hashPassword(password);
const user = await db.createUser({
email,
password: hashedPassword
});
return { success: true, user };
} catch (error) {
// I had to learn to distinguish between:
// 1. Validation errors (user's fault, show message)
// 2. Database errors (our fault, generic message)
// 3. Unexpected errors (log and alert)
if (error.code === '23505') { // PostgreSQL unique violation
throw new AppError('Email already registered', 409);
}
if (error.code === 'ECONNREFUSED') {
throw new AppError('Service temporarily unavailable', 503);
}
throw error; // Will be caught by global handler
}
}

The Rules I Followed

I set some strict boundaries for myself:

  1. No AI code generation - I could ask AI to explain concepts, but not to write code
  2. The 30-minute rule - If stuck, struggle for 30 minutes before seeking help
  3. Understand every line - If I can’t explain it to a beginner, I don’t understand it
  4. Build, don’t read - One project teaches more than ten tutorials

The struggle is where the learning happens. Every “I don’t know how to do this” moment forced me to actually understand, not just copy.

What Changed

After eight weeks, three things happened:

  1. Interview confidence - I could explain why I made decisions, not just what the code does
  2. Code review skills - I started spotting issues in others’ code because I had made those same mistakes
  3. Learning speed - When I encountered new problems, I had mental models to fit them into

The fundamentals didn’t just “come back” - they became intuitive. I stopped thinking about how to build things and started thinking about what to build.

Where to Start

If you’re in the same position I was, start here:

  1. Pick one area - Don’t try to learn everything. Auth, APIs, and databases are the core triad
  2. Build without shortcuts - No frameworks, no generated code, just you and the docs
  3. Embrace the struggle - If it feels easy, you’re not learning
  4. Finish what you start - A complete small project beats an abandoned large one

The discomfort you feel when you can’t just copy-paste a solution? That’s exactly where the growth happens.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments