Who this is for
This post is for Haskell learners who do not want another isolated syntax exercise. The goal is to start a real application that strengthens functional programming, web development, persistence, API integration, and product thinking while also supporting a concrete career outcome.
The example project is Career Trainer on GitHub, a compact Haskell web application for structured career preparation.
What you will build
Career Trainer starts as a personal training system for job readiness. It lets a user define a target role, add learning topics, generate multiple-choice practice questions with OpenAI, and track topic-level progress over time.
That makes it useful as a learning project because the application has real domain pressure: the product must remember data, adapt to user history, survive missing API credentials, and expose a UI that makes preparation visible.
Why Haskell is a good fit for this project
A career trainer looks simple at first, but it has exactly the kinds of boundaries that make Haskell valuable: validated input, explicit data models, predictable state transitions, JSON decoding, database writes, and failure handling. Instead of learning Haskell only through list functions, you learn how types shape a working application.
The current implementation uses GHC 9.12.3, Scotty over WAI and Warp, Lucid2 for type-safe HTML, SQLite through sqlite-simple, the OpenAI Responses API, and Nix flakes for a reproducible development environment.
The first useful version
The repository keeps the first version intentionally small. One executable owns the HTTP routes, database setup, OpenAI request, HTML rendering, and small browser-side controllers. That is not the final architecture for a large product, but it is an excellent first milestone because every moving part remains visible.
| Layer | Current choice | Learning value |
|---|---|---|
| Runtime | GHC 9.12.3 | Compile a modern Haskell executable with strict warnings. |
| Web server | Scotty over WAI/Warp | Learn routing, JSON endpoints, status codes, and middleware. |
| HTML | Lucid2 | Render server-side UI with typed HTML builders. |
| Persistence | SQLite | Practice schema design, inserts, updates, and query mapping. |
| AI integration | OpenAI Responses API | Handle structured output, credentials, network failure, and fallback behavior. |
| Environment | Nix flakes | Make the project reproducible instead of depending on local machine drift. |
Start with reproducibility
The repository is designed to start through Nix. That keeps the learning loop focused on the application rather than on debugging global tool versions.
git clone https://github.com/luisantonioig/career-trainer.git
cd career-trainer
nix runFor an interactive development shell, the README uses:
nix develop
cabal run career-trainerThis is a strong portfolio signal because it tells a reviewer that the project can be built from a declared environment, not from undocumented local setup.
Model the career domain first
A useful career tool needs a specific target. Career Trainer begins with a CareerGoal record containing the target role, industry, work mode, deadline, and success criteria.
data CareerGoal = CareerGoal
{ role :: Text
, industry :: Text
, mode :: Text
, deadline :: Text
, success :: Text
}This is small, but it matters. It gives the application a stable center: every learning topic and practice session should eventually answer one question: does this improve readiness for the target role?
Turn learning into state
The learning workspace stores topics with a level, estimated knowledge percentage, correct answer count, and total answer count. That turns preparation into data the application can update after every practice attempt.
This is where the project becomes more than a wrapper around an AI prompt. The app owns the learning state. The model generates a question, but the Haskell application decides how answers affect progress.
"UPDATE learning_topics
SET total_answers = total_answers + 1,
correct_answers = correct_answers + ?,
knowledge = min(100, max(0, CAST(((correct_answers + ?) * 100.0 / (total_answers + 1)) AS INTEGER))),
level = min(5, max(1, CAST(1 + (((correct_answers + ?) * 100.0 / (total_answers + 1)) / 25) AS INTEGER)))
WHERE id = ?"The formula is intentionally simple in the first version. That is a good starting point because it creates a visible feedback loop without pretending to measure all of career readiness.
Use AI behind a typed boundary
Career Trainer asks OpenAI for a multiple-choice question and expects structured JSON with a question, four options, a correct index, and an explanation. The important design choice is the boundary: the app does not accept arbitrary prose as application state.
data GeneratedQuestion = GeneratedQuestion
{ generatedQuestion :: Text
, generatedOptions :: [Text]
, generatedCorrectIndex :: Int
, generatedExplanation :: Text
}The OpenAI API supports structured response formats with JSON Schema. In this project, that maps well to Haskell: request a constrained shape, decode it, and fall back when the response cannot be used.
The fallback path is important. If OPENAI_API_KEY is not configured, or if the API call fails, the app still returns a local practice question. That keeps the development loop usable and makes failure behavior explicit.
Keep the first route set small
The current app exposes a dashboard, a career-goal editor, a learning workspace, a health check, and JSON APIs for goals, topics, question generation, and answer submission. That is enough surface area to learn the end-to-end path without burying the project under premature framework decisions.
| Capability | Route type | What it teaches |
|---|---|---|
| Dashboard | HTML | Server-rendered navigation and product framing. |
| Career goal | HTML plus API | Form handling, persistence, updates, and deletion. |
| Learning topics | HTML plus API | Collection state, inserts, selection, and progress display. |
| Question generation | API | External API calls, JSON parsing, and recoverable failure. |
| Answer submission | API | Domain updates and feedback based on persisted history. |
Make it portfolio-ready from day one
A project like this can help with job readiness in two ways. First, it gives the user a tool for structured preparation. Second, it gives the developer a concrete artifact that demonstrates taste and engineering judgment.
The portfolio value is not the number of features. It is the clarity of the tradeoffs: a reproducible environment, a typed domain model, durable local persistence, graceful API failure, and a product loop that connects practice to evidence.
Common mistakes
- Starting with a generic chat interface: a career trainer needs memory, goals, and measurable practice, not only a prompt box.
- Letting AI own the product logic: the model should generate practice material, while the app owns progress rules and persistence.
- Skipping fallback behavior: local development should still work when credentials, quota, or network access fail.
- Over-engineering the first release: a compact executable is easier to review while the domain model is still forming.
- Confusing activity with readiness: answer counts are useful signals, but the project should eventually connect practice to real interviews, portfolio proof, and hiring outcomes.
Next iterations I would prioritize
- Add tests around topic-level updates so the progress formula stays intentional.
- Move user-facing strings into one module so the app can support a clean language strategy.
- Add a project README section that shows screenshots, data flow, and the reasoning behind the first architecture.
- Add an exportable weekly readiness report with goals, topics, weak areas, and next actions.
- Split the executable into modules after the boundaries are stable: domain types, database access, OpenAI client, routes, and views.
Build checklist
- The repository builds from a reproducible Nix environment.
- The README explains configuration without exposing secrets.
- The database schema is created automatically for local use.
- Career goals and learning topics persist across restarts.
- OpenAI output is decoded through an explicit data type.
- The app still works when the API key is missing or the request fails.
- The next milestone is small enough to finish and review.
Primary documentation and project links
- Career Trainer repository
- Scotty package documentation
- Lucid2 package documentation
- Nix flake documentation
- OpenAI Responses API reference
Where to go next
After the first working version, the best learning path is to tighten evidence. Add tests, document the architecture, improve the scoring model, and connect practice sessions to artifacts a hiring team can inspect: GitHub commits, portfolio write-ups, code reviews, and interview stories.
For deployment thinking after the application is ready to publish, read the guide on deploying a production site with Vercel and a custom domain and the infrastructure guide for S3, CloudFront, ACM, and DNS.